미디어 그리드 렌더링 분리
This commit is contained in:
135
components/admin/AdminMediaGrid.vue
Normal file
135
components/admin/AdminMediaGrid.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: 'library'
|
||||
},
|
||||
getMediaBadgeClass: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaBadgeLabel: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaGridPreviewUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaItemKind: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getMediaUsageBadgeCount: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedUrls: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'drag-start',
|
||||
'image-error',
|
||||
'open-detail',
|
||||
'toggle-selection'
|
||||
])
|
||||
|
||||
/**
|
||||
* 미디어 선택 여부를 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 선택 여부
|
||||
*/
|
||||
const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-media-grid admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<div
|
||||
v-for="(item, index) in props.items"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
:draggable="props.activeTab === 'library'"
|
||||
@dragstart="emit('drag-start', $event, item)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
|
||||
:class="isMediaSelected(item)
|
||||
? 'border-white bg-[#15171a] text-white'
|
||||
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
|
||||
:aria-label="`${item.name} 선택`"
|
||||
:aria-pressed="isMediaSelected(item)"
|
||||
@click.stop="emit('toggle-selection', item, index, $event)"
|
||||
>
|
||||
<svg
|
||||
v-if="isMediaSelected(item)"
|
||||
class="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__thumb flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
@click="emit('open-detail', item)"
|
||||
>
|
||||
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
|
||||
<img
|
||||
v-if="props.getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="props.getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="emit('image-error', $event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="props.getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
>
|
||||
{{ props.getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.getMediaUsageBadgeCount(item)"
|
||||
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 z-[1] rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ props.getMediaUsageBadgeCount(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.getMediaBadgeLabel(item)"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 z-[1] rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="[
|
||||
props.getMediaBadgeClass(item),
|
||||
props.getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
|
||||
]"
|
||||
>
|
||||
{{ props.getMediaBadgeLabel(item) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -191,6 +191,7 @@
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||
| components/admin/AdminMediaFolderPanel.vue | 관리자 미디어 화면의 폴더 사이드바(라이브러리 폴더 트리·폴더별 개수·드래그 드롭 이동·폴더 삭제 메뉴·폴더 추가 버튼·시스템 이미지 탭 안내 패널) |
|
||||
| components/admin/AdminMediaGrid.vue | 관리자 미디어 화면의 그리드 카드 목록(미디어 선택 체크·이미지/영상/파일 썸네일·사용처 개수·상태 배지·드래그 시작·상세 열기 이벤트) |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 설정 패널 상태와 이벤트 연결, 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPostSettingsSidebar.vue | 관리자 게시물 작성/수정 우측 설정 사이드바(Post URL·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- 거대 컴포넌트 책임 분리 진행.
|
||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||
- `pages/admin/media/index.vue`: 미디어 목록, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- `pages/admin/media/index.vue`: 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.45
|
||||
|
||||
- 관리자 미디어 화면의 그리드 카드 목록 렌더링을 `AdminMediaGrid` 컴포넌트로 분리.
|
||||
- 미디어 카드 썸네일·선택 체크·사용처 개수·상태 배지·영상 썸네일 표시 책임을 하위 컴포넌트로 이동.
|
||||
- 미디어 화면은 목록 데이터 계산과 선택·드래그·상세 열기·이미지 fallback 이벤트 연결만 담당하도록 정리.
|
||||
|
||||
## v1.11.44
|
||||
|
||||
- 관리자 미디어 화면의 폴더 사이드바 렌더링을 `AdminMediaFolderPanel` 컴포넌트로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.44",
|
||||
"version": "1.11.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.44",
|
||||
"version": "1.11.45",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.44",
|
||||
"version": "1.11.45",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -1471,88 +1471,21 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<div
|
||||
v-for="(item, index) in paginatedMediaItems"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
:draggable="activeTab === 'library'"
|
||||
@dragstart="startMediaDrag($event, item)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
|
||||
:class="isMediaSelected(item)
|
||||
? 'border-white bg-[#15171a] text-white'
|
||||
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
|
||||
:aria-label="`${item.name} 선택`"
|
||||
:aria-pressed="isMediaSelected(item)"
|
||||
@click.stop="toggleMediaSelection(item, index, $event)"
|
||||
>
|
||||
<svg
|
||||
v-if="isMediaSelected(item)"
|
||||
class="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__thumb flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
@click="openMediaDetail(item)"
|
||||
>
|
||||
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onMediaGridImageError($event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
>
|
||||
{{ getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaUsageBadgeCount(item)"
|
||||
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 z-[1] rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ getMediaUsageBadgeCount(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaBadgeLabel(item)"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 z-[1] rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="[
|
||||
getMediaBadgeClass(item),
|
||||
getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
|
||||
]"
|
||||
>
|
||||
{{ getMediaBadgeLabel(item) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminMediaGrid
|
||||
v-if="filteredMediaItems.length"
|
||||
:active-tab="activeTab"
|
||||
:get-media-badge-class="getMediaBadgeClass"
|
||||
:get-media-badge-label="getMediaBadgeLabel"
|
||||
:get-media-grid-preview-url="getMediaGridPreviewUrl"
|
||||
:get-media-item-kind="getMediaItemKind"
|
||||
:get-media-usage-badge-count="getMediaUsageBadgeCount"
|
||||
:items="paginatedMediaItems"
|
||||
:selected-urls="selectedMediaUrls"
|
||||
@drag-start="startMediaDrag"
|
||||
@image-error="onMediaGridImageError"
|
||||
@open-detail="openMediaDetail"
|
||||
@toggle-selection="toggleMediaSelection"
|
||||
/>
|
||||
|
||||
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
표시할 미디어가 없습니다.
|
||||
|
||||
Reference in New Issue
Block a user