공유 덱 카드 복원 기준을 imgSrc로 전환
동일 번호 카드가 많은 시리즈에서도 선택한 카드가 정확히 복원되도록 저장·검증·복원 흐름을 imgSrc 중심으로 정리하고, 이미지 누락 시 공통 뒷면 폴백을 적용한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.16",
|
||||
"version": "4.0.17",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { pathToFileURL } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {Map<string, Map<string, object>>} */
|
||||
/**
|
||||
* @typedef {object} CardIndex
|
||||
* @property {Map<string, object>} byNumber
|
||||
* @property {Map<string, object>} byImgSrc
|
||||
*/
|
||||
|
||||
/** @type {Map<string, CardIndex>} */
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* 시리즈 카드 맵 로드
|
||||
* 시리즈 카드 인덱스 로드
|
||||
* @param {string} seriesKey
|
||||
* @returns {Promise<Map<string, object>>}
|
||||
* @returns {Promise<CardIndex>}
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = `
|
||||
<div class="card_image cursor-pointer relative w-full mb-2 rounded overflow-hidden bg-slate-50" style="aspect-ratio: 2/3;">
|
||||
<img src="${imgSrc}" class="absolute inset-0 w-full h-full object-contain transition-transform"
|
||||
onerror="console.error('이미지 로드 실패:', '${card.Number}', '${imgSrc}'); this.src='/images/uapr/comingsoon.png'; this.onerror=null;">
|
||||
onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-1.5 px-0.5">
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.17
|
||||
|
||||
### 공유 덱 카드 복원 정확도 개선
|
||||
|
||||
공유 링크로 덱을 불러올 때 카드 복원 기준을 `imgSrc`로 통일해, 같은 카드 번호를 가진 일반·패러렐 카드가 섞여 있어도 선택한 카드가 정확히 다시 표시됩니다.
|
||||
|
||||
### 누락 이미지 표시 방식 개선
|
||||
|
||||
카드 목록과 덱 프리뷰에서 이미지 경로가 잘못되었거나 파일이 없는 경우 깨진 이미지 대신 카드 뒷면 이미지를 표시하도록 개선했습니다.
|
||||
|
||||
## v4.0.16
|
||||
|
||||
### 회원 탈퇴
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원
|
||||
|
||||
💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문.
|
||||
🔧 덱 저장 payload에 `imgSrc`를 필수 포함하고 서버 복원은 `imgSrc` 우선, 레거시 데이터만 `number`로 보조 복원.
|
||||
|
||||
## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존
|
||||
|
||||
💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함.
|
||||
|
||||
@@ -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/<series>.js`에서 이미지·색상·희귀도·트리거 정보를 합성한다.
|
||||
- 같은 `Number`에 여러 판본이 있을 경우 API는 저장된 `imgSrc`를 기준으로 카드 메타를 복원한다.
|
||||
- 공개 덱 목록은 `share_type='user'`, `is_public=1`, 미만료 조건을 사용한다.
|
||||
- `preset` 타입과 관리자 프리셋 API는 레거시 호환용으로 코드에 남아 있으며 현재 공개 덱 화면에서는 사용하지 않는다.
|
||||
|
||||
|
||||
@@ -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 — 회원 탈퇴·관리자 덱 일괄 삭제
|
||||
|
||||
### 수행 작업
|
||||
|
||||
21
result.html
21
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 = `
|
||||
<div class="card-badge">${card.count}</div>
|
||||
<div class="card-image-box ${isKey ? 'key-card-highlight' : ''}">
|
||||
<img src="${imgSrc}" class="w-full h-full object-contain" crossorigin="anonymous">
|
||||
<img src="${imgSrc}" class="w-full h-full object-contain" crossorigin="anonymous" onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
|
||||
${isKey ? `<div class="absolute bottom-0 left-0 right-0 key-label text-[10px] text-center py-1 font-bold">${keyLabelText}</div>` : ''}
|
||||
</div>
|
||||
<div class="mt-2 text-[10px] font-mono text-slate-400">${card.Number}</div>
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user