미디어 폴더 패널 렌더링 분리

This commit is contained in:
2026-07-24 13:43:47 +09:00
parent e6c78f8686
commit b2537d4d14
7 changed files with 197 additions and 116 deletions

View File

@@ -0,0 +1,170 @@
<script setup>
const openMenuId = defineModel('openMenuId', {
type: String,
default: ''
})
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
const MEDIA_THUMBNAIL_ROOT = '썸네일'
const props = defineProps({
activeFolder: {
type: String,
default: ''
},
activeSystemMediaCount: {
type: Number,
default: 0
},
activeSystemMediaHint: {
type: String,
default: ''
},
activeSystemMediaTitle: {
type: String,
default: ''
},
folderMediaCounts: {
type: Object,
default: () => ({})
},
folders: {
type: Array,
default: () => []
},
libraryCount: {
type: Number,
default: 0
},
mode: {
type: String,
default: 'library'
},
deletingFolder: {
type: String,
default: ''
}
})
const emit = defineEmits([
'create-folder',
'drop-folder',
'remove-folder',
'select-folder'
])
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
* @returns {boolean} 썸네일 전용이면 true
*/
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
/**
* 폴더 행 메뉴 id를 반환한다.
* @param {string} folder - 폴더 경로
* @returns {string} 행 메뉴 id
*/
const getFolderMenuId = (folder) => `folder:${folder}`
/**
* 폴더 경로의 표시 이름을 반환한다.
* @param {string} folder - 폴더 경로
* @returns {string} 표시 이름
*/
const getFolderName = (folder) => String(folder || '').split('/').filter(Boolean).pop() || '미분류'
/**
* 폴더 깊이를 반환한다.
* @param {string} folder - 폴더 경로
* @returns {number} 폴더 깊이
*/
const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filter(Boolean).length - 1, 0)
</script>
<template>
<aside
v-if="props.mode === 'library'"
class="admin-media-folder-panel admin-media__folders rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="!props.activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
@click="emit('select-folder', '')"
@dragover.prevent
@drop.prevent="emit('drop-folder', '미분류')"
>
<span>전체 미디어</span>
<span>{{ props.libraryCount }}</span>
</button>
<div class="admin-media__folder-list mt-3 grid gap-1">
<div
v-for="folder in props.folders"
:key="folder"
class="admin-media__folder-row flex items-stretch rounded"
:class="props.activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
>
<button
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
type="button"
@click="emit('select-folder', folder)"
@dragover.prevent
@drop.prevent="emit('drop-folder', folder)"
>
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ props.folderMediaCounts[folder] || 0 }}</span>
</button>
<AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
v-model:open-menu-id="openMenuId"
:item-id="getFolderMenuId(folder)"
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="props.activeFolder === folder"
:busy="props.deletingFolder === folder"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="props.deletingFolder === folder"
@click="emit('remove-folder', folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4">
<button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button"
@click="emit('create-folder')"
>
폴더 추가
</button>
</div>
</aside>
<aside
v-else
class="admin-media-folder-panel admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
type="button"
@click="emit('select-folder', '')"
>
<span>{{ props.activeSystemMediaTitle }}</span>
<span>{{ props.activeSystemMediaCount }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
{{ props.activeSystemMediaHint }}
</p>
</aside>
</template>

View File

@@ -190,6 +190,7 @@
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaFolderPanel.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·상태·발행 시각·요약·태그·대표 이미지 표시·추천 글·검색엔진 노출·본문 통계·삭제 버튼·블록 설정 패널 오버레이) |

View File

@@ -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·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.44
- 관리자 미디어 화면의 폴더 사이드바 렌더링을 `AdminMediaFolderPanel` 컴포넌트로 분리.
- 라이브러리 폴더 트리와 대표·본문·프로필 이미지 탭 안내 패널 표시 책임을 하위 컴포넌트로 이동.
- 미디어 화면은 폴더 목록 계산·선택·드롭·삭제·생성 액션 연결만 담당하도록 정리.
## v1.11.43
- 관리자 게시물 폼의 우측 설정 패널 렌더링을 `AdminPostSettingsSidebar` 컴포넌트로 분리.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -36,13 +36,6 @@ const memberRoleBadgeLabels = {
member: '멤버'
}
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
* @returns {boolean} 썸네일 전용이면 true
*/
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
const activeTab = ref('library')
const searchText = ref('')
const {
@@ -83,13 +76,6 @@ const { toast, showToast } = useAdminToast()
const { openMenuId, closeMenu } = useAdminRowMenu()
/**
* 폴더 행 메뉴 id
* @param {string} folder - 폴더 경로
* @returns {string}
*/
const getFolderMenuId = (folder) => `folder:${folder}`
const mediaQuery = computed(() => ({
paged: '1',
tab: activeTab.value,
@@ -712,20 +698,6 @@ const formatFileSize = (size) => {
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
/**
* 폴더 경로의 표시 이름 조회
* @param {string} folder - 폴더 경로
* @returns {string} 표시 이름
*/
const getFolderName = (folder) => folder.split('/').filter(Boolean).pop() || '미분류'
/**
* 폴더 깊이 조회
* @param {string} folder - 폴더 경로
* @returns {number} 폴더 깊이
*/
const getFolderDepth = (folder) => Math.max(folder.split('/').filter(Boolean).length - 1, 0)
/**
* 미디어 선택 여부 확인
* @param {Object} item - 미디어 항목
@@ -1433,90 +1405,22 @@ watch(() => mediaPagination.value.page, (page) => {
</div>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside
v-if="activeTab === 'library'"
class="admin-media__folders rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
@click="selectFolder('')"
@dragover.prevent
@drop.prevent="dropMediaOnFolder('미분류')"
>
<span>전체 미디어</span>
<span>{{ mediaCounts.tabs?.library || 0 }}</span>
</button>
<div class="admin-media__folder-list mt-3 grid gap-1">
<div
v-for="folder in normalizedFolders"
:key="folder"
class="admin-media__folder-row flex items-stretch rounded"
:class="activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
>
<button
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
type="button"
@click="selectFolder(folder)"
@dragover.prevent
@drop.prevent="dropMediaOnFolder(folder)"
>
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
</button>
<AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
v-model:open-menu-id="openMenuId"
:item-id="getFolderMenuId(folder)"
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="activeFolder === folder"
:busy="deletingFolder === folder"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="deletingFolder === folder"
@click="removeMediaFolder(folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>
<div v-if="activeTab === 'library'" class="admin-media__folder-actions mt-4 border-t border-line pt-4">
<button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button"
@click="openCreateFolderModal"
>
폴더 추가
</button>
</div>
</aside>
<aside
v-else
class="admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
type="button"
@click="selectFolder('')"
>
<span>{{ activeSystemMediaTitle }}</span>
<span>{{ activeSystemMediaCount }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
{{ activeSystemMediaHint }}
</p>
</aside>
<AdminMediaFolderPanel
v-model:open-menu-id="openMenuId"
:active-folder="activeFolder"
:active-system-media-count="activeSystemMediaCount"
:active-system-media-hint="activeSystemMediaHint"
:active-system-media-title="activeSystemMediaTitle"
:deleting-folder="deletingFolder"
:folder-media-counts="folderMediaCounts"
:folders="normalizedFolders"
:library-count="mediaCounts.tabs?.library || 0"
:mode="activeTab === 'library' ? 'library' : 'system'"
@create-folder="openCreateFolderModal"
@drop-folder="dropMediaOnFolder"
@remove-folder="removeMediaFolder"
@select-folder="selectFolder"
/>
<div class="admin-media__content min-w-0">
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">