373 lines
13 KiB
JavaScript
373 lines
13 KiB
JavaScript
import { nextTick } from 'vue'
|
|
import Sortable from 'sortablejs'
|
|
|
|
export function useAdminGameManager({
|
|
api,
|
|
toApiUrl,
|
|
selectedGameId,
|
|
selectedGame,
|
|
uploadFiles,
|
|
uploadItemDrafts,
|
|
thumbFile,
|
|
itemPreviewUrls,
|
|
itemFileInput,
|
|
gameItemListEl,
|
|
gameItemSortable,
|
|
savedGameItemOrderIds,
|
|
isGameLoading,
|
|
activeTemplateRequest,
|
|
templateRequests,
|
|
customItemModalOpen,
|
|
customItemModalTargetGameId,
|
|
newGameId,
|
|
newGameName,
|
|
newGameIsPublic,
|
|
clearPreviewUrl,
|
|
resetFileInput,
|
|
resetUploadState,
|
|
refreshGames,
|
|
closeGameCreateModal,
|
|
resetMessages,
|
|
success,
|
|
error,
|
|
}) {
|
|
function normalizeDraftSrc(src) {
|
|
if (typeof src !== 'string') return ''
|
|
const raw = src.trim()
|
|
if (!raw) return ''
|
|
if (raw.startsWith('/uploads/')) return raw
|
|
try {
|
|
const url = new URL(raw)
|
|
return url.pathname || raw
|
|
} catch (e) {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function requestItemFilename(item = {}) {
|
|
const src = typeof item.src === 'string' ? item.src : ''
|
|
return src.split('/').pop() || item.file?.name || 'item'
|
|
}
|
|
|
|
function destroyGameItemSortable() {
|
|
if (gameItemSortable.value) {
|
|
gameItemSortable.value.destroy()
|
|
gameItemSortable.value = null
|
|
}
|
|
}
|
|
|
|
async function syncGameItemSortable() {
|
|
await nextTick()
|
|
destroyGameItemSortable()
|
|
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
|
|
|
|
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
|
animation: 160,
|
|
draggable: '[data-game-item-id]',
|
|
forceFallback: true,
|
|
fallbackOnBody: false,
|
|
filter: '[data-no-drag]',
|
|
preventOnFilter: false,
|
|
fallbackClass: 'thumbCard--dragging',
|
|
ghostClass: 'ghost',
|
|
chosenClass: 'chosen',
|
|
onEnd: (evt) => {
|
|
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
|
const nextItems = [...(selectedGame.value?.items || [])]
|
|
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
|
nextItems.splice(evt.newIndex, 0, moved)
|
|
selectedGame.value = {
|
|
...selectedGame.value,
|
|
items: nextItems,
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
function mergeRequestItemsIntoDrafts(request) {
|
|
const requestId = request?.id
|
|
if (!requestId) return
|
|
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
|
|
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
|
|
const nextRequestDrafts = (request.items || [])
|
|
.filter((item) => item?.src)
|
|
.map((item) => ({
|
|
kind: 'request',
|
|
requestId,
|
|
itemId: item.id,
|
|
previewUrl: toApiUrl(item.src),
|
|
label: item.label || '',
|
|
sourceName: requestItemFilename(item),
|
|
src: item.src,
|
|
}))
|
|
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
|
|
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
|
|
|
if (nextRequestDrafts.length) {
|
|
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
|
|
}
|
|
}
|
|
|
|
function removeUploadDraft(targetDraft) {
|
|
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
|
|
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
|
|
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
|
|
return currentKey !== targetKey
|
|
})
|
|
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
|
}
|
|
|
|
async function loadGame(options = {}) {
|
|
const preserveUploadState = !!options.preserveUploadState
|
|
resetMessages()
|
|
if (!preserveUploadState) resetUploadState()
|
|
|
|
if (!selectedGameId.value) {
|
|
selectedGame.value = null
|
|
savedGameItemOrderIds.value = []
|
|
destroyGameItemSortable()
|
|
return
|
|
}
|
|
|
|
try {
|
|
isGameLoading.value = true
|
|
const data = await api.getGame(selectedGameId.value)
|
|
selectedGame.value = {
|
|
...data,
|
|
items: (data.items || []).map((item) => ({
|
|
...item,
|
|
draftLabel: item.label,
|
|
})),
|
|
}
|
|
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
|
await syncGameItemSortable()
|
|
} catch (e) {
|
|
selectedGame.value = null
|
|
error.value = '게임 정보를 불러오지 못했어요.'
|
|
} finally {
|
|
isGameLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function createGame(options = {}) {
|
|
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
|
|
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
|
|
const preserveUploadState = !!options.preserveUploadState
|
|
resetMessages()
|
|
try {
|
|
const res = await fetch(toApiUrl('/api/admin/games'), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: nextGameId,
|
|
name: nextGameName,
|
|
isPublic: !!newGameIsPublic.value,
|
|
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('failed')
|
|
|
|
const data = await res.json()
|
|
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
|
|
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
|
|
gameId: data.game.id,
|
|
})
|
|
activeTemplateRequest.value = {
|
|
...activeTemplateRequest.value,
|
|
targetGameId: linkData.request?.targetGameId || data.game.id,
|
|
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
|
}
|
|
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
|
|
if (requestIndex >= 0) {
|
|
templateRequests.value.splice(requestIndex, 1, {
|
|
...templateRequests.value[requestIndex],
|
|
targetGameId: linkData.request?.targetGameId || data.game.id,
|
|
targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
|
|
})
|
|
}
|
|
}
|
|
await refreshGames()
|
|
selectedGameId.value = data.game.id
|
|
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
|
closeGameCreateModal()
|
|
await loadGame({ preserveUploadState })
|
|
if (!preserveUploadState && activeTemplateRequest.value?.id) {
|
|
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
|
mergeRequestItemsIntoDrafts(sourceRequest)
|
|
}
|
|
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
|
} catch (e) {
|
|
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
|
}
|
|
}
|
|
|
|
function handleItemFiles(fileList) {
|
|
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
|
|
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
|
|
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
|
|
previousFileDrafts.forEach((draft) => {
|
|
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
|
|
})
|
|
itemPreviewUrls.value = []
|
|
uploadFiles.value = files
|
|
uploadItemDrafts.value = requestDrafts
|
|
if (!files.length) {
|
|
resetFileInput('item')
|
|
return
|
|
}
|
|
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
|
|
const fileDrafts = files.map((file, index) => ({
|
|
kind: 'file',
|
|
file,
|
|
previewUrl: itemPreviewUrls.value[index],
|
|
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
|
|
sourceName: file.name,
|
|
}))
|
|
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
|
|
resetFileInput('item')
|
|
}
|
|
|
|
function onFile(event) {
|
|
handleItemFiles(event.target.files)
|
|
}
|
|
|
|
function openItemFilePicker() {
|
|
itemFileInput.value?.click()
|
|
}
|
|
|
|
function clearItemFiles() {
|
|
uploadFiles.value = []
|
|
uploadItemDrafts.value = []
|
|
itemPreviewUrls.value.forEach((url) => {
|
|
if (url) URL.revokeObjectURL(url)
|
|
})
|
|
itemPreviewUrls.value = []
|
|
resetFileInput('item')
|
|
}
|
|
|
|
async function uploadItem() {
|
|
resetMessages()
|
|
if (!uploadItemDrafts.value.length) {
|
|
error.value = '아이템 파일을 선택해주세요.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
|
|
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
|
|
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
|
|
if (!draftGameId || !draftGameName) {
|
|
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
|
|
return
|
|
}
|
|
await createGame({
|
|
gameId: draftGameId,
|
|
gameName: draftGameName,
|
|
preserveUploadState: true,
|
|
})
|
|
}
|
|
|
|
if (!selectedGameId.value) {
|
|
error.value = '게임을 먼저 선택해주세요.'
|
|
return
|
|
}
|
|
|
|
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
|
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
|
let uploadCount = 0
|
|
|
|
if (fileDrafts.length) {
|
|
const fd = new FormData()
|
|
fileDrafts.forEach((entry) => {
|
|
fd.append('images', entry.file)
|
|
fd.append('labels', entry.label.trim())
|
|
})
|
|
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: fd,
|
|
})
|
|
if (!res.ok) throw new Error('failed')
|
|
uploadCount += fileDrafts.length
|
|
}
|
|
|
|
if (requestDrafts.length) {
|
|
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
|
|
for (const requestId of requestIds) {
|
|
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
|
const result = await api.promoteAdminTemplateRequestItems(requestId, {
|
|
gameId: selectedGameId.value,
|
|
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
|
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
|
|
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
|
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
|
|
return acc
|
|
}, {}),
|
|
})
|
|
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
|
|
}
|
|
}
|
|
|
|
resetUploadState()
|
|
await loadGame()
|
|
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
|
} catch (e) {
|
|
const apiError = e?.data?.error || ''
|
|
if (apiError === 'no_items_selected') {
|
|
error.value = '추가할 요청 아이템이 없어요.'
|
|
return
|
|
}
|
|
if (apiError === 'promote_items_failed') {
|
|
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
|
|
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
|
|
return
|
|
}
|
|
if (apiError === 'game_not_found') {
|
|
error.value = '선택한 게임을 찾지 못했어요.'
|
|
return
|
|
}
|
|
error.value = '아이템 추가에 실패했어요.'
|
|
}
|
|
}
|
|
|
|
async function saveGameItemOrder() {
|
|
resetMessages()
|
|
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
|
|
|
|
try {
|
|
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
|
|
itemIds: selectedGame.value.items.map((item) => item.id),
|
|
})
|
|
selectedGame.value = {
|
|
...selectedGame.value,
|
|
items: (data.items || []).map((item) => ({
|
|
...item,
|
|
draftLabel: item.label,
|
|
})),
|
|
}
|
|
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
|
await syncGameItemSortable()
|
|
success.value = '기본 아이템 순서를 저장했어요.'
|
|
} catch (e) {
|
|
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
|
}
|
|
}
|
|
|
|
return {
|
|
requestItemFilename,
|
|
destroyGameItemSortable,
|
|
syncGameItemSortable,
|
|
mergeRequestItemsIntoDrafts,
|
|
removeUploadDraft,
|
|
loadGame,
|
|
createGame,
|
|
handleItemFiles,
|
|
onFile,
|
|
openItemFilePicker,
|
|
clearItemFiles,
|
|
uploadItem,
|
|
saveGameItemOrder,
|
|
}
|
|
}
|