426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
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 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 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,
|
|
})),
|
|
}
|
|
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 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
|
|
}
|
|
}
|
|
|
|
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,
|
|
})),
|
|
}
|
|
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
|
|
await syncTemplateItemSortable()
|
|
success.value = '기본 아이템 순서를 저장했어요.'
|
|
} catch (e) {
|
|
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
|
}
|
|
}
|
|
|
|
return {
|
|
requestItemFilename,
|
|
destroyTemplateItemSortable,
|
|
syncTemplateItemSortable,
|
|
mergeRequestItemsIntoDrafts,
|
|
removeUploadDraft,
|
|
loadTemplate,
|
|
createTemplate,
|
|
handleItemFiles,
|
|
onFile,
|
|
openItemFilePicker,
|
|
clearItemFiles,
|
|
uploadItem,
|
|
saveTemplateItemOrder,
|
|
}
|
|
}
|