덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가) - 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지 - 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용 - 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user