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, } }