덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가) - 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지 - 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용 - 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -353,13 +353,13 @@
|
||||
}
|
||||
|
||||
function renderDeckPreview(d) {
|
||||
if (!d.preview_url) {
|
||||
if (!d.preview_url && !d.thumbnail_url) {
|
||||
return '<div class="deck-card-preview"><div class="flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400">미리보기 없음</div></div>';
|
||||
}
|
||||
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
|
||||
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
|
||||
const previewSrc = escapeHtml(src + version);
|
||||
return `<div class="deck-card-preview deck-card-preview--holdable" data-hold-preview data-preview-src="${previewSrc}"><img src="${previewSrc}" alt="" loading="lazy" class="admin-deck-preview-img h-full w-full bg-white object-contain" draggable="false" onerror="this.parentElement.classList.remove('deck-card-preview--holdable'); this.parentElement.removeAttribute('data-hold-preview'); this.parentElement.removeAttribute('data-preview-src'); this.parentElement.innerHTML='<div class=\\'flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400\\'>미리보기 없음</div>'" /></div>`;
|
||||
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 `<div class="deck-card-preview deck-card-preview--holdable" data-hold-preview data-preview-src="${fullSrc}"><img src="${thumbSrc}" alt="" loading="lazy" class="admin-deck-preview-img h-full w-full bg-white object-contain" draggable="false" onerror="this.parentElement.classList.remove('deck-card-preview--holdable'); this.parentElement.removeAttribute('data-hold-preview'); this.parentElement.removeAttribute('data-preview-src'); this.parentElement.innerHTML='<div class=\\'flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400\\'>미리보기 없음</div>'" /></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.18",
|
||||
"version": "4.0.19",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
return true;
|
||||
deleted = true;
|
||||
} catch (err) {
|
||||
console.warn(`[preview] 삭제 실패: ${filePath}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB에서 참조하지 않는 미리보기 파일 목록
|
||||
* DB에서 참조하지 않는 미리보기 파일 목록 (원본 PNG + 썸네일 WebP)
|
||||
* @param {Iterable<string>} 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 {
|
||||
|
||||
136
deckHistory.html
136
deckHistory.html
@@ -81,6 +81,7 @@
|
||||
<script src="/script/deck-favorites.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-favorites-shim.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-preview-hold.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-list-pager.js?v=4.0.19"></script>
|
||||
<script src="/script/page-i18n.js?v=4.0.8"></script>
|
||||
<script src="/script/page-help.js?v=4.0.8"></script>
|
||||
<script src="/script/auth-header.js?v=4.0.8"></script>
|
||||
@@ -230,13 +231,37 @@
|
||||
return `<button type="button" class="deck-card-btn ${cls}" data-favorite="${escapeHtml(shareCode)}" aria-pressed="${on ? 'true' : 'false'}" aria-label="${escapeHtml(t('deckFavorite', '즐겨찾기'))}"><span class="${starCls}" aria-hidden="true"></span>${escapeHtml(t('deckFavorite', '즐겨찾기'))}</button>`;
|
||||
}
|
||||
|
||||
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 `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}<br><span>${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}</span></div></div>`;
|
||||
}
|
||||
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
|
||||
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(previewSrc)}" alt="${escapeHtml(title)} 미리보기" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
|
||||
const fullSrc = getFullSrc(d) || thumbSrc;
|
||||
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`;
|
||||
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(thumbSrc)}" alt="${escapeHtml(title)} 미리보기" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,34 +337,35 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {{ refresh: (items: object[]) => void }|null} */
|
||||
let deckPager = null;
|
||||
|
||||
/**
|
||||
* 목록 렌더링
|
||||
* 현재 필터 상태의 빈 목록 메시지
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderDeckList() {
|
||||
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) {
|
||||
function emptyMessage() {
|
||||
const favOnly =
|
||||
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
|
||||
const emptyMsg = favOnly
|
||||
return favOnly
|
||||
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
|
||||
: t('deckNoMatch', '조건에 맞는 덱이 없습니다.');
|
||||
list.innerHTML =
|
||||
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMsg)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
|
||||
closeAllDeckMenus();
|
||||
UaDeckPreviewHold.bindHoldPreview(list);
|
||||
|
||||
list.querySelectorAll('.deck-card-preview-img').forEach((img) => {
|
||||
img.addEventListener('error', () => {
|
||||
/**
|
||||
* 이미지 로드 실패 시 미리보기 없음 표시로 교체 (append 되는 카드까지 위임 처리)
|
||||
*/
|
||||
function bindPreviewErrorHandler() {
|
||||
const list = document.getElementById('deck-list');
|
||||
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: () =>
|
||||
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMessage())}</div></div>`,
|
||||
});
|
||||
});
|
||||
}
|
||||
deckPager.refresh(filtered);
|
||||
closeAllDeckMenus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,8 +492,16 @@
|
||||
const favBtn = e.target.closest('[data-favorite]');
|
||||
if (favBtn) {
|
||||
try {
|
||||
await UaDeckFavorites.toggle(favBtn.dataset.favorite);
|
||||
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', '즐겨찾기 변경에 실패했습니다.'));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.19
|
||||
|
||||
### 덱 목록 무한 스크롤
|
||||
|
||||
`내 덱 관리`와 `공개 덱` 목록이 무한 스크롤로 바뀌었습니다. 화면을 아래로 내리면 덱이 자동으로 이어서 나타나, 덱이 많아도 목록이 훨씬 가볍게 열립니다. 특정 덱은 검색으로 빠르게 찾을 수 있습니다.
|
||||
|
||||
### 목록 이미지 로딩 최적화
|
||||
|
||||
목록에서는 원본 대신 가벼운 썸네일 이미지를 사용하도록 개선했습니다. 이미지 용량이 크게 줄어 목록이 더 빠르게 표시되며, 썸네일을 길게 누르면 기존처럼 원본 크기로 크게 볼 수 있습니다.
|
||||
|
||||
> 썸네일은 덱을 새로 저장할 때 생성됩니다. 이전에 저장한 덱은 다시 저장하면 최적화된 썸네일이 적용됩니다.
|
||||
|
||||
## v4.0.18
|
||||
|
||||
### 관리자 덱 관리 편의 복구
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-02 v4.0.19 — 덱 목록은 무한 스크롤 + WebP 썸네일 이원화
|
||||
|
||||
💡 페이지네이션 대신 무한 스크롤로 초기 DOM 부담을 줄이되, 원본 1200px PNG를 목록마다 내려받는 대역폭 낭비를 없애기 위함.
|
||||
🔧 목록은 420px WebP 썸네일을 별도 저장(`<code>.thumb.webp`)해 우선 사용하고 확대 시에만 원본 PNG 사용, 썸네일 인코딩은 브라우저 canvas로 처리하고 미지원 시 원본으로 폴백.
|
||||
|
||||
## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원
|
||||
|
||||
💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문.
|
||||
|
||||
@@ -89,6 +89,9 @@ api/src/routes/decks.js
|
||||
덱 썸네일 확대
|
||||
script/deck-preview-hold.js
|
||||
|
||||
덱 목록 무한 스크롤 페이저
|
||||
script/deck-list-pager.js
|
||||
|
||||
이미지 PNG 변환
|
||||
script/image-utils.js
|
||||
|
||||
|
||||
@@ -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(`<shareCode>.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` | 회원 | 내 덱 저장과 공개 링크 생성 |
|
||||
|
||||
@@ -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가 썸네일을 `<shareCode>.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 — 누락 이미지 폴백·공유 덱 매핑 보정
|
||||
|
||||
### 수행 작업
|
||||
|
||||
102
preset.html
102
preset.html
@@ -74,6 +74,7 @@
|
||||
<script src="/script/deck-favorites.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-favorites-shim.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-preview-hold.js?v=4.0.8"></script>
|
||||
<script src="/script/deck-list-pager.js?v=4.0.19"></script>
|
||||
<script src="/script/page-i18n.js?v=4.0.8"></script>
|
||||
<script src="/script/page-help.js?v=4.0.8"></script>
|
||||
<script src="/script/auth-header.js?v=4.0.8"></script>
|
||||
@@ -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 `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}</div></div>`;
|
||||
}
|
||||
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
|
||||
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(previewSrc)}" alt="${title}" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
|
||||
const fullSrc = getFullSrc(d) || thumbSrc;
|
||||
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`;
|
||||
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(thumbSrc)}" alt="${title}" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
|
||||
}
|
||||
|
||||
function renderDeckCard(d) {
|
||||
@@ -199,27 +221,55 @@
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = document.getElementById('preset-list');
|
||||
const countEl = document.getElementById('deck-count');
|
||||
const filtered = getFilteredDecks();
|
||||
countEl.textContent = formatDeckCount(filtered.length, allDecks.length);
|
||||
updateFavCount();
|
||||
/** @type {{ refresh: (items: object[]) => void }|null} */
|
||||
let deckPager = null;
|
||||
|
||||
if (!filtered.length) {
|
||||
/**
|
||||
* 현재 필터 상태에서 표시할 빈 목록 메시지
|
||||
* @returns {string}
|
||||
*/
|
||||
function emptyMessage() {
|
||||
const favOnly =
|
||||
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
|
||||
const emptyMsg = favOnly
|
||||
return favOnly
|
||||
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
|
||||
: allDecks.length
|
||||
? t('deckNoMatch', '조건에 맞는 덱이 없습니다.')
|
||||
: t('publicDeckEmpty', '공개된 덱이 없습니다.');
|
||||
list.innerHTML = `<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMsg)}</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
|
||||
UaDeckPreviewHold.bindHoldPreview(list);
|
||||
/**
|
||||
* 즐겨찾기 상태를 카드 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 filtered = getFilteredDecks();
|
||||
document.getElementById('deck-count').textContent = formatDeckCount(filtered.length, allDecks.length);
|
||||
updateFavCount();
|
||||
|
||||
if (!deckPager) {
|
||||
deckPager = UaDeckPager.create({
|
||||
container: list,
|
||||
renderItem: renderDeckCard,
|
||||
emptyHtml: () =>
|
||||
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMessage())}</div></div>`,
|
||||
});
|
||||
}
|
||||
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);
|
||||
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', '즐겨찾기 변경에 실패했습니다.'));
|
||||
}
|
||||
|
||||
48
result.html
48
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<string|null>}
|
||||
*/
|
||||
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();
|
||||
|
||||
127
script/deck-list-pager.js
Normal file
127
script/deck-list-pager.js
Normal file
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user