1990 lines
69 KiB
Vue
1990 lines
69 KiB
Vue
<script setup>
|
|
import {
|
|
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
|
DEFAULT_UPLOAD_MAX_TOTAL_SIZE,
|
|
formatUploadSizeLimit,
|
|
getUploadFilesTotalSize,
|
|
normalizeUploadMaxFileCount,
|
|
normalizeUploadMaxTotalSize
|
|
} from '~/lib/upload-size-limit.js'
|
|
import {
|
|
getPostBodyPreviewThumbnailUrl,
|
|
isPageCoverThumbnailUrl,
|
|
isPostBodyThumbnailUrl,
|
|
isPostCardThumbnailUrl,
|
|
withGeneratedImageCacheVersion
|
|
} from '~/lib/post-card-thumbnail-url.js'
|
|
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
|
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
|
|
|
/** 관리자 미디어 한 페이지 표시 개수 옵션 */
|
|
const mediaPageSizeOptions = [50, 100, 200].map((size) => ({
|
|
value: size,
|
|
label: `${size}개`
|
|
}))
|
|
|
|
/** 회원 등급 코드 → 미디어 배지 라벨 */
|
|
const memberRoleBadgeLabels = {
|
|
owner: '소유자',
|
|
admin: '관리자',
|
|
vip: 'VIP',
|
|
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 {
|
|
displayValue: searchTextInput,
|
|
onInput: onSearchTextInput,
|
|
onCompositionEnd: onSearchTextCompositionEnd
|
|
} = useImeSearchInput(searchText, { debounce: 250 })
|
|
const activeFolder = ref('')
|
|
const activeMediaKind = ref('all')
|
|
const showUnusedOnly = ref(false)
|
|
const isCreateFolderModalOpen = ref(false)
|
|
const createFolderModalName = ref('')
|
|
const deletingFolder = ref('')
|
|
const editingUrl = ref('')
|
|
const editingName = ref('')
|
|
const editingCategory = ref('')
|
|
const deletingUrl = ref('')
|
|
const regeneratingThumbnailUrl = ref('')
|
|
const regeneratingBodyThumbnailUrl = ref('')
|
|
const selectedMediaUrl = ref('')
|
|
const selectedMediaUrls = ref([])
|
|
const lastSelectedIndex = ref(-1)
|
|
const mediaPage = ref(1)
|
|
const mediaPageSize = ref(50)
|
|
/** 그리드 파생 이미지 실패 후 원본으로 되돌린 항목 URL */
|
|
const mediaGridPreviewFallbackUrls = ref(new Set())
|
|
const draggingUrls = ref([])
|
|
const uploadInputRef = ref(null)
|
|
const uploadingFiles = ref(false)
|
|
const deletingSelected = ref(false)
|
|
const { data: uploadSettings } = await useFetch('/admin/api/settings', {
|
|
key: 'admin-media-upload-settings'
|
|
})
|
|
const uploadMaxFileCount = computed(() => normalizeUploadMaxFileCount(uploadSettings.value?.uploadMaxFileCount || DEFAULT_UPLOAD_MAX_FILE_COUNT))
|
|
const uploadMaxTotalSize = computed(() => normalizeUploadMaxTotalSize(uploadSettings.value?.uploadMaxTotalSize || DEFAULT_UPLOAD_MAX_TOTAL_SIZE))
|
|
|
|
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,
|
|
page: mediaPage.value,
|
|
pageSize: mediaPageSize.value,
|
|
search: searchText.value.trim() || undefined,
|
|
folder: activeFolder.value || undefined,
|
|
kind: activeMediaKind.value,
|
|
unused: showUnusedOnly.value ? '1' : undefined
|
|
}))
|
|
|
|
const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
|
|
query: mediaQuery,
|
|
default: () => ({
|
|
items: [],
|
|
pagination: {
|
|
page: 1,
|
|
pageSize: 50,
|
|
total: 0,
|
|
pageCount: 1
|
|
},
|
|
counts: {
|
|
tabs: {
|
|
library: 0,
|
|
postThumbnails: 0,
|
|
postBodyThumbnails: 0,
|
|
thumbnails: 0
|
|
},
|
|
folders: {},
|
|
kinds: {
|
|
all: 0,
|
|
image: 0,
|
|
video: 0,
|
|
audio: 0,
|
|
file: 0
|
|
},
|
|
usage: {
|
|
all: 0,
|
|
unused: 0
|
|
}
|
|
}
|
|
}),
|
|
onResponse () {
|
|
mediaGridPreviewFallbackUrls.value = new Set()
|
|
}
|
|
})
|
|
|
|
const mediaItems = computed(() => Array.isArray(mediaResponse.value)
|
|
? mediaResponse.value
|
|
: (mediaResponse.value?.items || []))
|
|
|
|
const mediaPagination = computed(() => Array.isArray(mediaResponse.value)
|
|
? {
|
|
page: 1,
|
|
pageSize: mediaPageSize.value,
|
|
total: mediaItems.value.length,
|
|
pageCount: Math.max(1, Math.ceil(mediaItems.value.length / mediaPageSize.value))
|
|
}
|
|
: (mediaResponse.value?.pagination || {
|
|
page: 1,
|
|
pageSize: mediaPageSize.value,
|
|
total: 0,
|
|
pageCount: 1
|
|
}))
|
|
|
|
const mediaCounts = computed(() => Array.isArray(mediaResponse.value)
|
|
? {
|
|
tabs: {},
|
|
folders: {},
|
|
kinds: {},
|
|
usage: {}
|
|
}
|
|
: (mediaResponse.value?.counts || {
|
|
tabs: {},
|
|
folders: {},
|
|
kinds: {},
|
|
usage: {}
|
|
}))
|
|
|
|
/**
|
|
* 썸네일 디스크 경로 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 회원 아바타 경로이면 true
|
|
*/
|
|
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
|
|
|
|
/**
|
|
* 게시물 카드 썸네일 디스크 경로 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 게시물 카드 썸네일이면 true
|
|
*/
|
|
const isPostCardThumbnailDiskItem = (item) => isPostCardThumbnailUrl(item?.url)
|
|
|
|
/**
|
|
* 페이지 커버 썸네일 디스크 경로 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 페이지 커버 썸네일이면 true
|
|
*/
|
|
const isPageCoverThumbnailDiskItem = (item) => isPageCoverThumbnailUrl(item?.url)
|
|
|
|
/**
|
|
* 게시물 본문 표시용 이미지 디스크 경로 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 게시물 본문 표시용 이미지이면 true
|
|
*/
|
|
const isPostBodyThumbnailDiskItem = (item) => isPostBodyThumbnailUrl(item?.url)
|
|
|
|
/**
|
|
* 게시물 본문 파생 이미지 해상도 라벨을 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {string} 해상도 라벨
|
|
*/
|
|
const getPostBodyThumbnailResolutionLabel = (item) => {
|
|
const url = String(item?.url || '')
|
|
|
|
if (url.endsWith('-body-720.webp')) {
|
|
return '720px'
|
|
}
|
|
|
|
if (url.endsWith('-body-2560.webp') || url.endsWith('-lightbox.webp')) {
|
|
return '2560px'
|
|
}
|
|
|
|
return '1600px'
|
|
}
|
|
|
|
/**
|
|
* 미디어 항목이 시스템 파생 파일인지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 시스템 관리 항목이면 true
|
|
*/
|
|
const isSystemManagedMediaItem = (item) => Boolean(item?.avatarOwner) || isPostCardThumbnailDiskItem(item) || isPageCoverThumbnailDiskItem(item) || isPostBodyThumbnailDiskItem(item)
|
|
|
|
/**
|
|
* 미디어 미리보기 이미지 URL을 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {string} 미리보기 URL
|
|
*/
|
|
const getMediaPreviewUrl = (item) => {
|
|
const url = String(item?.url || '')
|
|
|
|
if (!isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)) {
|
|
return url
|
|
}
|
|
|
|
return withGeneratedImageCacheVersion(url, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
|
}
|
|
|
|
/**
|
|
* 관리자 미디어 그리드에서 사용할 가벼운 이미지 URL을 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {string} 그리드 표시 URL
|
|
*/
|
|
const getMediaGridPreviewUrl = (item) => {
|
|
const itemUrl = String(item?.url || '')
|
|
|
|
if (!itemUrl || mediaGridPreviewFallbackUrls.value.has(itemUrl)) {
|
|
return getMediaPreviewUrl(item)
|
|
}
|
|
|
|
if (getMediaItemKind(item) !== 'image') {
|
|
return getMediaPreviewUrl(item)
|
|
}
|
|
|
|
if (isSystemManagedMediaItem(item)) {
|
|
return getMediaPreviewUrl(item)
|
|
}
|
|
|
|
const cacheVersion = `${item?.updatedAt || ''}-${item?.size || ''}`
|
|
|
|
if (item?.bodyThumbnailStatus?.hasPreviewThumbnail && item.bodyThumbnailStatus.previewThumbnailUrl) {
|
|
return withGeneratedImageCacheVersion(item.bodyThumbnailStatus.previewThumbnailUrl, cacheVersion)
|
|
}
|
|
|
|
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
|
const previewUrl = item.bodyThumbnailStatus.previewThumbnailUrl || getPostBodyPreviewThumbnailUrl(item.url)
|
|
const bodyUrl = item.bodyThumbnailStatus.thumbnailUrl || previewUrl
|
|
|
|
if (item.bodyThumbnailStatus.hasPreviewThumbnail === false && bodyUrl) {
|
|
return withGeneratedImageCacheVersion(bodyUrl, cacheVersion)
|
|
}
|
|
|
|
if (previewUrl) {
|
|
return withGeneratedImageCacheVersion(previewUrl, cacheVersion)
|
|
}
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
|
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, cacheVersion)
|
|
}
|
|
|
|
return getMediaPreviewUrl(item)
|
|
}
|
|
|
|
/**
|
|
* 그리드용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
|
* @param {Event} _event - 이미지 오류 이벤트
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const onMediaGridImageError = (_event, item) => {
|
|
const itemUrl = String(item?.url || '')
|
|
|
|
if (!itemUrl || mediaGridPreviewFallbackUrls.value.has(itemUrl)) {
|
|
return
|
|
}
|
|
|
|
mediaGridPreviewFallbackUrls.value = new Set([
|
|
...mediaGridPreviewFallbackUrls.value,
|
|
itemUrl
|
|
])
|
|
}
|
|
|
|
/**
|
|
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 잠금이면 true
|
|
*/
|
|
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
|
|
|
|
/**
|
|
* 미디어 파일명을 변경할 수 있는지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 변경 가능 여부
|
|
*/
|
|
const canRenameMediaItem = (item) => !isMediaItemLocked(item) && !isPostCardThumbnailDiskItem(item) && !isPageCoverThumbnailDiskItem(item) && !isPostBodyThumbnailDiskItem(item)
|
|
|
|
/**
|
|
* 미디어 논리 폴더를 변경할 수 있는지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 변경 가능 여부
|
|
*/
|
|
const canEditMediaCategory = (item) => !isSystemManagedMediaItem(item)
|
|
|
|
/**
|
|
* 미디어 상태 배지 라벨을 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {string} 배지 라벨
|
|
*/
|
|
const getMediaBadgeLabel = (item) => {
|
|
if (item?.avatarOwner) {
|
|
const roleCode = String(item.avatarOwner.roleCode || 'member')
|
|
return memberRoleBadgeLabels[roleCode] || memberRoleBadgeLabels.member
|
|
}
|
|
|
|
if (isPostCardThumbnailDiskItem(item) && item?.thumbnailStatus?.role === 'card') {
|
|
return ''
|
|
}
|
|
|
|
if (isPageCoverThumbnailDiskItem(item) && item?.thumbnailStatus?.role === 'pageCover') {
|
|
return ''
|
|
}
|
|
|
|
if (isPostBodyThumbnailDiskItem(item) && item?.bodyThumbnailStatus?.role === 'body') {
|
|
return ''
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.role === 'card') {
|
|
return '카드'
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.role === 'pageCover') {
|
|
return '페이지'
|
|
}
|
|
|
|
if (item?.bodyThumbnailStatus?.role === 'body') {
|
|
return '본문'
|
|
}
|
|
|
|
if (item?.bodyThumbnailStatus?.isFallbackActive) {
|
|
return '원본'
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.isFallbackActive) {
|
|
return '원본'
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* 미디어 상태 배지 클래스를 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {string} Tailwind 클래스
|
|
*/
|
|
const getMediaBadgeClass = (item) => {
|
|
if (item?.thumbnailStatus?.isFallbackActive || item?.bodyThumbnailStatus?.isFallbackActive) {
|
|
return 'bg-amber-600 text-white'
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.role === 'card') {
|
|
return 'bg-sky-700 text-white'
|
|
}
|
|
|
|
if (item?.thumbnailStatus?.role === 'pageCover') {
|
|
return 'bg-cyan-800 text-white'
|
|
}
|
|
|
|
if (item?.bodyThumbnailStatus?.role === 'body') {
|
|
return 'bg-violet-700 text-white'
|
|
}
|
|
|
|
if (item?.avatarOwner) {
|
|
return 'bg-emerald-800 text-white'
|
|
}
|
|
|
|
return 'bg-[#15171a] text-white'
|
|
}
|
|
|
|
/**
|
|
* 미디어 사용 개수를 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {number} 사용 개수
|
|
*/
|
|
const getMediaUsageBadgeCount = (item) => {
|
|
const usageCount = Array.isArray(item?.usage) ? item.usage.length : 0
|
|
const avatarOwnerCount = item?.avatarOwner ? 1 : 0
|
|
|
|
return usageCount + avatarOwnerCount
|
|
}
|
|
|
|
/**
|
|
* 카드 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 생성 가능 여부
|
|
*/
|
|
const canRegeneratePostThumbnail = (item) => (
|
|
item?.thumbnailStatus?.role === 'original'
|
|
&& item?.thumbnailStatus?.variant !== 'pageCover'
|
|
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
|
|
)
|
|
|
|
/**
|
|
* 페이지 커버 썸네일을 생성할 수 있는 원본 이미지인지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 생성 가능 여부
|
|
*/
|
|
const canRegeneratePageCoverThumbnail = (item) => (
|
|
item?.thumbnailStatus?.role === 'original'
|
|
&& item?.thumbnailStatus?.variant === 'pageCover'
|
|
&& (item.thumbnailStatus.isFallbackActive || item.thumbnailStatus.hasThumbnail)
|
|
)
|
|
|
|
/**
|
|
* 본문 표시용 이미지를 생성할 수 있는 원본 이미지인지 확인한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {boolean} 생성 가능 여부
|
|
*/
|
|
const canRegeneratePostBodyThumbnail = (item) => (
|
|
item?.bodyThumbnailStatus?.role === 'original'
|
|
&& (item.bodyThumbnailStatus.isFallbackActive || item.bodyThumbnailStatus.hasThumbnail)
|
|
)
|
|
|
|
/**
|
|
* 미디어 항목 종류를 반환한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
|
|
*/
|
|
const getMediaItemKind = (item) => item?.kind || 'image'
|
|
|
|
const systemManagedTab = computed(() => ['thumbnails', 'postThumbnails', 'postBodyThumbnails'].includes(activeTab.value))
|
|
|
|
const activeSystemMediaCount = computed(() => {
|
|
if (activeTab.value === 'postThumbnails') {
|
|
return mediaCounts.value.tabs?.postThumbnails || 0
|
|
}
|
|
|
|
if (activeTab.value === 'postBodyThumbnails') {
|
|
return mediaCounts.value.tabs?.postBodyThumbnails || 0
|
|
}
|
|
|
|
return mediaCounts.value.tabs?.thumbnails || 0
|
|
})
|
|
|
|
const activeSystemMediaTitle = computed(() => {
|
|
if (activeTab.value === 'postThumbnails') {
|
|
return '대표 이미지'
|
|
}
|
|
|
|
if (activeTab.value === 'postBodyThumbnails') {
|
|
return '본문 이미지'
|
|
}
|
|
|
|
return '프로필 이미지'
|
|
})
|
|
|
|
const activeSystemMediaHint = computed(() => {
|
|
if (activeTab.value === 'postThumbnails') {
|
|
return '게시물 목록 카드와 홈 Pages 진열장에서 쓰는 대표 이미지용 자동 생성 파일입니다. 원본 이미지가 사용 중이고 이 파일이 있으면 공개 목록에서는 원본 대신 이 파일을 불러옵니다. 원본에 연결된 파일은 사용 중으로 표시되며, 파일이 없는 원본은 일반 미디어 상세에서 다시 생성 필요 상태로 구분합니다.'
|
|
}
|
|
|
|
if (activeTab.value === 'postBodyThumbnails') {
|
|
return '게시물 본문을 빠르게 보여주기 위해 원본 이미지에서 자동 생성한 WebP 이미지입니다. 본문에는 720px·1600px 파일을 우선 표시하고, 크게 보기에는 2560px 파일을 우선 사용합니다. 원본과 연결되어 있어 이름 변경과 폴더 이동은 제한됩니다.'
|
|
}
|
|
|
|
return '회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 디스크 파일은 삭제되지 않으며 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.'
|
|
})
|
|
|
|
const mediaKindFilterOptions = computed(() => {
|
|
const countByKind = mediaCounts.value.kinds || {}
|
|
|
|
return [
|
|
{ value: 'all', label: '전체', count: countByKind.all || 0 },
|
|
{ value: 'image', label: '이미지', count: countByKind.image || 0 },
|
|
{ value: 'video', label: '영상', count: countByKind.video || 0 },
|
|
{ value: 'audio', label: '음악', count: countByKind.audio || 0 },
|
|
{ value: 'file', label: '파일', count: countByKind.file || 0 }
|
|
]
|
|
})
|
|
|
|
const mediaUsageFilterOptions = computed(() => {
|
|
const usageCounts = mediaCounts.value.usage || {}
|
|
|
|
return [
|
|
{ value: false, id: 'all', label: '전체', count: usageCounts.all || 0 },
|
|
{ value: true, id: 'unused', label: '미사용', count: usageCounts.unused || 0 }
|
|
]
|
|
})
|
|
|
|
const visibleMediaUsageFilterOptions = computed(() => activeTab.value === 'library'
|
|
? mediaUsageFilterOptions.value.filter((option) => option.id !== 'all')
|
|
: mediaUsageFilterOptions.value)
|
|
|
|
/**
|
|
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
|
* @param {'library' | 'postThumbnails' | 'postBodyThumbnails' | 'thumbnails'} tab - 선택 탭
|
|
* @returns {void}
|
|
*/
|
|
const setActiveTab = (tab) => {
|
|
if (activeTab.value === tab) {
|
|
return
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* 미디어 사용 상태 필터를 선택한다.
|
|
* @param {boolean} unusedOnly - 미사용 항목만 볼지 여부
|
|
* @returns {void}
|
|
*/
|
|
const setMediaUsageFilter = (unusedOnly) => {
|
|
if (showUnusedOnly.value === unusedOnly) {
|
|
if (unusedOnly) {
|
|
showUnusedOnly.value = false
|
|
clearMediaSelection()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
showUnusedOnly.value = unusedOnly
|
|
clearMediaSelection()
|
|
}
|
|
|
|
/**
|
|
* ISO 시각을 짧은 로캘 문자열로 표시한다.
|
|
* @param {string | null} iso - ISO 시각
|
|
* @returns {string} 표시 문자열
|
|
*/
|
|
const formatDateTime = (iso) => {
|
|
if (!iso) {
|
|
return '—'
|
|
}
|
|
|
|
try {
|
|
return new Date(iso).toLocaleString('ko-KR', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
})
|
|
} catch {
|
|
return '—'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
|
|
* @returns {void}
|
|
*/
|
|
const downloadSelectedMedia = () => {
|
|
const item = selectedMedia.value
|
|
|
|
if (!item?.url || !import.meta.client) {
|
|
return
|
|
}
|
|
|
|
const anchor = document.createElement('a')
|
|
anchor.href = item.url
|
|
anchor.download = item.name || 'image'
|
|
anchor.rel = 'noopener noreferrer'
|
|
anchor.target = '_blank'
|
|
document.body.appendChild(anchor)
|
|
anchor.click()
|
|
document.body.removeChild(anchor)
|
|
}
|
|
|
|
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
|
default: () => ['미분류']
|
|
})
|
|
|
|
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
|
|
|
const normalizedFolders = computed(() => {
|
|
const folderSet = new Set(['미분류'])
|
|
const folderCounts = mediaCounts.value.folders || {}
|
|
|
|
mediaFolders.value.forEach((folder) => {
|
|
String(folder || '').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
|
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
|
folderSet.add(nextPath)
|
|
return nextPath
|
|
}, '')
|
|
})
|
|
|
|
Object.keys(folderCounts).forEach((folder) => {
|
|
String(folder || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
|
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
|
folderSet.add(nextPath)
|
|
return nextPath
|
|
}, '')
|
|
})
|
|
|
|
return [...folderSet].sort((left, right) => left.localeCompare(right))
|
|
})
|
|
|
|
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
|
|
counts[folder] = mediaCounts.value.folders?.[folder] || 0
|
|
return counts
|
|
}, {}))
|
|
|
|
const filteredMediaItems = computed(() => mediaItems.value)
|
|
|
|
const filteredMediaUrls = computed(() => filteredMediaItems.value.map((item) => item.url))
|
|
|
|
const mediaPageCount = computed(() => Math.max(1, Number(mediaPagination.value.pageCount) || 1))
|
|
|
|
const mediaPageTotal = computed(() => Number(mediaPagination.value.total) || 0)
|
|
|
|
const mediaPageStartIndex = computed(() => mediaPageTotal.value
|
|
? ((Number(mediaPagination.value.page) || mediaPage.value) - 1) * mediaPageSize.value
|
|
: 0)
|
|
|
|
const mediaPageDisplayStart = computed(() => mediaPageTotal.value ? mediaPageStartIndex.value + 1 : 0)
|
|
|
|
const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + filteredMediaItems.value.length, mediaPageTotal.value))
|
|
|
|
const paginatedMediaItems = computed(() => filteredMediaItems.value)
|
|
|
|
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
|
|
|
|
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
|
|
|
|
const isAllFilteredMediaSelected = computed(() => {
|
|
if (!filteredSelectableMediaUrls.value.length) {
|
|
return false
|
|
}
|
|
|
|
return filteredSelectableMediaUrls.value.every((url) => selectedMediaUrls.value.includes(url))
|
|
})
|
|
|
|
/**
|
|
* 관리자 미디어 페이지를 변경한다.
|
|
* @param {number} page - 이동할 페이지
|
|
* @returns {void}
|
|
*/
|
|
const setMediaPage = (page) => {
|
|
mediaPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPageCount.value)
|
|
clearMediaSelection()
|
|
closeMediaDetail()
|
|
}
|
|
|
|
/**
|
|
* 관리자 미디어 페이지 표시 개수를 변경한다.
|
|
* @param {number} pageSize - 변경할 표시 개수
|
|
* @returns {void}
|
|
*/
|
|
const setMediaPageSize = (pageSize) => {
|
|
mediaPageSize.value = Number(pageSize) || 50
|
|
mediaPage.value = 1
|
|
clearMediaSelection()
|
|
closeMediaDetail()
|
|
}
|
|
|
|
const selectedDeletableMediaItems = computed(() => {
|
|
const selectedUrlSet = new Set(selectedMediaUrls.value)
|
|
|
|
return filteredMediaItems.value.filter((item) => selectedUrlSet.has(item.url) && !isMediaItemLocked(item))
|
|
})
|
|
|
|
/**
|
|
* 파일 크기 표시 문자열 생성
|
|
* @param {number} size - 파일 크기
|
|
* @returns {string} 표시 문자열
|
|
*/
|
|
const formatFileSize = (size) => {
|
|
if (size < 1024) {
|
|
return `${size} B`
|
|
}
|
|
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`
|
|
}
|
|
|
|
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 - 미디어 항목
|
|
* @returns {boolean} 선택 여부
|
|
*/
|
|
const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
|
|
|
|
/**
|
|
* 폴더 선택
|
|
* @param {string} folder - 폴더 경로
|
|
* @returns {void}
|
|
*/
|
|
const selectFolder = (folder) => {
|
|
activeFolder.value = folder
|
|
activeMediaKind.value = 'all'
|
|
showUnusedOnly.value = false
|
|
selectedMediaUrls.value = []
|
|
lastSelectedIndex.value = -1
|
|
}
|
|
|
|
/**
|
|
* 체크박스로 미디어 선택 토글(Shift 시 범위 추가 선택)
|
|
* @param {Object} item - 미디어 항목
|
|
* @param {number} index - 필터 목록 내 순서
|
|
* @param {MouseEvent} event - 클릭 이벤트
|
|
* @returns {void}
|
|
*/
|
|
const toggleMediaSelection = (item, index, event) => {
|
|
if (event.shiftKey && lastSelectedIndex.value >= 0) {
|
|
const startIndex = Math.min(lastSelectedIndex.value, index)
|
|
const endIndex = Math.max(lastSelectedIndex.value, index)
|
|
const rangeUrls = paginatedMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
|
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
|
|
selectedMediaUrls.value = [...merged]
|
|
lastSelectedIndex.value = index
|
|
return
|
|
}
|
|
|
|
selectedMediaUrls.value = isMediaSelected(item)
|
|
? selectedMediaUrls.value.filter((url) => url !== item.url)
|
|
: [...selectedMediaUrls.value, item.url]
|
|
lastSelectedIndex.value = index
|
|
}
|
|
|
|
/**
|
|
* 선택된 미디어 해제
|
|
* @returns {void}
|
|
*/
|
|
const clearMediaSelection = () => {
|
|
selectedMediaUrls.value = []
|
|
lastSelectedIndex.value = -1
|
|
}
|
|
|
|
/**
|
|
* 현재 페이지에 표시된 미디어 전체 선택을 토글한다.
|
|
* @returns {void}
|
|
*/
|
|
const toggleFilteredMediaSelection = () => {
|
|
if (!filteredSelectableMediaUrls.value.length) {
|
|
return
|
|
}
|
|
|
|
if (isAllFilteredMediaSelected.value) {
|
|
const filteredUrlSet = new Set(filteredSelectableMediaUrls.value)
|
|
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => !filteredUrlSet.has(url))
|
|
lastSelectedIndex.value = -1
|
|
return
|
|
}
|
|
|
|
selectedMediaUrls.value = [...new Set([
|
|
...selectedMediaUrls.value,
|
|
...filteredSelectableMediaUrls.value
|
|
])]
|
|
lastSelectedIndex.value = paginatedMediaItems.value.length - 1
|
|
}
|
|
|
|
/**
|
|
* 파일 직접 업로드 선택창을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const openUploadFilePicker = () => {
|
|
if (activeTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
uploadInputRef.value?.click()
|
|
}
|
|
|
|
/**
|
|
* 선택된 파일을 미디어 라이브러리에 업로드한다.
|
|
* @param {Event} event - 파일 입력 변경 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const uploadSelectedFiles = async (event) => {
|
|
if (activeTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
const input = event.target
|
|
|
|
if (!(input instanceof HTMLInputElement) || !input.files?.length) {
|
|
return
|
|
}
|
|
|
|
const files = Array.from(input.files)
|
|
|
|
if (files.length > uploadMaxFileCount.value) {
|
|
showToast('error', `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.`)
|
|
input.value = ''
|
|
return
|
|
}
|
|
|
|
if (getUploadFilesTotalSize(files) > uploadMaxTotalSize.value) {
|
|
showToast('error', `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})`)
|
|
input.value = ''
|
|
return
|
|
}
|
|
|
|
const formData = new FormData()
|
|
|
|
files.forEach((file) => {
|
|
formData.append('files', file)
|
|
})
|
|
|
|
uploadingFiles.value = true
|
|
|
|
try {
|
|
const result = await $fetch('/admin/api/uploads', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
const uploadedUrls = Array.isArray(result?.files)
|
|
? result.files.map((file) => file.url).filter(Boolean)
|
|
: []
|
|
|
|
if (activeFolder.value && uploadedUrls.length) {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'PUT',
|
|
body: {
|
|
urls: uploadedUrls,
|
|
category: activeFolder.value
|
|
}
|
|
})
|
|
}
|
|
|
|
await Promise.all([
|
|
refresh(),
|
|
refreshMediaFolders()
|
|
])
|
|
selectedMediaUrls.value = uploadedUrls
|
|
showToast('success', `${files.length}개 파일을 추가했습니다.`)
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '파일을 업로드하지 못했습니다.')
|
|
} finally {
|
|
uploadingFiles.value = false
|
|
input.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 상세 모달 열기
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const openMediaDetail = (item) => {
|
|
selectedMediaUrl.value = item.url
|
|
editingUrl.value = item.url
|
|
editingName.value = item.title
|
|
editingCategory.value = item.category
|
|
}
|
|
|
|
/**
|
|
* 미디어 상세 모달 닫기
|
|
* @returns {void}
|
|
*/
|
|
const closeMediaDetail = () => {
|
|
selectedMediaUrl.value = ''
|
|
cancelRename()
|
|
}
|
|
|
|
/**
|
|
* 미디어 파일명 변경 취소
|
|
* @returns {void}
|
|
*/
|
|
const cancelRename = () => {
|
|
editingUrl.value = ''
|
|
editingName.value = ''
|
|
}
|
|
|
|
/**
|
|
* 폴더 추가 모달을 연다
|
|
* @returns {void}
|
|
*/
|
|
const openCreateFolderModal = () => {
|
|
createFolderModalName.value = ''
|
|
isCreateFolderModalOpen.value = true
|
|
}
|
|
|
|
/**
|
|
* 폴더 추가 모달을 닫는다
|
|
* @returns {void}
|
|
*/
|
|
const closeCreateFolderModal = () => {
|
|
isCreateFolderModalOpen.value = false
|
|
createFolderModalName.value = ''
|
|
}
|
|
|
|
/**
|
|
* 미디어 폴더 생성(모달에서 확인 시)
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const submitCreateFolderModal = async () => {
|
|
if (activeTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
const folderName = createFolderModalName.value.trim()
|
|
|
|
if (!folderName) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
|
|
const createdFolder = await $fetch('/admin/api/media-folders', {
|
|
method: 'POST',
|
|
body: {
|
|
path: folderPath
|
|
}
|
|
})
|
|
|
|
closeCreateFolderModal()
|
|
activeFolder.value = createdFolder.path
|
|
await refreshMediaFolders()
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 폴더 삭제
|
|
* @param {string} folder - 삭제할 폴더 경로
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const removeMediaFolder = async (folder) => {
|
|
closeMenu()
|
|
|
|
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
|
return
|
|
}
|
|
|
|
if (!confirm(`"${folder}" 폴더를 삭제할까요? 이 폴더(및 하위 경로)에 속한 미디어는 모두 "미분류"로 옮겨집니다.`)) {
|
|
return
|
|
}
|
|
|
|
deletingFolder.value = folder
|
|
|
|
try {
|
|
await $fetch('/admin/api/media-folders', {
|
|
method: 'DELETE',
|
|
body: {
|
|
path: folder
|
|
}
|
|
})
|
|
if (activeFolder.value === folder || activeFolder.value.startsWith(`${folder}/`)) {
|
|
activeFolder.value = ''
|
|
}
|
|
selectedMediaUrls.value = []
|
|
lastSelectedIndex.value = -1
|
|
await Promise.all([
|
|
refresh(),
|
|
refreshMediaFolders()
|
|
])
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '폴더를 삭제하지 못했습니다.')
|
|
} finally {
|
|
deletingFolder.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 미디어를 폴더로 이동
|
|
* @param {string} folder - 폴더 경로
|
|
* @param {Array<string>} urls - 이동할 미디어 URL 목록
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
|
if (activeTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
const targetUrls = [...new Set(urls.filter(Boolean))]
|
|
|
|
if (!targetUrls.length) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'PUT',
|
|
body: {
|
|
urls: targetUrls,
|
|
category: folder || '미분류'
|
|
}
|
|
})
|
|
await Promise.all([
|
|
refresh(),
|
|
refreshMediaFolders()
|
|
])
|
|
activeFolder.value = folder || '미분류'
|
|
clearMediaSelection()
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 드래그 시작
|
|
* @param {DragEvent} event - 드래그 이벤트
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {void}
|
|
*/
|
|
const startMediaDrag = (event, item) => {
|
|
if (activeTab.value !== 'library') {
|
|
return
|
|
}
|
|
|
|
if (!isMediaSelected(item)) {
|
|
selectedMediaUrls.value = [item.url]
|
|
}
|
|
|
|
draggingUrls.value = [...selectedMediaUrls.value]
|
|
|
|
if (event.dataTransfer) {
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 폴더로 미디어 드롭
|
|
* @param {string} folder - 폴더 경로
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const dropMediaOnFolder = async (folder) => {
|
|
if (activeTab.value !== 'library') {
|
|
draggingUrls.value = []
|
|
return
|
|
}
|
|
|
|
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
|
|
draggingUrls.value = []
|
|
await moveMediaToFolder(folder, urls)
|
|
}
|
|
|
|
/**
|
|
* 미디어 카테고리 저장
|
|
* @returns {Promise<void>} 저장 결과
|
|
*/
|
|
const saveMediaCategory = async () => {
|
|
if (!canEditMediaCategory(selectedMedia.value)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'PUT',
|
|
body: {
|
|
url: selectedMedia.value.url,
|
|
category: editingCategory.value
|
|
}
|
|
})
|
|
await Promise.all([
|
|
refresh(),
|
|
refreshMediaFolders()
|
|
])
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '카테고리를 저장하지 못했습니다.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 파일명 변경
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const renameMedia = async () => {
|
|
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
|
|
|
if (editingItem && !canRenameMediaItem(editingItem)) {
|
|
showToast('error', '사용 중인 미디어 또는 대표 이미지 파일은 파일명을 바꿀 수 없습니다.')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const renamedItem = await $fetch('/admin/api/media', {
|
|
method: 'PUT',
|
|
body: {
|
|
url: editingUrl.value,
|
|
name: editingName.value
|
|
}
|
|
})
|
|
cancelRename()
|
|
await refresh()
|
|
selectedMediaUrl.value = renamedItem.url
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '파일명을 변경하지 못했습니다.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 삭제
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deleteMedia = async (item) => {
|
|
if (isMediaItemLocked(item)) {
|
|
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.')
|
|
return
|
|
}
|
|
|
|
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
|
return
|
|
}
|
|
|
|
deletingUrl.value = item.url
|
|
|
|
try {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'DELETE',
|
|
body: {
|
|
url: item.url
|
|
}
|
|
})
|
|
closeMediaDetail()
|
|
await refresh()
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '파일을 삭제하지 못했습니다.')
|
|
} finally {
|
|
deletingUrl.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 원본 이미지의 대표 이미지 파일을 다시 생성한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const regeneratePostThumbnail = async (item) => {
|
|
if (!canRegeneratePostThumbnail(item)) {
|
|
return
|
|
}
|
|
|
|
regeneratingThumbnailUrl.value = item.url
|
|
|
|
try {
|
|
await $fetch('/admin/api/media/thumbnail', {
|
|
method: 'POST',
|
|
body: {
|
|
url: item.url
|
|
}
|
|
})
|
|
await refresh()
|
|
selectedMediaUrl.value = item.url
|
|
showToast('success', '대표 이미지 파일을 생성했습니다.')
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '대표 이미지 파일을 생성하지 못했습니다.')
|
|
} finally {
|
|
regeneratingThumbnailUrl.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 원본 이미지의 페이지 커버 썸네일을 다시 생성한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const regeneratePageCoverThumbnail = async (item) => {
|
|
if (!canRegeneratePageCoverThumbnail(item)) {
|
|
return
|
|
}
|
|
|
|
regeneratingThumbnailUrl.value = item.url
|
|
|
|
try {
|
|
await $fetch('/admin/api/media/thumbnail', {
|
|
method: 'POST',
|
|
body: {
|
|
url: item.url,
|
|
kind: 'pageCover'
|
|
}
|
|
})
|
|
await refresh()
|
|
selectedMediaUrl.value = item.url
|
|
showToast('success', '페이지 커버 썸네일을 생성했습니다.')
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '페이지 커버 썸네일을 생성하지 못했습니다.')
|
|
} finally {
|
|
regeneratingThumbnailUrl.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 원본 이미지의 본문 표시용 이미지를 다시 생성한다.
|
|
* @param {Object} item - 미디어 항목
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const regeneratePostBodyThumbnail = async (item) => {
|
|
if (!canRegeneratePostBodyThumbnail(item)) {
|
|
return
|
|
}
|
|
|
|
regeneratingBodyThumbnailUrl.value = item.url
|
|
|
|
try {
|
|
await $fetch('/admin/api/media/thumbnail', {
|
|
method: 'POST',
|
|
body: {
|
|
url: item.url,
|
|
kind: 'body'
|
|
}
|
|
})
|
|
await refresh()
|
|
selectedMediaUrl.value = item.url
|
|
showToast('success', '본문 이미지를 생성했습니다.')
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '본문 이미지를 생성하지 못했습니다.')
|
|
} finally {
|
|
regeneratingBodyThumbnailUrl.value = ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선택한 미디어 중 삭제 가능한 항목을 한 번에 삭제한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deleteSelectedMedia = async () => {
|
|
const items = selectedDeletableMediaItems.value
|
|
const lockedCount = selectedMediaUrls.value.length - items.length
|
|
|
|
if (!items.length) {
|
|
showToast('error', '삭제할 수 있는 선택 항목이 없습니다.')
|
|
return
|
|
}
|
|
|
|
const confirmMessage = lockedCount > 0
|
|
? `${items.length}개 파일을 삭제할까요? 잠긴 ${lockedCount}개 항목은 제외됩니다.`
|
|
: `${items.length}개 파일을 삭제할까요?`
|
|
|
|
if (!confirm(confirmMessage)) {
|
|
return
|
|
}
|
|
|
|
deletingSelected.value = true
|
|
|
|
try {
|
|
for (const item of items) {
|
|
await $fetch('/admin/api/media', {
|
|
method: 'DELETE',
|
|
body: {
|
|
url: item.url
|
|
}
|
|
})
|
|
}
|
|
|
|
closeMediaDetail()
|
|
clearMediaSelection()
|
|
await Promise.all([
|
|
refresh(),
|
|
refreshMediaFolders()
|
|
])
|
|
showToast('success', `${items.length}개 파일을 삭제했습니다.`)
|
|
} catch (error) {
|
|
showToast('error', error?.data?.message || '선택한 파일을 삭제하지 못했습니다.')
|
|
} finally {
|
|
deletingSelected.value = false
|
|
}
|
|
}
|
|
|
|
watch(filteredMediaUrls, (urls) => {
|
|
const visibleUrlSet = new Set(urls)
|
|
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => visibleUrlSet.has(url))
|
|
|
|
if (!selectedMediaUrls.value.length) {
|
|
lastSelectedIndex.value = -1
|
|
}
|
|
})
|
|
|
|
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
|
|
mediaPage.value = 1
|
|
clearMediaSelection()
|
|
closeMediaDetail()
|
|
})
|
|
|
|
watch(mediaPageCount, (pageCount) => {
|
|
if (mediaPage.value > pageCount) {
|
|
mediaPage.value = pageCount
|
|
clearMediaSelection()
|
|
closeMediaDetail()
|
|
}
|
|
})
|
|
|
|
watch(() => mediaPagination.value.page, (page) => {
|
|
const normalizedPage = Number(page) || 1
|
|
|
|
if (normalizedPage !== mediaPage.value) {
|
|
mediaPage.value = normalizedPage
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-media bg-paper p-6">
|
|
<div class="admin-media__header flex flex-col gap-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
|
Media
|
|
</p>
|
|
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
|
미디어
|
|
</h1>
|
|
<div class="admin-media__tabs mt-3 inline-flex rounded-lg border border-line bg-surface p-0.5">
|
|
<button
|
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
|
:class="activeTab === 'library' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
|
type="button"
|
|
@click="setActiveTab('library')"
|
|
>
|
|
미디어 라이브러리
|
|
</button>
|
|
<button
|
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
|
:class="activeTab === 'postThumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
|
type="button"
|
|
@click="setActiveTab('postThumbnails')"
|
|
>
|
|
대표 이미지
|
|
</button>
|
|
<button
|
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
|
:class="activeTab === 'postBodyThumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
|
type="button"
|
|
@click="setActiveTab('postBodyThumbnails')"
|
|
>
|
|
본문 이미지
|
|
</button>
|
|
<button
|
|
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
|
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
|
type="button"
|
|
@click="setActiveTab('thumbnails')"
|
|
>
|
|
프로필 이미지
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="admin-media__header-actions flex min-w-0 flex-wrap items-center gap-2">
|
|
<AdminSearchInput
|
|
:value="searchTextInput"
|
|
label="미디어 검색"
|
|
placeholder="미디어 검색"
|
|
root-class="admin-media__search"
|
|
input-class="admin-media__search-input"
|
|
width-class="w-full md:w-72"
|
|
@input="onSearchTextInput"
|
|
@compositionupdate="onSearchTextInput"
|
|
@compositionend="onSearchTextCompositionEnd"
|
|
/>
|
|
<button
|
|
v-if="activeTab === 'library'"
|
|
class="admin-media__upload-button h-10 shrink-0 rounded bg-[#15171a] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="uploadingFiles"
|
|
@click="openUploadFilePicker"
|
|
>
|
|
{{ uploadingFiles ? '추가 중' : '파일 추가' }}
|
|
</button>
|
|
<input
|
|
ref="uploadInputRef"
|
|
class="hidden"
|
|
type="file"
|
|
multiple
|
|
@change="uploadSelectedFiles"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-media__filters flex flex-wrap gap-1.5">
|
|
<AdminFilterPillGroup
|
|
v-if="activeTab === 'library'"
|
|
:model-value="activeMediaKind"
|
|
:options="mediaKindFilterOptions"
|
|
label="미디어 종류 필터"
|
|
button-class="admin-media__kind-filter"
|
|
@update:model-value="setMediaKindFilter"
|
|
/>
|
|
<AdminFilterPillGroup
|
|
:model-value="showUnusedOnly"
|
|
:options="visibleMediaUsageFilterOptions"
|
|
label="미디어 사용 상태 필터"
|
|
button-class="admin-media__usage-filter"
|
|
@update:model-value="setMediaUsageFilter"
|
|
/>
|
|
</div>
|
|
</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>
|
|
|
|
<div class="admin-media__content min-w-0">
|
|
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
|
|
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
|
|
</h2>
|
|
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
|
{{ mediaPageTotal }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
|
|
</p>
|
|
</div>
|
|
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
|
|
<div v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
|
|
<span>표시</span>
|
|
<AdminSelectInput
|
|
:model-value="mediaPageSize"
|
|
:options="mediaPageSizeOptions"
|
|
label="미디어 표시 개수"
|
|
root-class="admin-media__page-size-select-wrap inline-flex"
|
|
select-class="admin-media__page-size-select py-2 pl-2 pr-8 text-xs font-semibold text-ink"
|
|
@update:model-value="setMediaPageSize"
|
|
/>
|
|
</div>
|
|
<button
|
|
v-if="filteredMediaItems.length"
|
|
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:aria-pressed="isAllFilteredMediaSelected"
|
|
:disabled="!filteredSelectableMediaUrls.length"
|
|
@click="toggleFilteredMediaSelection"
|
|
>
|
|
{{ isAllFilteredMediaSelected ? '현재 페이지 해제' : '현재 페이지 선택' }}
|
|
</button>
|
|
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
|
|
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
|
|
<button
|
|
class="admin-media__selection-delete font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="deletingSelected"
|
|
@click="deleteSelectedMedia"
|
|
>
|
|
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
|
|
</button>
|
|
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
|
선택 해제
|
|
</button>
|
|
</div>
|
|
</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>
|
|
|
|
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
|
표시할 미디어가 없습니다.
|
|
</p>
|
|
|
|
<AdminPagination
|
|
:page="mediaPage"
|
|
:page-count="mediaPageCount"
|
|
label="미디어 페이지"
|
|
@update:page="setMediaPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminModalFrame
|
|
v-if="isCreateFolderModalOpen"
|
|
labelled-by="media-folder-modal-title"
|
|
root-class="admin-media__folder-create-modal"
|
|
panel-class="admin-media__folder-create-panel max-w-sm"
|
|
@close="closeCreateFolderModal"
|
|
>
|
|
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
|
|
새 폴더
|
|
</h3>
|
|
<p class="mt-1 text-xs text-muted">
|
|
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
|
</p>
|
|
<label class="mt-4 grid gap-1.5 text-sm">
|
|
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
|
<input
|
|
v-model="createFolderModalName"
|
|
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
|
type="text"
|
|
placeholder="폴더 이름"
|
|
@keydown.enter.prevent="submitCreateFolderModal"
|
|
>
|
|
</label>
|
|
<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>
|
|
<button
|
|
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
|
type="button"
|
|
:disabled="!createFolderModalName.trim()"
|
|
@click="submitCreateFolderModal"
|
|
>
|
|
만들기
|
|
</button>
|
|
</div>
|
|
</AdminModalFrame>
|
|
|
|
<div
|
|
v-if="selectedMedia"
|
|
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@click.self="closeMediaDetail"
|
|
>
|
|
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
|
|
<img
|
|
v-if="getMediaItemKind(selectedMedia) === 'image'"
|
|
class="admin-media__preview-image max-h-[72vh] max-w-full object-contain"
|
|
:src="getMediaPreviewUrl(selectedMedia)"
|
|
:alt="selectedMedia.title"
|
|
>
|
|
<video
|
|
v-else-if="getMediaItemKind(selectedMedia) === 'video'"
|
|
class="admin-media__preview-image max-h-[72vh] max-w-full bg-black"
|
|
:src="selectedMedia.url"
|
|
controls
|
|
preload="metadata"
|
|
/>
|
|
<audio
|
|
v-else-if="getMediaItemKind(selectedMedia) === 'audio'"
|
|
class="w-full max-w-xl"
|
|
:src="selectedMedia.url"
|
|
controls
|
|
preload="metadata"
|
|
/>
|
|
<span
|
|
v-else
|
|
class="rounded border border-line bg-white px-5 py-4 text-sm font-semibold text-muted"
|
|
>
|
|
{{ selectedMedia.name }}
|
|
</span>
|
|
</div>
|
|
|
|
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
|
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
|
Attachment
|
|
</p>
|
|
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
|
|
{{ selectedMedia.name }}
|
|
</h2>
|
|
</div>
|
|
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
|
|
<button
|
|
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
|
|
type="button"
|
|
@click="downloadSelectedMedia"
|
|
>
|
|
다운로드
|
|
</button>
|
|
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<dl class="admin-media__info grid gap-3 text-sm">
|
|
<div class="admin-media__info-row">
|
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
|
|
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.url }}</dd>
|
|
</div>
|
|
<div class="admin-media__info-row">
|
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
|
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
|
</div>
|
|
<div v-if="selectedMedia.thumbnailStatus" class="admin-media__info-row">
|
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">
|
|
{{ selectedMedia.thumbnailStatus.variant === 'pageCover' || selectedMedia.thumbnailStatus.role === 'pageCover' ? '페이지 커버 이미지' : '대표 이미지' }}
|
|
</dt>
|
|
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
|
<span v-if="selectedMedia.thumbnailStatus.role === 'card'">
|
|
자동 생성된 목록 카드용 썸네일입니다.
|
|
</span>
|
|
<span v-else-if="selectedMedia.thumbnailStatus.role === 'pageCover'">
|
|
자동 생성된 Pages 진열장용 커버 썸네일입니다.
|
|
</span>
|
|
<template v-else>
|
|
<span v-if="selectedMedia.thumbnailStatus.hasThumbnail">
|
|
생성됨: {{ selectedMedia.thumbnailStatus.thumbnailUrl }}
|
|
</span>
|
|
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive && selectedMedia.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
|
|
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
|
|
</span>
|
|
<span v-else-if="selectedMedia.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
|
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
|
|
</span>
|
|
<span v-else-if="selectedMedia.thumbnailStatus.variant === 'pageCover'">
|
|
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
|
|
</span>
|
|
<span v-else>
|
|
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
|
|
</span>
|
|
</template>
|
|
<span v-if="selectedMedia.thumbnailStatus.originalUrl" class="text-muted">
|
|
원본: {{ selectedMedia.thumbnailStatus.originalUrl }}
|
|
</span>
|
|
<button
|
|
v-if="canRegeneratePostThumbnail(selectedMedia)"
|
|
class="admin-media__thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="regeneratingThumbnailUrl === selectedMedia.url"
|
|
@click="regeneratePostThumbnail(selectedMedia)"
|
|
>
|
|
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '대표 이미지 다시 생성' : '대표 이미지 생성') }}
|
|
</button>
|
|
<button
|
|
v-if="canRegeneratePageCoverThumbnail(selectedMedia)"
|
|
class="admin-media__page-cover-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="regeneratingThumbnailUrl === selectedMedia.url"
|
|
@click="regeneratePageCoverThumbnail(selectedMedia)"
|
|
>
|
|
{{ regeneratingThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
<div v-if="selectedMedia.bodyThumbnailStatus" class="admin-media__info-row">
|
|
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
|
|
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
|
|
<span v-if="selectedMedia.bodyThumbnailStatus.role === 'body'">
|
|
자동 생성된 본문 표시용 WebP 이미지입니다. ({{ getPostBodyThumbnailResolutionLabel(selectedMedia) }})
|
|
</span>
|
|
<template v-else>
|
|
<span v-if="selectedMedia.bodyThumbnailStatus.hasThumbnail">
|
|
생성됨: {{ selectedMedia.bodyThumbnailStatus.thumbnailUrl }}
|
|
</span>
|
|
<span v-else-if="selectedMedia.bodyThumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
|
|
본문 이미지 없음. 현재 본문에서 원본 이미지를 불러옵니다.
|
|
</span>
|
|
<span v-else>
|
|
본문 이미지 없음. 게시물 본문에서 사용되면 원본으로 대체됩니다.
|
|
</span>
|
|
</template>
|
|
<span v-if="selectedMedia.bodyThumbnailStatus.originalUrl" class="text-muted">
|
|
원본: {{ selectedMedia.bodyThumbnailStatus.originalUrl }}
|
|
</span>
|
|
<button
|
|
v-if="canRegeneratePostBodyThumbnail(selectedMedia)"
|
|
class="admin-media__body-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="regeneratingBodyThumbnailUrl === selectedMedia.url"
|
|
@click="regeneratePostBodyThumbnail(selectedMedia)"
|
|
>
|
|
{{ regeneratingBodyThumbnailUrl === selectedMedia.url ? '생성 중' : (selectedMedia.bodyThumbnailStatus.hasThumbnail ? '본문 이미지 다시 생성' : '본문 이미지 생성') }}
|
|
</button>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="admin-media__category grid gap-2">
|
|
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
|
폴더
|
|
</label>
|
|
<div class="admin-media__category-row flex gap-2">
|
|
<input
|
|
id="media-category"
|
|
v-model="editingCategory"
|
|
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
|
|
type="text"
|
|
list="media-folder-options"
|
|
placeholder="미분류"
|
|
:disabled="!canEditMediaCategory(selectedMedia)"
|
|
@keydown.enter.prevent="saveMediaCategory"
|
|
>
|
|
<button
|
|
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
|
type="button"
|
|
:disabled="!canEditMediaCategory(selectedMedia)"
|
|
@click="saveMediaCategory"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
|
|
프로필 썸네일의 논리 폴더는 「{{ MEDIA_THUMBNAIL_ROOT }}」로 고정됩니다.
|
|
</p>
|
|
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia)" class="admin-media__category-hint text-xs text-muted">
|
|
대표 이미지 파일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 직접 바꾸지 않습니다.
|
|
</p>
|
|
<datalist id="media-folder-options">
|
|
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
|
|
</datalist>
|
|
</div>
|
|
|
|
<div
|
|
v-if="selectedMedia.avatarOwner"
|
|
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
|
|
>
|
|
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
|
|
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
|
|
<div class="admin-media__avatar-owner-row">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
|
|
<dd class="mt-0.5 font-semibold text-ink">{{ selectedMedia.avatarOwner.username }}</dd>
|
|
</div>
|
|
<div class="admin-media__avatar-owner-row">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
|
|
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
|
|
</div>
|
|
<div class="admin-media__avatar-owner-row">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">등급</dt>
|
|
<dd class="mt-0.5 text-ink">{{ getMediaBadgeLabel(selectedMedia) }}</dd>
|
|
</div>
|
|
<div class="admin-media__avatar-owner-row">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
|
|
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
|
|
</div>
|
|
<div v-if="selectedMedia.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
|
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
|
|
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ selectedMedia.avatarOwner.lastSeenIp }}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
|
<strong class="admin-media__usage-title text-ink">
|
|
사용 현황 {{ selectedMedia.usage.length }}곳
|
|
</strong>
|
|
<ul v-if="selectedMedia.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
|
<li v-for="usage in selectedMedia.usage" :key="`${selectedMedia.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
|
<NuxtLink
|
|
v-if="usage.adminUrl"
|
|
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
|
:to="usage.adminUrl"
|
|
>
|
|
{{ usage.title }}
|
|
</NuxtLink>
|
|
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
|
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
|
{{ selectedMedia.avatarOwner ? '프로필 연결 외에 다른 사용처는 없습니다.' : '사용 중인 곳이 없습니다.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="admin-media__rename grid gap-2">
|
|
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
|
|
파일명
|
|
</label>
|
|
<input
|
|
id="media-name"
|
|
v-model="editingName"
|
|
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
|
type="text"
|
|
:disabled="!canRenameMediaItem(selectedMedia)"
|
|
:placeholder="selectedMedia.title"
|
|
@keydown.enter.prevent="renameMedia"
|
|
>
|
|
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
|
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
|
|
</p>
|
|
<p v-else-if="isPostCardThumbnailDiskItem(selectedMedia) || isPageCoverThumbnailDiskItem(selectedMedia) || isPostBodyThumbnailDiskItem(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
|
자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다. 사용처가 없는 파생 파일만 정리용으로 삭제할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="admin-media__actions flex flex-wrap gap-2">
|
|
<button
|
|
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
|
type="button"
|
|
:disabled="!canRenameMediaItem(selectedMedia) || !editingName"
|
|
@click="renameMedia"
|
|
>
|
|
파일명 저장
|
|
</button>
|
|
<button
|
|
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
|
type="button"
|
|
:disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
|
|
@click="deleteMedia(selectedMedia)"
|
|
>
|
|
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
|
|
<AdminToast :toast="toast" />
|
|
</section>
|
|
</template>
|