덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가

- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가)
- 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지
- 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용
- 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 18:17:58 +09:00
parent 63aabf61c4
commit a90bdcbe13
15 changed files with 516 additions and 85 deletions

View File

@@ -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),
})),
});
});

View File

@@ -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

View File

@@ -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<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 {