import { nextTick } from 'vue' import Sortable from 'sortablejs' export function useAdminTemplateManager({ api, toApiUrl, selectedTemplateId, selectedTemplate, uploadFiles, uploadItemDrafts, thumbFile, itemPreviewUrls, itemFileInput, templateItemListEl, templateItemSortable, savedTemplateItemOrderIds, isTemplateLoading, activeTemplateRequest, templateRequests, customItemModalOpen, customItemModalTargetTemplateId, newTemplateId, newTemplateName, newTemplateIsPublic, clearPreviewUrl, resetFileInput, resetUploadState, refreshTemplates, closeTemplateCreateModal, resetMessages, success, error, }) { function parseTagsText(value) { return Array.from( new Set( String(value || '') .split(',') .map((tag) => tag.trim().replace(/^#/, '').slice(0, 40)) .filter(Boolean) ) ).slice(0, 30) } 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 destroyTemplateItemSortable() { if (templateItemSortable.value) { templateItemSortable.value.destroy() templateItemSortable.value = null } } async function syncTemplateItemSortable() { await nextTick() destroyTemplateItemSortable() if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return templateItemSortable.value = Sortable.create(templateItemListEl.value, { animation: 160, draggable: '[data-template-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 = [...(selectedTemplate.value?.items || [])] const [moved] = nextItems.splice(evt.oldIndex, 1) nextItems.splice(evt.newIndex, 0, moved) selectedTemplate.value = { ...selectedTemplate.value, items: nextItems, } }, }) } function mergeRequestItemsIntoDrafts(request) { const requestId = request?.id if (!requestId) return const existingTemplateSrcs = new Set((selectedTemplate.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) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src))) .filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`)) if (nextRequestDrafts.length) { uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts] } } function mergeLibraryItemsIntoDrafts(items, sourceLabel = '') { const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean)) const existingDraftSrcs = new Set(uploadItemDrafts.value.map((draft) => normalizeDraftSrc(draft?.src)).filter(Boolean)) const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.itemSourceType || ''}:${draft.itemId || draft.file?.name || ''}`)) const nextDrafts = (items || []) .filter((item) => item?.id && item?.src) .map((item) => ({ kind: 'library', itemId: item.id, itemSourceType: item.sourceType || 'template', previewUrl: toApiUrl(item.src), label: item.label || '', tagsText: Array.isArray(item.tags) ? item.tags.join(', ') : '', sourceName: sourceLabel ? `${sourceLabel} · ${item.label || item.id}` : (item.label || item.id), src: item.src, })) .filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src))) .filter((draft) => !existingDraftSrcs.has(normalizeDraftSrc(draft.src))) .filter((draft) => !existingKeys.has(`${draft.kind}:${draft.itemSourceType}:${draft.itemId}`)) if (nextDrafts.length) { uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextDrafts] } return nextDrafts.length } 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 loadTemplate(options = {}) { const preserveUploadState = !!options.preserveUploadState resetMessages() if (!preserveUploadState) resetUploadState() if (!selectedTemplateId.value) { selectedTemplate.value = null savedTemplateItemOrderIds.value = [] destroyTemplateItemSortable() return } try { isTemplateLoading.value = true const data = await api.getTopic(selectedTemplateId.value) const loadedTemplate = data.template || data.topic || null selectedTemplate.value = { ...data, template: loadedTemplate, items: (data.items || []).map((item) => ({ ...item, draftLabel: item.label, draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '', })), } savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id) await syncTemplateItemSortable() } catch (e) { selectedTemplate.value = null error.value = '템플릿 정보를 불러오지 못했어요.' } finally { isTemplateLoading.value = false } } async function createTemplate(options = {}) { const nextTopicSlug = typeof options.topicId === 'string' ? options.topicId.trim().toLowerCase() : newTemplateId.value.trim().toLowerCase() const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim() const preserveUploadState = !!options.preserveUploadState resetMessages() try { const res = await fetch(toApiUrl('/api/admin/templates'), { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: nextTopicSlug, name: nextTopicName, isPublic: !!newTemplateIsPublic.value, thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', }), }) const data = await res.json() if (!res.ok) { const requestError = new Error('failed') requestError.data = data throw requestError } const createdTemplate = data.template || {} if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) { const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, { topicId: createdTemplate.id, }) activeTemplateRequest.value = { ...activeTemplateRequest.value, targetTopicId: linkData.request?.targetTopicId || createdTemplate.id, targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName, } const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id) if (requestIndex >= 0) { templateRequests.value.splice(requestIndex, 1, { ...templateRequests.value[requestIndex], targetTopicId: linkData.request?.targetTopicId || createdTemplate.id, targetTopicName: linkData.request?.targetTopicName || createdTemplate.name || nextTopicName, }) } } await refreshTemplates() selectedTemplateId.value = createdTemplate.id if (customItemModalOpen.value) customItemModalTargetTemplateId.value = createdTemplate.id closeTemplateCreateModal() await loadTemplate({ 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) { const errorCode = e?.data?.error || '' if (errorCode === 'topic_slug_taken') { error.value = '이미 사용 중인 템플릿 주소(slug)입니다.' return } if (errorCode === 'topic_slug_invalid') { error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' return } error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)' } } 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 (!selectedTemplateId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetTopicId) { const draftTopicId = (activeTemplateRequest.value?.draftTopicId || '').trim() const draftTopicName = (activeTemplateRequest.value?.draftTopicName || '').trim() if (!draftTopicId || !draftTopicName) { error.value = '먼저 신규 템플릿의 이름과 템플릿 ID를 저장해주세요.' return } await createTemplate({ topicId: draftTopicId, topicName: draftTopicName, preserveUploadState: true, }) } if (!selectedTemplateId.value) { error.value = '템플릿을 먼저 선택해주세요.' return } const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file') const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request') const libraryDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'library') const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0) let uploadCount = 0 if (fileDrafts.length) { console.info('[admin] template item upload start', { topicId: selectedTemplateId.value, fileCount: fileDrafts.length, totalBytes: totalUploadBytes, labels: fileDrafts.map((entry) => entry.label.trim()), }) 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/templates/${encodeURIComponent(selectedTemplateId.value)}/images`), { method: 'POST', credentials: 'include', body: fd, }) if (!res.ok) { const responseText = await res.text().catch(() => '') console.error('[admin] template item upload failed', { topicId: selectedTemplateId.value, fileCount: fileDrafts.length, totalBytes: totalUploadBytes, status: res.status, body: responseText, }) const uploadError = new Error('failed') uploadError.status = res.status uploadError.body = responseText throw uploadError } console.info('[admin] template item upload success', { topicId: selectedTemplateId.value, fileCount: fileDrafts.length, totalBytes: totalUploadBytes, }) 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, { topicId: selectedTemplateId.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 } } if (libraryDrafts.length) { for (const draft of libraryDrafts) { const promoted = await api.promoteAdminTemplateItem(draft.itemId, { topicId: selectedTemplateId.value, }) const createdItem = promoted?.item || null if (createdItem?.id) { const nextTags = parseTagsText(draft.tagsText) const needsMetaUpdate = (draft.label || '').trim() !== (createdItem.label || '').trim() || JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(createdItem.tags) ? createdItem.tags : []) if (needsMetaUpdate) { await api.updateAdminTemplateItem(selectedTemplateId.value, createdItem.id, { label: (draft.label || createdItem.label || '').trim(), tags: nextTags, }) } uploadCount += 1 } } } resetUploadState() await loadTemplate() success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.` } catch (e) { console.error('[admin] uploadItem error', { message: e?.message || '', status: e?.status || 0, body: e?.body || '', data: e?.data || null, }) 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 === 'topic_not_found') { error.value = '선택한 템플릿을 찾지 못했어요.' return } if (e?.status === 413) { error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.' return } error.value = '아이템 추가에 실패했어요.' } } async function saveTemplateItemOrder() { resetMessages() if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return try { const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, { itemIds: selectedTemplate.value.items.map((item) => item.id), }) selectedTemplate.value = { ...selectedTemplate.value, items: (data.items || []).map((item) => ({ ...item, draftLabel: item.label, draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '', })), } savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id) await syncTemplateItemSortable() success.value = '기본 아이템 순서를 저장했어요.' } catch (e) { error.value = '기본 아이템 순서 저장에 실패했어요.' } } return { requestItemFilename, destroyTemplateItemSortable, syncTemplateItemSortable, mergeRequestItemsIntoDrafts, mergeLibraryItemsIntoDrafts, removeUploadDraft, loadTemplate, createTemplate, handleItemFiles, onFile, openItemFilePicker, clearItemFiles, uploadItem, saveTemplateItemOrder, } }