diff --git a/VERSION b/VERSION index eb4ff85..4682e44 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v4.0.16 +v4.0.17 diff --git a/api/package.json b/api/package.json index a56abd8..48f9924 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "uadeck-api", - "version": "4.0.16", + "version": "4.0.17", "type": "module", "main": "src/index.js", "scripts": { diff --git a/api/src/services/cardResolver.js b/api/src/services/cardResolver.js index b80c437..364f159 100644 --- a/api/src/services/cardResolver.js +++ b/api/src/services/cardResolver.js @@ -1,13 +1,19 @@ import { pathToFileURL } from 'url'; import path from 'path'; -/** @type {Map>} */ +/** + * @typedef {object} CardIndex + * @property {Map} byNumber + * @property {Map} byImgSrc + */ + +/** @type {Map} */ const cache = new Map(); /** - * 시리즈 카드 맵 로드 + * 시리즈 카드 인덱스 로드 * @param {string} seriesKey - * @returns {Promise>} + * @returns {Promise} */ export async function getCardMap(seriesKey) { if (cache.has(seriesKey)) { @@ -25,27 +31,34 @@ export async function getCardMap(seriesKey) { } const byNumber = new Map(); + const byImgSrc = new Map(); for (const card of mod.cardList) { - byNumber.set(card.Number, card); + if (!byNumber.has(card.Number)) { + byNumber.set(card.Number, card); + } + if (card.imgSrc && !byImgSrc.has(card.imgSrc)) { + byImgSrc.set(card.imgSrc, card); + } } - cache.set(seriesKey, byNumber); - return byNumber; + const index = { byNumber, byImgSrc }; + cache.set(seriesKey, index); + return index; } /** * 저장된 덱 카드를 풀 카드 객체로 합성 * @param {string} seriesKey - * @param {Array<{number: string, count: number, key: boolean}>} cards + * @param {Array<{number?: string, imgSrc?: string, count: number, key: boolean}>} cards * @returns {Promise<{cards: object[], totalCards: number}>} */ export async function resolveDeckCards(seriesKey, cards) { - const map = await getCardMap(seriesKey); + const index = await getCardMap(seriesKey); const resolved = []; let totalCards = 0; for (const item of cards) { - const base = map.get(item.number); + const base = (item.imgSrc && index.byImgSrc.get(item.imgSrc)) || index.byNumber.get(item.number); if (!base) continue; totalCards += item.count; diff --git a/api/src/utils.js b/api/src/utils.js index 252d67a..60a3641 100644 --- a/api/src/utils.js +++ b/api/src/utils.js @@ -18,23 +18,24 @@ export function generateShareCode(length = 8) { /** * 덱 본문 정규화 (해시용) - * @param {Array<{number: string, count: number, key: boolean}>} cards + * @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards * @returns {string} */ export function normalizeCards(cards) { const sorted = [...cards] .map((c) => ({ + imgSrc: String(c.imgSrc).trim(), number: String(c.number).trim(), count: Number(c.count), key: Boolean(c.key), })) - .sort((a, b) => a.number.localeCompare(b.number)); + .sort((a, b) => a.imgSrc.localeCompare(b.imgSrc)); return JSON.stringify(sorted); } /** * 프리셋 덱용 내용 해시 (8자) - * @param {Array<{number: string, count: number, key: boolean}>} cards + * @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards * @returns {string} */ export function contentHash(cards) { @@ -45,7 +46,7 @@ export function contentHash(cards) { /** * 덱 카드 배열 검증 * @param {unknown} cards - * @returns {Array<{number: string, count: number, key: boolean}>} + * @returns {Array<{imgSrc: string, number: string, count: number, key: boolean}>} */ export function validateCards(cards) { if (!Array.isArray(cards) || cards.length === 0) { @@ -54,6 +55,9 @@ export function validateCards(cards) { let total = 0; const normalized = cards.map((c) => { + if (!c.imgSrc || typeof c.imgSrc !== 'string' || !c.imgSrc.trim()) { + throw new Error('카드 형식이 올바르지 않습니다. (imgSrc 필요)'); + } if (!c.number || c.count == null) { throw new Error('카드 형식이 올바르지 않습니다.'); } @@ -63,6 +67,7 @@ export function validateCards(cards) { } total += count; return { + imgSrc: String(c.imgSrc).trim(), number: String(c.number).trim(), count, key: Boolean(c.key), diff --git a/cardList.html b/cardList.html index 9c5d69c..b713ae0 100644 --- a/cardList.html +++ b/cardList.html @@ -247,6 +247,21 @@ let selectedColors = new Set(['All']); let currentShareCode = null; let currentDeckName = ''; + const CARD_IMAGE_FALLBACK_SRC = '/images/uapr/comingsoon.png'; + + /** + * 카드 데이터의 실제 렌더링 이미지 URL 생성 + * @param {string} currentSeriesKey + * @param {string} cardImgSrc + * @returns {string} + */ + function resolveCardImageSrc(currentSeriesKey, cardImgSrc) { + const fileName = (cardImgSrc || '').split('/').pop(); + if (!currentSeriesKey || !fileName) { + return CARD_IMAGE_FALLBACK_SRC; + } + return `/images/${currentSeriesKey}/${fileName}`; + } const getEl = () => ({ searchQuery: document.getElementById('searchQuery'), @@ -408,13 +423,12 @@ cardContainer.className = 'cardContainer group flex flex-col p-2 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-blue-300 transition-colors'; cardContainer.setAttribute('data-imgsrc', card.imgSrc); - const fileName = card.imgSrc.split('/').pop(); - const imgSrc = `images/${seriesKey}/${fileName}`; + const imgSrc = resolveCardImageSrc(seriesKey, card.imgSrc); cardContainer.innerHTML = `
+ onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
@@ -737,7 +751,11 @@ function showOverlay(card, index, list) { const overlay = document.getElementById('imageOverlay'); const img = overlay.querySelector('img'); - img.src = `images/${seriesKey}/${card.imgSrc.split('/').pop()}`; + img.src = resolveCardImageSrc(seriesKey, card.imgSrc); + img.onerror = () => { + img.onerror = null; + img.src = CARD_IMAGE_FALLBACK_SRC; + }; overlay.style.display = 'flex'; overlay.onclick = (e) => { if (e.target === overlay) overlay.style.display = 'none'; diff --git a/docs/changelog.md b/docs/changelog.md index ee17ed0..3bf03d0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,15 @@ # 변경사항 +## v4.0.17 + +### 공유 덱 카드 복원 정확도 개선 + +공유 링크로 덱을 불러올 때 카드 복원 기준을 `imgSrc`로 통일해, 같은 카드 번호를 가진 일반·패러렐 카드가 섞여 있어도 선택한 카드가 정확히 다시 표시됩니다. + +### 누락 이미지 표시 방식 개선 + +카드 목록과 덱 프리뷰에서 이미지 경로가 잘못되었거나 파일이 없는 경우 깨진 이미지 대신 카드 뒷면 이미지를 표시하도록 개선했습니다. + ## v4.0.16 ### 회원 탈퇴 diff --git a/docs/history.md b/docs/history.md index 6396d3b..d961e47 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원 + +💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문. +🔧 덱 저장 payload에 `imgSrc`를 필수 포함하고 서버 복원은 `imgSrc` 우선, 레거시 데이터만 `number`로 보조 복원. + ## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존 💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함. diff --git a/docs/spec.md b/docs/spec.md index 2a450f5..f7a06fa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -3,7 +3,7 @@ ## 프로젝트 개요 - 서비스명: `UNIARE - Union Arena Deck Builder` -- 현재 버전: `v4.0.16` +- 현재 버전: `v4.0.17` - 프론트: 정적 HTML, CSS, JavaScript - 백엔드: Express, SQLite(`better-sqlite3`) - 운영: Docker Compose 기반 Nginx + API @@ -69,6 +69,7 @@ - 폴백 이미지: `/images/uapr/comingsoon.png` - 브랜드 자산: `images/assets/` - `images/**`는 Git LFS로 관리한다. +- 카드 목록·덱 결과 화면에서 카드 이미지 로드 실패 시 폴백 이미지를 표시한다. - 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다. - 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다. - 덱 삭제 시 해당 공유 코드의 미리보기 PNG도 함께 삭제한다. @@ -122,8 +123,9 @@ - 덱 저장과 공개 링크 생성은 로그인한 회원만 사용할 수 있다. - 비회원은 덱 구성과 PNG 이미지 다운로드만 사용할 수 있다. - 기존 비회원 공유 덱은 호환을 위해 원래 만료일까지 조회할 수 있다. -- DB의 `cards_json`에는 `{number, count, key}`만 저장한다. +- DB의 `cards_json`에는 `{imgSrc, number, count, key}`를 저장한다. - API 응답 시 `datas/.js`에서 이미지·색상·희귀도·트리거 정보를 합성한다. +- 같은 `Number`에 여러 판본이 있을 경우 API는 저장된 `imgSrc`를 기준으로 카드 메타를 복원한다. - 공개 덱 목록은 `share_type='user'`, `is_public=1`, 미만료 조건을 사용한다. - `preset` 타입과 관리자 프리셋 API는 레거시 호환용으로 코드에 남아 있으며 현재 공개 덱 화면에서는 사용하지 않는다. diff --git a/docs/update.md b/docs/update.md index 11004c6..17337dd 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## 2026-06-25 / v4.0.17 — 누락 이미지 폴백·공유 덱 매핑 보정 + +### 수행 작업 +- 덱 저장 카드 payload를 `imgSrc` 필수로 확장하고 API 검증에서 `imgSrc` 누락 요청을 차단 +- 덱 복원은 `imgSrc` 기준으로 정확 매핑하고, 기존 구데이터 호환을 위해서만 `number`를 보조 fallback으로 유지 +- `cardList.html`, `result.html` 카드 렌더링에서 이미지 로드 실패 시 공통 폴백(`/images/uapr/comingsoon.png`)을 사용 +- 카드 목록 확대 미리보기(오버레이)도 동일 폴백을 적용해 이미지 누락 시 깨짐 대신 뒷면 노출 + ## 2026-06-25 / v4.0.16 — 회원 탈퇴·관리자 덱 일괄 삭제 ### 수행 작업 diff --git a/result.html b/result.html index a053cbc..27812a9 100644 --- a/result.html +++ b/result.html @@ -526,6 +526,7 @@ let currentStyle = 'type1'; let selectedCards = []; let seriesKey = ''; + const CARD_IMAGE_FALLBACK_SRC = '/images/uapr/comingsoon.png'; // 전역 함수로 등록 (onclick 이벤트 대응) window.changeStyle = changeStyle; @@ -537,6 +538,20 @@ let translations = {}; + /** + * 카드 이미지 URL 생성 (누락 시 공통 뒷면 사용) + * @param {string} currentSeriesKey + * @param {string} cardImgSrc + * @returns {string} + */ + function resolveCardImageSrc(currentSeriesKey, cardImgSrc) { + const fileName = (cardImgSrc || '').split('/').pop(); + if (!currentSeriesKey || !fileName) { + return CARD_IMAGE_FALLBACK_SRC; + } + return `/images/${currentSeriesKey}/${fileName}`; + } + document.addEventListener('DOMContentLoaded', async () => { const urlParams = new URLSearchParams(window.location.search); const currentLang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko'; @@ -653,15 +668,14 @@ const div = document.createElement('div'); div.className = `${widthClass} card-wrapper`; - const fileName = card.imgSrc.split('/').pop(); - const imgSrc = `/images/${seriesKey}/${fileName}`; + const imgSrc = resolveCardImageSrc(seriesKey, card.imgSrc); const keyLabelText = translations.keyCardLabel || "KEY CARD"; div.innerHTML = `
${card.count}
- + ${isKey ? `
${keyLabelText}
` : ''}
${card.Number}
@@ -726,6 +740,7 @@ return selectedCards .filter((c) => c.count > 0) .map((c) => ({ + imgSrc: c.imgSrc, number: c.Number, count: c.count, key: Boolean(c.key),