v1.4.3: 관리자 UI·홈·미디어 개선

- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
This commit is contained in:
2026-05-21 18:30:50 +09:00
parent 6919669330
commit 10c5a099fc
15 changed files with 523 additions and 84 deletions

View File

@@ -16,6 +16,8 @@ const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || Str
const activeTab = ref('library')
const searchText = ref('')
const activeFolder = ref('')
const activeMediaKind = ref('all')
const showUnusedOnly = ref(false)
const isCreateFolderModalOpen = ref(false)
const createFolderModalName = ref('')
const deletingFolder = ref('')
@@ -70,6 +72,38 @@ const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
const mediaKindFilterOptions = computed(() => {
const baseItems = scopeItems.value.filter((item) => {
const folder = activeFolder.value
return activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
})
const countByKind = baseItems.reduce((counts, item) => {
const kind = getMediaItemKind(item)
counts[kind] = (counts[kind] || 0) + 1
return counts
}, {})
return [
{ id: 'all', label: '전체', count: baseItems.length },
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
{ id: 'video', label: '영상', count: countByKind.video || 0 },
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
{ id: 'file', label: '파일', count: countByKind.file || 0 }
]
})
const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
const folder = activeFolder.value
const matchesFolder = activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
return matchesFolder && !isMediaItemLocked(item)
}).length)
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'thumbnails'} tab - 선택 탭
@@ -83,10 +117,31 @@ const setActiveTab = (tab) => {
activeTab.value = tab
activeFolder.value = ''
searchText.value = ''
activeMediaKind.value = 'all'
showUnusedOnly.value = false
clearMediaSelection()
closeMediaDetail()
}
/**
* 미디어 종류 필터를 선택한다.
* @param {'all'|'image'|'video'|'audio'|'file'} kind - 선택할 미디어 종류
* @returns {void}
*/
const setMediaKindFilter = (kind) => {
activeMediaKind.value = kind
clearMediaSelection()
}
/**
* 미사용 미디어만 보기 필터를 토글한다.
* @returns {void}
*/
const toggleUnusedMediaFilter = () => {
showUnusedOnly.value = !showUnusedOnly.value
clearMediaSelection()
}
/**
* ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각
@@ -164,6 +219,8 @@ const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts,
const filteredMediaItems = computed(() => {
const query = searchText.value.trim().toLowerCase()
const folder = activeFolder.value
const mediaKind = activeMediaKind.value
const unusedOnly = showUnusedOnly.value
const base = scopeItems.value
return base.filter((item) => {
@@ -175,8 +232,10 @@ const filteredMediaItems = computed(() => {
item.name,
...usageTitles
].some((value) => String(value || '').toLowerCase().includes(query))
const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind
const matchesUsage = !unusedOnly || !isMediaItemLocked(item)
return matchesFolder && matchesQuery
return matchesFolder && matchesQuery && matchesKind && matchesUsage
})
})
@@ -225,6 +284,8 @@ const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
*/
const selectFolder = (folder) => {
activeFolder.value = folder
activeMediaKind.value = 'all'
showUnusedOnly.value = false
selectedMediaUrls.value = []
lastSelectedIndex.value = -1
}
@@ -583,6 +644,32 @@ const deleteMedia = async (item) => {
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
>
</div>
<div
v-if="activeTab === 'library'"
class="admin-media__filters flex flex-wrap gap-1.5"
>
<button
v-for="option in mediaKindFilterOptions"
:key="option.id"
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
@click="setMediaKindFilter(option.id)"
>
{{ option.label }}
<span class="ml-1 opacity-70">{{ option.count }}</span>
</button>
<button
class="admin-media__unused-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="showUnusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
:aria-pressed="showUnusedOnly"
@click="toggleUnusedMediaFilter"
>
미사용
<span class="ml-1 opacity-70">{{ unusedMediaCount }}</span>
</button>
</div>
</div>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
@@ -733,6 +820,11 @@ const deleteMedia = async (item) => {
:src="item.url"
:alt="item.title"
>
<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"