diff --git a/VERSION b/VERSION index 0204305..36c263a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v4.0.18 +v4.0.19 diff --git a/admin.html b/admin.html index cec0ebe..40a249d 100644 --- a/admin.html +++ b/admin.html @@ -353,13 +353,13 @@ } function renderDeckPreview(d) { - if (!d.preview_url) { + if (!d.preview_url && !d.thumbnail_url) { return '
.thumb.webp`)해 우선 사용하고 확대 시에만 원본 PNG 사용, 썸네일 인코딩은 브라우저 canvas로 처리하고 미지원 시 원본으로 폴백.
+
## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원
💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문.
diff --git a/docs/map.md b/docs/map.md
index 27a9328..815e227 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -89,6 +89,9 @@ api/src/routes/decks.js
덱 썸네일 확대
script/deck-preview-hold.js
+덱 목록 무한 스크롤 페이저
+script/deck-list-pager.js
+
이미지 PNG 변환
script/image-utils.js
diff --git a/docs/spec.md b/docs/spec.md
index 6964b1c..23438f3 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -3,7 +3,7 @@
## 프로젝트 개요
- 서비스명: `UNIARE - Union Arena Deck Builder`
-- 현재 버전: `v4.0.18`
+- 현재 버전: `v4.0.19`
- 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API
@@ -72,7 +72,10 @@
- 카드 목록·덱 결과 화면에서 카드 이미지 로드 실패 시 폴백 이미지를 표시한다.
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
-- 덱 삭제 시 해당 공유 코드의 미리보기 PNG도 함께 삭제한다.
+- 덱 저장 시 목록용 축소 썸네일을 420px WebP(`.thumb.webp`)로 함께 생성한다. (브라우저 WebP 인코딩 미지원 시 썸네일 생략, 원본으로 폴백)
+- 목록 화면(내 덱·공개 덱·관리자)은 썸네일을 우선 표시하고, 길게 눌러 확대할 때만 원본 PNG를 사용한다.
+- 내 덱·공개 덱 목록은 클라이언트 무한 스크롤(청크 24개)로 렌더링한다.
+- 덱 삭제·미참조 정리 시 해당 공유 코드의 원본 PNG와 썸네일 WebP를 함께 삭제한다.
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
## 브라우저 상태
@@ -223,6 +226,7 @@
|---|---|---|---|
| GET | `/api/v1/decks/presets` | 공개 | 공개 사용자 덱 목록 |
| GET | `/api/v1/decks/:shareCode/preview` | 공개 | 덱 미리보기 PNG |
+| GET | `/api/v1/decks/:shareCode/thumbnail` | 공개 | 덱 목록용 축소 WebP 썸네일 |
| GET | `/api/v1/decks/:shareCode` | 조건부 | 공개 덱 또는 소유 덱 상세 |
| GET | `/api/v1/decks` | 회원 | 내 덱 목록 |
| POST | `/api/v1/decks` | 회원 | 내 덱 저장과 공개 링크 생성 |
diff --git a/docs/update.md b/docs/update.md
index 68688ea..2dec5d2 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,20 @@
# 업데이트 이력
+## 2026-07-02 / v4.0.19 — 덱 목록 무한 스크롤·WebP 썸네일
+
+### 수행 작업
+- 공용 무한 스크롤 페이저 모듈 `script/deck-list-pager.js` 추가 (IntersectionObserver 기반, 청크 24개, 하단 근접 시 이어붙임, 미지원 시 전체 렌더 폴백)
+- `preset.html`, `deckHistory.html` 덱 목록을 무한 스크롤로 전환해 대량 덱에서도 초기 DOM 렌더 부담 완화
+- 즐겨찾기 토글 시 전체 리렌더 대신 해당 카드만 in-place 갱신해 스크롤 위치 유지(즐겨찾기 필터 모드에서는 리렌더 유지)
+- 저장 시 클라이언트가 원본(1200px PNG)과 함께 목록용 축소 썸네일(420px WebP, 품질 0.82)을 생성·전송 (`result.html`)
+- API가 썸네일을 `.thumb.webp`로 저장하고 `GET /decks/:shareCode/thumbnail` 및 응답 `thumbnail_url`/`thumbnailUrl` 제공
+- 목록 화면(내 덱·공개 덱·관리자)은 썸네일 우선 표시, 길게 눌러 확대할 때만 원본 사용
+- 덱 삭제·미참조 정리(orphan)가 원본 PNG와 썸네일 WebP를 함께 처리하도록 확장
+
+### 검증
+- 서버 모듈 `node --check` 문법 검증 통과 (previewImage.js, decks.js, admin.js)
+- 프론트 lint 확인(신규/수정 파일 오류 없음, 기존 Tailwind 경고만 잔존)
+
## 2026-06-25 / v4.0.18 — 누락 이미지 폴백·공유 덱 매핑 보정
### 수행 작업
diff --git a/preset.html b/preset.html
index 1b59a4e..af1fd8b 100644
--- a/preset.html
+++ b/preset.html
@@ -74,6 +74,7 @@
+
@@ -138,14 +139,34 @@
}
/**
+ * 미디어 URL에 갱신 시각 기반 캐시 무효화 쿼리를 붙인다.
+ * @param {string|null} apiPath
+ * @param {string|null} updatedAt
+ * @returns {string|null}
+ */
+ function withVersion(apiPath, updatedAt) {
+ if (!apiPath) return null;
+ const src = UaDeckApi.resolveMediaUrl(apiPath);
+ const version = updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : '';
+ return src + version;
+ }
+
+ /**
+ * 목록 표시용 이미지(썸네일 우선)
* @param {object} d
* @returns {string|null}
*/
- function getPreviewSrc(d) {
- if (!d.preview_url) return null;
- const src = UaDeckApi.resolveMediaUrl(d.preview_url);
- const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
- return src + version;
+ function getThumbSrc(d) {
+ return withVersion(d.thumbnail_url || d.preview_url, d.updated_at);
+ }
+
+ /**
+ * 확대 표시용 원본 이미지(원본 우선)
+ * @param {object} d
+ * @returns {string|null}
+ */
+ function getFullSrc(d) {
+ return withVersion(d.preview_url || d.thumbnail_url, d.updated_at);
}
/**
@@ -171,12 +192,13 @@
}
function renderPreviewHtml(d, title) {
- const previewSrc = getPreviewSrc(d);
- if (!previewSrc) {
+ const thumbSrc = getThumbSrc(d);
+ if (!thumbSrc) {
return `${escapeHtml(t('deckNoPreview', '미리보기 없음'))}`;
}
- const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
- return `
`;
+ const fullSrc = getFullSrc(d) || thumbSrc;
+ const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`;
+ return `
`;
}
function renderDeckCard(d) {
@@ -199,27 +221,55 @@
`;
}
+ /** @type {{ refresh: (items: object[]) => void }|null} */
+ let deckPager = null;
+
+ /**
+ * 현재 필터 상태에서 표시할 빈 목록 메시지
+ * @returns {string}
+ */
+ function emptyMessage() {
+ const favOnly =
+ document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
+ return favOnly
+ ? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
+ : allDecks.length
+ ? t('deckNoMatch', '조건에 맞는 덱이 없습니다.')
+ : t('publicDeckEmpty', '공개된 덱이 없습니다.');
+ }
+
+ /**
+ * 즐겨찾기 상태를 카드 DOM에 즉시 반영 (전체 리렌더 없이 스크롤 유지)
+ * @param {string} shareCode
+ * @param {boolean} on
+ */
+ function applyFavoriteState(shareCode, on) {
+ const article = document.querySelector(`#preset-list .deck-card[data-code="${CSS.escape(shareCode)}"]`);
+ if (article) article.classList.toggle('is-favorite', on);
+ const btn = document.querySelector(`#preset-list [data-favorite="${CSS.escape(shareCode)}"]`);
+ if (btn) {
+ btn.classList.toggle('is-on', on);
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ const star = btn.querySelector('.deck-star-icon');
+ if (star) star.classList.toggle('is-on', on);
+ }
+ }
+
function renderList() {
const list = document.getElementById('preset-list');
- const countEl = document.getElementById('deck-count');
const filtered = getFilteredDecks();
- countEl.textContent = formatDeckCount(filtered.length, allDecks.length);
+ document.getElementById('deck-count').textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
- if (!filtered.length) {
- const favOnly =
- document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
- const emptyMsg = favOnly
- ? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
- : allDecks.length
- ? t('deckNoMatch', '조건에 맞는 덱이 없습니다.')
- : t('publicDeckEmpty', '공개된 덱이 없습니다.');
- list.innerHTML = `${escapeHtml(emptyMsg)}`;
- return;
+ if (!deckPager) {
+ deckPager = UaDeckPager.create({
+ container: list,
+ renderItem: renderDeckCard,
+ emptyHtml: () =>
+ `${escapeHtml(emptyMessage())}`,
+ });
}
-
- list.innerHTML = `${filtered.map(renderDeckCard).join('')}`;
- UaDeckPreviewHold.bindHoldPreview(list);
+ deckPager.refresh(filtered);
}
async function loadPublicDecks() {
@@ -266,8 +316,16 @@
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
try {
- await UaDeckFavorites.toggle(favBtn.dataset.favorite);
- renderList();
+ const code = favBtn.dataset.favorite;
+ const nowFav = await UaDeckFavorites.toggle(code);
+ const favOnly =
+ document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
+ if (favOnly) {
+ renderList();
+ } else {
+ applyFavoriteState(code, nowFav);
+ updateFavCount();
+ }
} catch (err) {
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
}
diff --git a/result.html b/result.html
index 27812a9..2c59ec2 100644
--- a/result.html
+++ b/result.html
@@ -841,6 +841,7 @@
/** 저장·다운로드 캡처와 동일한 미리보기 기준 너비(px) */
const DECK_PREVIEW_SAVE_WIDTH = 1200;
+ const DECK_THUMBNAIL_WIDTH = 420;
/**
* 저장용 미리보기를 기준 너비로 정규화
@@ -880,6 +881,50 @@
});
}
+ /**
+ * 목록용 축소 WebP 썸네일 생성 (WebP 인코딩 실패 시 null)
+ * @param {string} dataUrl 원본 미리보기 data URL
+ * @param {number} [targetWidth]
+ * @returns {Promise}
+ */
+ async function createThumbnailWebp(
+ dataUrl,
+ targetWidth = DECK_THUMBNAIL_WIDTH,
+ ) {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => {
+ if (!img.width) {
+ resolve(null);
+ return;
+ }
+ const w = Math.min(targetWidth, img.width);
+ const h = Math.max(1, Math.round(img.height * (w / img.width)));
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ resolve(null);
+ return;
+ }
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, w, h);
+ ctx.drawImage(img, 0, 0, w, h);
+ let out = null;
+ try {
+ out = canvas.toDataURL('image/webp', 0.82);
+ } catch {
+ out = null;
+ }
+ // WebP 미지원 브라우저는 PNG로 폴백되므로 이 경우 전송하지 않는다.
+ resolve(out && out.startsWith('data:image/webp') ? out : null);
+ };
+ img.onerror = () => resolve(null);
+ img.src = dataUrl;
+ });
+ }
+
/**
* 저장·공유 로딩 UI
* @param {string} message
@@ -1131,10 +1176,12 @@
loader.setProgress(8, t('savingPreview', '미리보기 생성 중...'));
loader.startAuto(52);
let previewImage = null;
+ let thumbnailImage = null;
try {
previewImage = await capturePreviewPng({ highQuality: false });
if (previewImage) {
previewImage = await normalizePreviewForSave(previewImage);
+ thumbnailImage = await createThumbnailWebp(previewImage);
}
} catch (captureErr) {
console.warn('Preview capture failed:', captureErr);
@@ -1148,6 +1195,7 @@
name: getDeckSaveName(),
cards,
previewImage,
+ thumbnailImage,
isPublic,
});
loader.stopAuto();
diff --git a/script/deck-list-pager.js b/script/deck-list-pager.js
new file mode 100644
index 0000000..58157dd
--- /dev/null
+++ b/script/deck-list-pager.js
@@ -0,0 +1,127 @@
+/**
+ * 덱 목록 무한 스크롤 페이저
+ * 필터링된 배열을 화면에 청크 단위로 나눠 렌더링하고,
+ * 하단 센티넬이 뷰포트에 근접하면 다음 청크를 이어 붙인다.
+ */
+(function initDeckListPager() {
+ const DEFAULT_PAGE_SIZE = 24;
+
+ /**
+ * @typedef {object} DeckPagerOptions
+ * @property {HTMLElement} container 목록을 그릴 컨테이너
+ * @property {(item: object) => string} renderItem 카드 HTML 생성 함수
+ * @property {() => string} [emptyHtml] 항목이 없을 때 표시할 HTML
+ * @property {(grid: HTMLElement) => void} [onAppend] 청크 추가 후 콜백
+ * @property {string} [gridClass] 그리드 컨테이너 클래스
+ * @property {number} [pageSize] 청크당 항목 수
+ */
+
+ /**
+ * 무한 스크롤 페이저 생성
+ * @param {DeckPagerOptions} options
+ * @returns {{ refresh: (items: object[]) => void }}
+ */
+ function create(options) {
+ const {
+ container,
+ renderItem,
+ emptyHtml,
+ onAppend,
+ gridClass = 'deck-grid',
+ pageSize = DEFAULT_PAGE_SIZE,
+ } = options;
+
+ /** @type {object[]} */
+ let items = [];
+ let rendered = 0;
+ /** @type {HTMLElement|null} */
+ let grid = null;
+ /** @type {HTMLElement|null} */
+ let sentinel = null;
+ /** @type {IntersectionObserver|null} */
+ let observer = null;
+
+ function teardownObserver() {
+ if (observer) {
+ observer.disconnect();
+ observer = null;
+ }
+ }
+
+ function removeSentinel() {
+ teardownObserver();
+ if (sentinel) {
+ sentinel.remove();
+ sentinel = null;
+ }
+ }
+
+ function appendNext() {
+ if (!grid) return;
+ const next = items.slice(rendered, rendered + pageSize);
+ if (next.length) {
+ grid.insertAdjacentHTML('beforeend', next.map(renderItem).join(''));
+ rendered += next.length;
+ if (typeof onAppend === 'function') onAppend(grid);
+ }
+ if (rendered >= items.length) {
+ removeSentinel();
+ }
+ }
+
+ /**
+ * 목록을 처음부터 다시 렌더링
+ * @param {object[]} nextItems
+ */
+ function refresh(nextItems) {
+ items = Array.isArray(nextItems) ? nextItems : [];
+ rendered = 0;
+ removeSentinel();
+ container.innerHTML = '';
+
+ if (!items.length) {
+ container.innerHTML = typeof emptyHtml === 'function' ? emptyHtml() : '';
+ grid = null;
+ return;
+ }
+
+ grid = document.createElement('div');
+ grid.className = gridClass;
+ container.appendChild(grid);
+
+ sentinel = document.createElement('div');
+ sentinel.setAttribute('aria-hidden', 'true');
+ sentinel.style.cssText = 'width:100%;height:1px;';
+ container.appendChild(sentinel);
+
+ appendNext();
+
+ if (rendered >= items.length) {
+ removeSentinel();
+ return;
+ }
+
+ if ('IntersectionObserver' in window) {
+ observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ appendNext();
+ break;
+ }
+ }
+ },
+ { rootMargin: '600px 0px' },
+ );
+ observer.observe(sentinel);
+ } else {
+ // IntersectionObserver 미지원 환경은 전체 렌더로 폴백
+ while (rendered < items.length) appendNext();
+ }
+ }
+
+ return { refresh };
+ }
+
+ window.UaDeckPager = { create };
+})();