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, clearPreviewUrl, resetFileInput, resetUploadState, refreshGames, closeGameCreateModal, resetMessages, success, error, }) { 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]', 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 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) => !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() { resetMessages() 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) { console.error('[AdminView] loadGame failed', selectedGameId.value, e) selectedGame.value = null error.value = '게임 정보를 불러오지 못했어요.' } finally { isGameLoading.value = false } } async function createGame() { resetMessages() try { const res = await fetch(toApiUrl('/api/admin/games'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: newGameId.value.trim(), name: newGameName.value.trim() }), }) if (!res.ok) throw new Error('failed') const data = await res.json() await refreshGames() selectedGameId.value = data.game.id if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id closeGameCreateModal() await loadGame() if (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 || !selectedGameId.value) { error.value = '아이템 파일을 선택해주세요.' return } try { 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) await api.promoteAdminTemplateRequestItems(requestId, { gameId: selectedGameId.value, itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean), itemLabels: draftsForRequest.reduce((acc, entry) => { if (entry.itemId) acc[entry.itemId] = entry.label.trim() return acc }, {}), }) uploadCount += draftsForRequest.length } } resetUploadState() await loadGame() success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.` } catch (e) { 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, } }