Files
sori.studio/server/routes/admin/api/media.get.js

240 lines
7.3 KiB
JavaScript

import { requireAdminSession } from '../../../utils/admin-auth'
import { listMediaItems } from '../../../utils/media-library'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl
} from '../../../../lib/post-card-thumbnail-url.js'
/** 관리자 미디어 페이지 표시 개수 허용값 */
const mediaPageSizeOptions = [50, 100, 200]
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => item?.kind || 'image'
/**
* 미디어 항목 잠금 여부를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금 여부
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
/**
* 프로필 이미지 파일인지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 프로필 이미지이면 true
*/
const isProfileThumbnailItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
/**
* 관리자 미디어 탭 id를 정규화한다.
* @param {unknown} value - 요청 탭
* @returns {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} 탭 id
*/
const normalizeMediaTab = (value) => {
const tab = String(value || 'library')
return ['library', 'postThumbnails', 'postBodyThumbnails', 'thumbnails'].includes(tab)
? tab
: 'library'
}
/**
* 관리자 미디어 종류 필터를 정규화한다.
* @param {unknown} value - 요청 미디어 종류
* @returns {'all'|'image'|'video'|'audio'|'file'} 미디어 종류
*/
const normalizeMediaKind = (value) => {
const kind = String(value || 'all')
return ['all', 'image', 'video', 'audio', 'file'].includes(kind)
? kind
: 'all'
}
/**
* 관리자 미디어 페이지 표시 개수를 정규화한다.
* @param {unknown} value - 요청 표시 개수
* @returns {number} 표시 개수
*/
const normalizeMediaPageSize = (value) => {
const pageSize = Number(value) || 50
return mediaPageSizeOptions.includes(pageSize) ? pageSize : 50
}
/**
* 관리자 미디어 탭별 항목인지 확인한다.
* @param {Object} item - 미디어 항목
* @param {'library'|'postThumbnails'|'postBodyThumbnails'|'thumbnails'} tab - 탭 id
* @returns {boolean} 탭에 속하면 true
*/
const matchesMediaTab = (item, tab) => {
const url = item?.url
if (tab === 'thumbnails') {
return isProfileThumbnailItem(item)
}
if (tab === 'postThumbnails') {
return isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url)
}
if (tab === 'postBodyThumbnails') {
return isPostBodyThumbnailUrl(url)
}
return !isProfileThumbnailItem(item)
&& !isPostCardThumbnailUrl(url)
&& !isPageCoverThumbnailUrl(url)
&& !isPostBodyThumbnailUrl(url)
}
/**
* 폴더와 하위 폴더에 포함되는지 확인한다.
* @param {Object} item - 미디어 항목
* @param {string} folder - 폴더 경로
* @returns {boolean} 포함 여부
*/
const matchesMediaFolder = (item, folder) => !folder
|| item.category === folder
|| item.category?.startsWith(`${folder}/`)
/**
* 검색어와 미디어 항목이 일치하는지 확인한다.
* @param {Object} item - 미디어 항목
* @param {string} query - 소문자 검색어
* @returns {boolean} 일치 여부
*/
const matchesMediaQuery = (item, query) => {
if (!query) {
return true
}
const usageTitles = Array.isArray(item?.usage)
? item.usage.map((usage) => usage.title)
: []
return [
item?.name,
item?.title,
...usageTitles
].some((value) => String(value || '').toLowerCase().includes(query))
}
/**
* 미디어 폴더별 개수를 계산한다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Record<string, number>} 폴더별 개수
*/
const countMediaFolders = (items) => items.reduce((counts, item) => {
const segments = String(item.category || '미분류').split('/').filter(Boolean)
if (!segments.length) {
counts['미분류'] = (counts['미분류'] || 0) + 1
return counts
}
segments.reduce((parentPath, segment) => {
const folder = parentPath ? `${parentPath}/${segment}` : segment
counts[folder] = (counts[folder] || 0) + 1
return folder
}, '')
return counts
}, {})
/**
* 미디어 종류별 개수를 계산한다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {Record<string, number>} 종류별 개수
*/
const countMediaKinds = (items) => items.reduce((counts, item) => {
const kind = getMediaItemKind(item)
counts[kind] = (counts[kind] || 0) + 1
counts.all += 1
return counts
}, {
all: 0,
image: 0,
video: 0,
audio: 0,
file: 0
})
/**
* 미디어 페이지 응답을 구성한다.
* @param {Array<Object>} items - 전체 미디어 목록
* @param {Record<string, unknown>} query - 요청 쿼리
* @returns {Object} 페이지 미디어 응답
*/
const createPagedMediaResponse = (items, query) => {
const tab = normalizeMediaTab(query.tab)
const pageSize = normalizeMediaPageSize(query.pageSize)
const requestedPage = Math.max(1, Number(query.page) || 1)
const mediaKind = normalizeMediaKind(query.kind)
const searchQuery = String(query.search || '').trim().toLowerCase()
const folder = tab === 'library' ? String(query.folder || '').trim() : ''
const unusedOnly = query.unused === '1' || query.unused === 'true'
const libraryItems = items.filter((item) => matchesMediaTab(item, 'library'))
const tabItems = items.filter((item) => matchesMediaTab(item, tab))
const folderItems = tab === 'library'
? tabItems.filter((item) => matchesMediaFolder(item, folder))
: tabItems
const kindItems = folderItems.filter((item) => mediaKind === 'all' || getMediaItemKind(item) === mediaKind)
const usageFilteredItems = unusedOnly
? kindItems.filter((item) => !isMediaItemLocked(item))
: kindItems
const filteredItems = usageFilteredItems.filter((item) => matchesMediaQuery(item, searchQuery))
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize))
const page = Math.min(requestedPage, pageCount)
const startIndex = (page - 1) * pageSize
return {
items: filteredItems.slice(startIndex, startIndex + pageSize),
pagination: {
page,
pageSize,
total: filteredItems.length,
pageCount
},
counts: {
tabs: {
library: libraryItems.length,
postThumbnails: items.filter((item) => matchesMediaTab(item, 'postThumbnails')).length,
postBodyThumbnails: items.filter((item) => matchesMediaTab(item, 'postBodyThumbnails')).length,
thumbnails: items.filter((item) => matchesMediaTab(item, 'thumbnails')).length
},
folders: countMediaFolders(libraryItems),
kinds: countMediaKinds(folderItems),
usage: {
all: kindItems.length,
unused: kindItems.filter((item) => !isMediaItemLocked(item)).length
}
}
}
}
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array<Object>|Object>} 미디어 목록 또는 페이지 응답
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const items = await listMediaItems()
const query = getQuery(event)
if (query.paged === '1' || query.paged === 'true') {
return createPagedMediaResponse(items, query)
}
return items
})