From 5eb08e175733ebc035e08525a1f002eaa277009c Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Apr 2026 01:06:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.35=20?= =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EA=B2=80=EC=83=89=EA=B3=BC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/admin.js | 63 +++++++++---- docs/history.md | 5 + docs/todo.md | 1 + docs/update.md | 8 ++ frontend/src/App.vue | 2 +- .../composables/useAdminTemplateManager.js | 37 +++++++- frontend/src/views/TierEditorView.vue | 93 ++++++++++++++++++- 7 files changed, 185 insertions(+), 24 deletions(-) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0212d59..9807e06 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -212,28 +212,57 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images' const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : [] const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : '')) if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' }) + const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0) - const items = await Promise.all( - files.map(async (file, index) => { - const optimized = await writeOptimizedImage({ - file, - directory: 'topics', - width: 512, - height: 512, - fit: 'inside', - quality: 84, - }) + console.info('[admin] template image upload start', { + templateId: template.id, + fileCount: files.length, + totalBytes, + fileNames: files.map((file) => file.originalname), + }) - return createTopicItem({ - id: nanoid(), - topicId: template.id, - src: optimized.src, - label: normalizedLabels[index] || buildItemLabelFromFilename(file), + try { + const items = await Promise.all( + files.map(async (file, index) => { + const optimized = await writeOptimizedImage({ + file, + directory: 'topics', + width: 512, + height: 512, + fit: 'inside', + quality: 84, + }) + + return createTopicItem({ + id: nanoid(), + topicId: template.id, + src: optimized.src, + label: normalizedLabels[index] || buildItemLabelFromFilename(file), + }) }) + ) + + console.info('[admin] template image upload success', { + templateId: template.id, + fileCount: items.length, + totalBytes, }) - ) - res.json({ item: items[0], items }) + res.json({ item: items[0], items }) + } catch (error) { + console.error('[admin] template image upload failed', { + templateId: template.id, + fileCount: files.length, + totalBytes, + message: error?.message || 'upload_failed', + code: error?.code || '', + stack: error?.stack || '', + }) + res.status(500).json({ + error: 'template_image_upload_failed', + detail: error?.message || 'upload_failed', + }) + } }) router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => { diff --git a/docs/history.md b/docs/history.md index 1a6e8c0..2bee19b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -612,6 +612,11 @@ - 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다. - 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다. +## 2026-04-03 v1.4.35 +- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다. +- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다. +- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다. + ## 2026-04-02 v1.4.34 - 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다. - 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다. diff --git a/docs/todo.md b/docs/todo.md index c8ddd4c..24f9271 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -124,6 +124,7 @@ - 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다. - 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다. - 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다. +- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다. - 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다. - 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다. - 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다. diff --git a/docs/update.md b/docs/update.md index 0ffadfc..7d84e71 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1104,6 +1104,14 @@ - **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화 - **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리 +## 2026-04-03 v1.4.35 +- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가 +- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지 +- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함 +- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임 +- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강 +- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경 + ## 2026-04-02 v1.4.34 - **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정 - **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f86f232..2db1e9d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,7 +22,7 @@ const route = useRoute() const router = useRouter() const auth = useAuthStore() const { toasts, dismissToast } = useToast() -const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito' +const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox' const currentTopicId = computed(() => route.params.topicId || '') const leftRailCollapsed = ref(false) diff --git a/frontend/src/composables/useAdminTemplateManager.js b/frontend/src/composables/useAdminTemplateManager.js index 8c218b4..072069b 100644 --- a/frontend/src/composables/useAdminTemplateManager.js +++ b/frontend/src/composables/useAdminTemplateManager.js @@ -278,9 +278,16 @@ export function useAdminTemplateManager({ 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) @@ -291,7 +298,25 @@ export function useAdminTemplateManager({ credentials: 'include', body: fd, }) - if (!res.ok) throw new Error('failed') + 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 } @@ -316,6 +341,12 @@ export function useAdminTemplateManager({ 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 = '추가할 요청 아이템이 없어요.' @@ -330,6 +361,10 @@ export function useAdminTemplateManager({ error.value = '선택한 템플릿을 찾지 못했어요.' return } + if (e?.status === 413) { + error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.' + return + } error.value = '아이템 추가에 실패했어요.' } } diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index d9fb62f..4be00b2 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -9,7 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg' import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg' import shareIcon from '../assets/icons/share.svg' import { api } from '../lib/api' -import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths' +import { editorNewPath, editorPath, homePath, loginPath, mePath, topicPath } from '../lib/paths' import { toApiUrl } from '../lib/runtime' import { useAuthStore } from '../stores/auth' import { useToast } from '../composables/useToast' @@ -72,6 +72,7 @@ const favoriteCount = ref(0) const isFavorited = ref(false) const isRequestingTemplate = ref(false) const isDeleting = ref(false) +const poolSearchQuery = ref('') const boardEl = ref(null) const exportBoardEl = ref(null) @@ -122,6 +123,7 @@ const copiedFromLabel = computed(() => { return parts.join(' · ') || '복사해 온 티어표' }) const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom')) +const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase()) const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new'))) const canRequestTemplateCreate = computed( () => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0 @@ -138,6 +140,7 @@ const shareTierListUrl = computed(() => { if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true }) return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString() }) +const previewHomeUrl = computed(() => homePath()) watch(error, (message) => { if (!message) return @@ -166,8 +169,6 @@ function formatExportDate(ts) { year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', - minute: '2-digit', }) } @@ -194,6 +195,16 @@ function getOrderedItems() { return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean) } +function isPoolItemVisible(itemId) { + const query = normalizedPoolSearchQuery.value + if (!query) return true + const item = itemsById.value[itemId] + const label = String(item?.label || itemId || '').toLowerCase() + return label.includes(query) +} + +const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length) + function setIconSize(nextSize) { iconSize.value = nextSize } @@ -963,6 +974,10 @@ onUnmounted(() => {