관리자 모달 프레임 공통화
This commit is contained in:
44
components/admin/AdminModalFrame.vue
Normal file
44
components/admin/AdminModalFrame.vue
Normal 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>
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||||
|
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
|
||||||
| components/admin/AdminToast.vue | 관리자 공통 우측 상단 토스트 표시(성공·오류·안내 톤, 화면별 상단 위치 조정) |
|
| components/admin/AdminToast.vue | 관리자 공통 우측 상단 토스트 표시(성공·오류·안내 톤, 화면별 상단 위치 조정) |
|
||||||
| components/admin/AdminSearchInput.vue | 관리자 공통 검색 입력(돋보기 아이콘, IME 검색 이벤트 전달, 외부 focus/select 노출) |
|
| components/admin/AdminSearchInput.vue | 관리자 공통 검색 입력(돋보기 아이콘, IME 검색 이벤트 전달, 외부 focus/select 노출) |
|
||||||
| components/admin/AdminSelectInput.vue | 관리자 공통 select 입력(문자·숫자 옵션 값, 기본 필터·정렬 옵션, chevron 아이콘, `v-model` 전달) |
|
| components/admin/AdminSelectInput.vue | 관리자 공통 select 입력(문자·숫자 옵션 값, 기본 필터·정렬 옵션, chevron 아이콘, `v-model` 전달) |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||||
- 관리자 UI 공통 컴포넌트 정리.
|
- 관리자 UI 공통 컴포넌트 정리.
|
||||||
- 모달 패널, 필터 팝오버의 radius·shadow·z-index·색상 규칙을 공통화.
|
- 필터 팝오버의 radius·shadow·z-index·색상 규칙을 공통화.
|
||||||
- 멤버 고급 필터와 설정 화면 select 입력의 공통 컴포넌트 적용 범위 검토.
|
- 멤버 고급 필터와 설정 화면 select 입력의 공통 컴포넌트 적용 범위 검토.
|
||||||
- 최근 브라우저 회귀 테스트 추가.
|
- 최근 브라우저 회귀 테스트 추가.
|
||||||
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.11
|
||||||
|
|
||||||
|
- 관리자 일반 모달 오버레이·패널을 공용 `AdminModalFrame` 컴포넌트로 분리.
|
||||||
|
- 미디어 폴더 생성과 네비게이션 생성·페이지 섹션·페이지 순서·추천 사이트 편집 모달의 반복 wrapper 제거.
|
||||||
|
|
||||||
## v1.11.10
|
## v1.11.10
|
||||||
|
|
||||||
- 관리자 페이지네이션 계산 로직을 `lib/admin-pagination.js` 순수 함수로 분리.
|
- 관리자 페이지네이션 계산 로직을 `lib/admin-pagination.js` 순수 함수로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.10",
|
"version": "1.11.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.10",
|
"version": "1.11.11",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.10",
|
"version": "1.11.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -1663,46 +1663,43 @@ watch(() => mediaPagination.value.page, (page) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AdminModalFrame
|
||||||
v-if="isCreateFolderModalOpen"
|
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"
|
labelled-by="media-folder-modal-title"
|
||||||
role="dialog"
|
root-class="admin-media__folder-create-modal"
|
||||||
aria-modal="true"
|
panel-class="admin-media__folder-create-panel max-w-sm"
|
||||||
aria-labelledby="media-folder-modal-title"
|
@close="closeCreateFolderModal"
|
||||||
@click.self="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 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
|
새 폴더
|
||||||
새 폴더
|
</h3>
|
||||||
</h3>
|
<p class="mt-1 text-xs text-muted">
|
||||||
<p class="mt-1 text-xs text-muted">
|
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
||||||
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
</p>
|
||||||
</p>
|
<label class="mt-4 grid gap-1.5 text-sm">
|
||||||
<label class="mt-4 grid gap-1.5 text-sm">
|
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
||||||
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
<input
|
||||||
<input
|
v-model="createFolderModalName"
|
||||||
v-model="createFolderModalName"
|
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
type="text"
|
||||||
type="text"
|
placeholder="폴더 이름"
|
||||||
placeholder="폴더 이름"
|
@keydown.enter.prevent="submitCreateFolderModal"
|
||||||
@keydown.enter.prevent="submitCreateFolderModal"
|
>
|
||||||
>
|
</label>
|
||||||
</label>
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
<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 class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
|
취소
|
||||||
취소
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
type="button"
|
||||||
type="button"
|
:disabled="!createFolderModalName.trim()"
|
||||||
:disabled="!createFolderModalName.trim()"
|
@click="submitCreateFolderModal"
|
||||||
@click="submitCreateFolderModal"
|
>
|
||||||
>
|
만들기
|
||||||
만들기
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</AdminModalFrame>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedMedia"
|
v-if="selectedMedia"
|
||||||
|
|||||||
@@ -2101,133 +2101,127 @@ const saveNavigation = async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AdminModalFrame
|
||||||
v-if="navCreateModalType"
|
v-if="navCreateModalType"
|
||||||
class="admin-navigation__create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
|
labelled-by="admin-navigation-create-title"
|
||||||
role="dialog"
|
root-class="admin-navigation__create-modal"
|
||||||
aria-modal="true"
|
panel-class="admin-navigation__create-modal-panel max-w-lg"
|
||||||
aria-labelledby="admin-navigation-create-title"
|
@close="closeNavCreateModal"
|
||||||
@click.self="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">
|
||||||
<div class="admin-navigation__create-modal-head">
|
<h3 id="admin-navigation-create-title" class="text-lg font-semibold text-ink">
|
||||||
<h3 id="admin-navigation-create-title" class="text-lg font-semibold text-ink">
|
{{ navCreateModalTitle }}
|
||||||
{{ navCreateModalTitle }}
|
</h3>
|
||||||
</h3>
|
<p class="mt-1 text-xs text-muted">
|
||||||
<p class="mt-1 text-xs text-muted">
|
제목과 URL을 입력한 뒤 추가하면 목록에 반영됩니다. 최종 반영은 네비게이션 저장으로 완료합니다.
|
||||||
제목과 URL을 입력한 뒤 추가하면 목록에 반영됩니다. 최종 반영은 네비게이션 저장으로 완료합니다.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-navigation__create-modal-fields mt-5 grid gap-4">
|
<div class="admin-navigation__create-modal-fields mt-5 grid gap-4">
|
||||||
<label class="grid gap-1.5 text-sm">
|
<label class="grid gap-1.5 text-sm">
|
||||||
<span class="text-xs font-semibold text-muted">라벨</span>
|
<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>
|
|
||||||
<input
|
<input
|
||||||
v-model="pageSectionCreateName"
|
v-model="navCreateDraft.label"
|
||||||
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="80"
|
placeholder="메뉴 이름"
|
||||||
placeholder="예: 게임"
|
|
||||||
required
|
required
|
||||||
@keydown.enter.prevent="addPageSection"
|
|
||||||
>
|
>
|
||||||
</label>
|
</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">
|
<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="closePageSectionCreateModal">
|
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeNavCreateModal">
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="savingPageSectionId === 'new' || !pageSectionCreateName.trim()"
|
:disabled="!navCreateDraft.label.trim() || !getNavCreateDraftUrl()"
|
||||||
@click="addPageSection"
|
@click="applyNavCreate"
|
||||||
>
|
>
|
||||||
{{ savingPageSectionId === 'new' ? '추가 중' : '추가' }}
|
추가
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</AdminModalFrame>
|
||||||
</div>
|
|
||||||
|
<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
|
<AdminConfirmModal
|
||||||
:open="recommendedDiscardModalOpen"
|
:open="recommendedDiscardModalOpen"
|
||||||
@@ -2254,253 +2248,247 @@ const saveNavigation = async () => {
|
|||||||
</p>
|
</p>
|
||||||
</AdminConfirmModal>
|
</AdminConfirmModal>
|
||||||
|
|
||||||
<div
|
<AdminModalFrame
|
||||||
v-if="pageOrderModalSection"
|
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"
|
labelled-by="admin-navigation-page-order-title"
|
||||||
role="dialog"
|
root-class="admin-navigation__page-order-modal"
|
||||||
aria-modal="true"
|
panel-class="admin-navigation__page-order-modal-panel max-w-4xl"
|
||||||
aria-labelledby="admin-navigation-page-order-title"
|
@close="closePageOrderModal"
|
||||||
@click.self="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 class="admin-navigation__page-order-modal-head flex items-start justify-between gap-4">
|
<div>
|
||||||
<div>
|
<h3 id="admin-navigation-page-order-title" class="text-lg font-semibold text-ink">
|
||||||
<h3 id="admin-navigation-page-order-title" class="text-lg font-semibold text-ink">
|
페이지 순서 변경
|
||||||
페이지 순서 변경
|
</h3>
|
||||||
</h3>
|
<p class="mt-1 text-xs text-muted">
|
||||||
<p class="mt-1 text-xs text-muted">
|
{{ pageOrderModalSection.name }} 섹션의 홈 표시 순서를 조정합니다.
|
||||||
{{ pageOrderModalSection.name }} 섹션의 홈 표시 순서를 조정합니다.
|
</p>
|
||||||
</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>
|
||||||
|
<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 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>
|
||||||
|
|
||||||
<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">
|
<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
|
<article
|
||||||
v-for="(page, index) in pageOrderDraftPages"
|
v-for="(page, index) in pageOrderDraftPages"
|
||||||
:key="page.id"
|
: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="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)"
|
:class="pageOrderRowClass(page.id)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="onPageOrderDragStart($event, page.id)"
|
@dragstart="onPageOrderDragStart($event, page.id)"
|
||||||
@dragover="onPageOrderDragOver($event, page.id)"
|
@dragover="onPageOrderDragOver($event, page.id)"
|
||||||
@drop="onPageOrderDrop($event, page.id)"
|
@drop="onPageOrderDrop($event, page.id)"
|
||||||
@dragend="onPageOrderDragEnd"
|
@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>
|
<figure class="admin-navigation__page-order-cover aspect-[3/4] overflow-hidden rounded-[6px] border border-line bg-[#f5f5f2]">
|
||||||
<span
|
<img
|
||||||
v-if="page.isFeatured"
|
v-if="getPageOrderCardImage(page)"
|
||||||
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"
|
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">
|
||||||
</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">
|
|
||||||
{{ page.listingTitle || page.title || '제목 없음' }}
|
{{ page.listingTitle || page.title || '제목 없음' }}
|
||||||
</h4>
|
</div>
|
||||||
<p class="admin-navigation__page-order-slug mt-1 truncate font-mono text-[10px] text-muted">
|
</figure>
|
||||||
/pages/{{ page.slug }}
|
<h4 class="admin-navigation__page-order-title mt-2 line-clamp-2 min-h-[2rem] text-xs font-semibold leading-tight text-ink">
|
||||||
</p>
|
{{ page.listingTitle || page.title || '제목 없음' }}
|
||||||
<button
|
</h4>
|
||||||
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"
|
<p class="admin-navigation__page-order-slug mt-1 truncate font-mono text-[10px] text-muted">
|
||||||
type="button"
|
/pages/{{ page.slug }}
|
||||||
@click.stop="removePageFromOrderSection(page)"
|
</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"
|
||||||
</button>
|
type="button"
|
||||||
</article>
|
@click.stop="removePageFromOrderSection(page)"
|
||||||
</div>
|
>
|
||||||
|
섹션에서 제거
|
||||||
|
</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">
|
<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 }}개 페이지는 저장하면 이 섹션에서 제거됩니다.
|
{{ 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>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-navigation__page-order-actions mt-6 flex justify-end gap-2">
|
<div class="admin-navigation__recommended-modal-preview mt-5 flex items-center gap-3 rounded border border-line bg-[#f9f9f7] p-3">
|
||||||
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageOrderModal">
|
<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>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="savingPageOrder || (pageOrderDraftPages.length === 0 && pageOrderRemovedCount === 0)"
|
:disabled="!recommendedEditDraft.label.trim() || !getRecommendedDraftUrl(recommendedEditDraft)"
|
||||||
@click="savePageOrder"
|
@click="applyRecommendedEdit"
|
||||||
>
|
>
|
||||||
{{ savingPageOrder ? '저장 중' : '저장' }}
|
적용
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</AdminModalFrame>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<AdminToast :toast="toast" />
|
<AdminToast :toast="toast" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user