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 '
미리보기 없음
'; } - const src = UaDeckApi.resolveMediaUrl(d.preview_url); const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : ''; - const previewSrc = escapeHtml(src + version); - return `
`; + const thumbSrc = escapeHtml(UaDeckApi.resolveMediaUrl(d.thumbnail_url || d.preview_url) + version); + const fullSrc = escapeHtml(UaDeckApi.resolveMediaUrl(d.preview_url || d.thumbnail_url) + version); + return `
`; } /** diff --git a/api/package.json b/api/package.json index 09c19d4..03c6f31 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "uadeck-api", - "version": "4.0.18", + "version": "4.0.19", "type": "module", "main": "src/index.js", "scripts": { diff --git a/api/src/routes/admin.js b/api/src/routes/admin.js index 85ac607..5d83402 100644 --- a/api/src/routes/admin.js +++ b/api/src/routes/admin.js @@ -5,6 +5,7 @@ import { contentHash, validateCards, nowIso } from '../utils.js'; import { resolveDeckCards } from '../services/cardResolver.js'; import { getPreviewUrl, + getThumbnailUrl, deletePreviewImage, listOrphanPreviewImages, deleteOrphanPreviewImages, @@ -368,6 +369,7 @@ export function createAdminRouter(db) { decks: rows.map((row) => ({ ...row, preview_url: getPreviewUrl(row.shareCode, row.previewImage), + thumbnail_url: getThumbnailUrl(row.shareCode), })), }); }); diff --git a/api/src/routes/decks.js b/api/src/routes/decks.js index 15c8f50..9ba24f0 100644 --- a/api/src/routes/decks.js +++ b/api/src/routes/decks.js @@ -5,8 +5,11 @@ import { resolveDeckCards } from '../services/cardResolver.js'; import { generateShareCode, validateCards, nowIso } from '../utils.js'; import { savePreviewImage, + saveThumbnailImage, getPreviewFilePath, + getThumbnailFilePath, getPreviewUrl, + getThumbnailUrl, deletePreviewImage, } from '../services/previewImage.js'; @@ -35,6 +38,7 @@ function toDeckResponse(row, author, resolvedCards, totalCards) { isPublic: Boolean(row.is_public), expiresAt: row.expires_at, previewUrl: getPreviewUrl(row.share_code, row.preview_image), + thumbnailUrl: getThumbnailUrl(row.share_code), }; } @@ -164,6 +168,7 @@ export function createDecksRouter(db) { created_at: row.created_at, updated_at: row.updated_at, preview_url: getPreviewUrl(row.share_code, row.preview_image), + thumbnail_url: getThumbnailUrl(row.share_code), })), }); } catch (err) { @@ -185,6 +190,20 @@ export function createDecksRouter(db) { }); }); + router.get('/:shareCode/thumbnail', (req, res) => { + const filePath = getThumbnailFilePath(req.params.shareCode); + if (!filePath) { + return res.status(404).json({ error: '썸네일 이미지가 없습니다.' }); + } + res.setHeader('Content-Type', 'image/webp'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.resolve(filePath), (err) => { + if (err && !res.headersSent) { + res.status(404).json({ error: '썸네일 이미지가 없습니다.' }); + } + }); + }); + router.get('/:shareCode', optionalAuth, async (req, res, next) => { try { const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode); @@ -237,6 +256,7 @@ export function createDecksRouter(db) { created_at: row.created_at, updated_at: row.updated_at, preview_url: getPreviewUrl(row.share_code, row.preview_image), + thumbnail_url: getThumbnailUrl(row.share_code), })), }); } catch (err) { @@ -246,7 +266,7 @@ export function createDecksRouter(db) { router.post('/', optionalAuth, async (req, res, next) => { try { - const { series, seriesName, name, cards, isPublic, previewImage } = req.body || {}; + const { series, seriesName, name, cards, isPublic, previewImage, thumbnailImage } = req.body || {}; if (!series || !cards) { return res.status(400).json({ error: 'series와 cards는 필수입니다.' }); } @@ -264,6 +284,9 @@ export function createDecksRouter(db) { console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`); } } + if (thumbnailImage) { + saveThumbnailImage(shareCode, thumbnailImage); + } db.prepare(` INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image) @@ -306,7 +329,7 @@ export function createDecksRouter(db) { return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' }); } - const { name, cards, isPublic, seriesName, previewImage } = req.body || {}; + const { name, cards, isPublic, seriesName, previewImage, thumbnailImage } = req.body || {}; let cardsJson = row.cards_json; let series = row.series; let previewFile = row.preview_image; @@ -321,6 +344,9 @@ export function createDecksRouter(db) { if (previewImage) { previewFile = savePreviewImage(row.share_code, previewImage); } + if (thumbnailImage) { + saveThumbnailImage(row.share_code, thumbnailImage); + } db.prepare(` UPDATE decks SET diff --git a/api/src/services/previewImage.js b/api/src/services/previewImage.js index 261f7f9..0db5e7d 100644 --- a/api/src/services/previewImage.js +++ b/api/src/services/previewImage.js @@ -4,6 +4,21 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** 목록용 축소 썸네일 파일 접미사 */ +const THUMB_SUFFIX = '.thumb.webp'; + +/** + * 미리보기 파일명에서 공유 코드 추출 (원본/썸네일 공통) + * @param {string} fileName + * @returns {string|null} + */ +function shareCodeOfPreviewFile(fileName) { + const lower = fileName.toLowerCase(); + if (lower.endsWith(THUMB_SUFFIX)) return fileName.slice(0, -THUMB_SUFFIX.length); + if (lower.endsWith('.png')) return fileName.slice(0, -'.png'.length); + return null; +} + /** * 미리보기 이미지 저장 디렉터리 * @returns {string} @@ -32,6 +47,22 @@ export function savePreviewImage(shareCode, dataUrl) { return fileName; } +/** + * 목록용 WebP 썸네일 저장 (data URL 형식만 허용) + * @param {string} shareCode + * @param {string} dataUrl + * @returns {string|null} 파일명 + */ +export function saveThumbnailImage(shareCode, dataUrl) { + if (!dataUrl || !dataUrl.startsWith('data:image/webp')) { + return null; + } + const base64 = dataUrl.replace(/^data:image\/webp;base64,/, ''); + const fileName = `${shareCode}${THUMB_SUFFIX}`; + fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64')); + return fileName; +} + /** * @param {string} shareCode * @returns {string|null} @@ -41,6 +72,15 @@ export function getPreviewFilePath(shareCode) { return fs.existsSync(filePath) ? filePath : null; } +/** + * @param {string} shareCode + * @returns {string|null} + */ +export function getThumbnailFilePath(shareCode) { + const filePath = path.join(getPreviewsDir(), `${shareCode}${THUMB_SUFFIX}`); + return fs.existsSync(filePath) ? filePath : null; +} + /** * @param {string} shareCode * @param {string|null} [previewImageRecord] DB preview_image 컬럼 @@ -55,33 +95,48 @@ export function getPreviewUrl(shareCode, previewImageRecord = null) { } /** - * 공유 코드에 연결된 미리보기 파일 삭제 + * 목록용 썸네일 URL (파일이 있을 때만 반환) + * @param {string} shareCode + * @returns {string|null} + */ +export function getThumbnailUrl(shareCode) { + return getThumbnailFilePath(shareCode) ? `/api/v1/decks/${shareCode}/thumbnail` : null; +} + +/** + * 공유 코드에 연결된 미리보기 파일(원본 PNG + 썸네일 WebP) 삭제 * @param {string} shareCode * @returns {boolean} */ export function deletePreviewImage(shareCode) { - const filePath = path.join(getPreviewsDir(), `${shareCode}.png`); - if (!fs.existsSync(filePath)) return false; - try { - fs.unlinkSync(filePath); - return true; - } catch (err) { - console.warn(`[preview] 삭제 실패: ${filePath}`, err); - return false; + let deleted = false; + for (const fileName of [`${shareCode}.png`, `${shareCode}${THUMB_SUFFIX}`]) { + const filePath = path.join(getPreviewsDir(), fileName); + if (!fs.existsSync(filePath)) continue; + try { + fs.unlinkSync(filePath); + deleted = true; + } catch (err) { + console.warn(`[preview] 삭제 실패: ${filePath}`, err); + } } + return deleted; } /** - * DB에서 참조하지 않는 미리보기 파일 목록 + * DB에서 참조하지 않는 미리보기 파일 목록 (원본 PNG + 썸네일 WebP) * @param {Iterable} referencedShareCodes * @returns {Array<{fileName: string, size: number, modifiedAt: string}>} */ export function listOrphanPreviewImages(referencedShareCodes) { - const referenced = new Set([...referencedShareCodes].map((code) => `${code}.png`)); + const referenced = new Set([...referencedShareCodes]); return fs .readdirSync(getPreviewsDir(), { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png')) - .filter((entry) => !referenced.has(entry.name)) + .filter((entry) => { + if (!entry.isFile()) return false; + const code = shareCodeOfPreviewFile(entry.name); + return Boolean(code) && !referenced.has(code); + }) .map((entry) => { const stat = fs.statSync(path.join(getPreviewsDir(), entry.name)); return { diff --git a/deckHistory.html b/deckHistory.html index 9bf94eb..5d0ea26 100644 --- a/deckHistory.html +++ b/deckHistory.html @@ -81,6 +81,7 @@ + @@ -230,13 +231,37 @@ return ``; } - 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)}` : ''; + /** + * 미디어 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 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); + } + /** * 미리보기 HTML * @param {object} d @@ -244,12 +269,13 @@ * @returns {string} */ function renderPreviewHtml(d, title) { - const previewSrc = getPreviewSrc(d); - if (!previewSrc) { + const thumbSrc = getThumbSrc(d); + if (!thumbSrc) { return `
${escapeHtml(t('deckNoPreview', '미리보기 없음'))}
${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}
`; } - const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`; - return `
${escapeHtml(title)} 미리보기
`; + const fullSrc = getFullSrc(d) || thumbSrc; + const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`; + return `
${escapeHtml(title)} 미리보기
`; } /** @@ -311,34 +337,35 @@ }); } + /** @type {{ refresh: (items: object[]) => void }|null} */ + let deckPager = null; + /** - * 목록 렌더링 + * 현재 필터 상태의 빈 목록 메시지 + * @returns {string} */ - function renderDeckList() { + function emptyMessage() { + const favOnly = + document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true'; + return favOnly + ? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.') + : t('deckNoMatch', '조건에 맞는 덱이 없습니다.'); + } + + /** + * 이미지 로드 실패 시 미리보기 없음 표시로 교체 (append 되는 카드까지 위임 처리) + */ + function bindPreviewErrorHandler() { const list = document.getElementById('deck-list'); - const countEl = document.getElementById('deck-count'); - const filtered = getFilteredDecks(); - - countEl.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', '즐겨찾기한 덱이 없습니다.') - : t('deckNoMatch', '조건에 맞는 덱이 없습니다.'); - list.innerHTML = - `
${escapeHtml(emptyMsg)}
`; - return; - } - - list.innerHTML = `
${filtered.map(renderDeckCard).join('')}
`; - closeAllDeckMenus(); - UaDeckPreviewHold.bindHoldPreview(list); - - list.querySelectorAll('.deck-card-preview-img').forEach((img) => { - img.addEventListener('error', () => { + if (!list || list.dataset.previewErrBound === '1') return; + list.dataset.previewErrBound = '1'; + list.addEventListener( + 'error', + (e) => { + const img = e.target; + if (!(img instanceof HTMLImageElement) || !img.classList.contains('deck-card-preview-img')) { + return; + } const wrap = img.closest('.deck-card-preview'); if (wrap) { wrap.classList.remove('deck-card-preview--holdable'); @@ -346,8 +373,49 @@ wrap.removeAttribute('data-preview-src'); wrap.innerHTML = noPreviewHtml(); } + }, + true, + ); + } + + /** + * 즐겨찾기 상태를 카드 DOM에 즉시 반영 (전체 리렌더 없이 스크롤 유지) + * @param {string} shareCode + * @param {boolean} on + */ + function applyFavoriteState(shareCode, on) { + const article = document.querySelector(`#deck-list .deck-card[data-code="${CSS.escape(shareCode)}"]`); + if (article) article.classList.toggle('is-favorite', on); + const btn = document.querySelector(`#deck-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 renderDeckList() { + const list = document.getElementById('deck-list'); + const filtered = getFilteredDecks(); + + document.getElementById('deck-count').textContent = formatDeckCount(filtered.length, allDecks.length); + updateFavCount(); + + if (!deckPager) { + bindPreviewErrorHandler(); + deckPager = UaDeckPager.create({ + container: list, + renderItem: renderDeckCard, + emptyHtml: () => + `
${escapeHtml(emptyMessage())}
`, }); - }); + } + deckPager.refresh(filtered); + closeAllDeckMenus(); } /** @@ -424,8 +492,16 @@ const favBtn = e.target.closest('[data-favorite]'); if (favBtn) { try { - await UaDeckFavorites.toggle(favBtn.dataset.favorite); - renderDeckList(); + const code = favBtn.dataset.favorite; + const nowFav = await UaDeckFavorites.toggle(code); + const favOnly = + document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true'; + if (favOnly) { + renderDeckList(); + } else { + applyFavoriteState(code, nowFav); + updateFavCount(); + } } catch (err) { alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.')); } diff --git a/docs/changelog.md b/docs/changelog.md index 2e4e228..45e065b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,17 @@ # 변경사항 +## v4.0.19 + +### 덱 목록 무한 스크롤 + +`내 덱 관리`와 `공개 덱` 목록이 무한 스크롤로 바뀌었습니다. 화면을 아래로 내리면 덱이 자동으로 이어서 나타나, 덱이 많아도 목록이 훨씬 가볍게 열립니다. 특정 덱은 검색으로 빠르게 찾을 수 있습니다. + +### 목록 이미지 로딩 최적화 + +목록에서는 원본 대신 가벼운 썸네일 이미지를 사용하도록 개선했습니다. 이미지 용량이 크게 줄어 목록이 더 빠르게 표시되며, 썸네일을 길게 누르면 기존처럼 원본 크기로 크게 볼 수 있습니다. + +> 썸네일은 덱을 새로 저장할 때 생성됩니다. 이전에 저장한 덱은 다시 저장하면 최적화된 썸네일이 적용됩니다. + ## v4.0.18 ### 관리자 덱 관리 편의 복구 diff --git a/docs/history.md b/docs/history.md index d961e47..134cf78 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-07-02 v4.0.19 — 덱 목록은 무한 스크롤 + WebP 썸네일 이원화 + +💡 페이지네이션 대신 무한 스크롤로 초기 DOM 부담을 줄이되, 원본 1200px PNG를 목록마다 내려받는 대역폭 낭비를 없애기 위함. +🔧 목록은 420px WebP 썸네일을 별도 저장(`.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 `
${title}
`; + const fullSrc = getFullSrc(d) || thumbSrc; + const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`; + return `
${title}
`; } 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 }; +})();