- 썸네일 슬라이드 기능 추가
- 미판매 상품 가격 표시 안함

추후 해야할일: 이미지 webp로 변환
This commit is contained in:
2026-02-11 18:48:15 +09:00
parent 293228b6d9
commit fcb5e8959c
4 changed files with 196 additions and 93 deletions

View File

@@ -5,7 +5,7 @@ const games = [
updatedAt: '2026-02-11', updatedAt: '2026-02-11',
title: '-KATANA Project CompleteBox- 煌花絢爛', title: '-KATANA Project CompleteBox- 煌花絢爛',
price: 0, price: 250000,
currency: '₩', currency: '₩',
category: 'PC Games', category: 'PC Games',
status: '미판매', status: '미판매',
@@ -88,7 +88,7 @@ const games = [
price: 60000, price: 60000,
currency: '₩', currency: '₩',
category: 'Games', category: 'Games',
status: '판매완료', status: '판매',
customTag: '', customTag: '',
tags: ['Switch', 'JP'], tags: ['Switch', 'JP'],

View File

@@ -11,7 +11,7 @@ export const STATUS_META = {
미판매: { 미판매: {
selectable: false, // 체크박스 선택 불가 selectable: false, // 체크박스 선택 불가
isDefaultActive: false, // 초기 로드 시 미체크 상태 isDefaultActive: false, // 초기 로드 시 미체크 상태
isSystemVisible: false, // 아예 리스트/필터에서 제외 (완전 숨김) isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김)
soldOut: false, soldOut: false,
}, },
판매예정: { 판매예정: {
@@ -30,7 +30,7 @@ export const STATUS_META = {
selectable: false, selectable: false,
isDefaultActive: false, // 초기에는 안 보이지만, 사용자가 필터 클릭하면 보임 isDefaultActive: false, // 초기에는 안 보이지만, 사용자가 필터 클릭하면 보임
isSystemVisible: true, isSystemVisible: true,
soldOut: true, // 이미지에 SOLD OUT 표시 soldOut: true,
}, },
}; };

View File

@@ -256,3 +256,59 @@ window.exportToExcel = () => {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}; };
let currentHoverIndex = -1;
window.handleThumbnailHover = (event, productId) => {
const product = productsData.find((p) => p.id === productId);
if (!product || product.images.length <= 1) return;
const container = event.currentTarget;
const thumbImg = document.getElementById(`thumb-${productId}`);
const indicator = document.getElementById(`indicator-${productId}`);
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const index = Math.min(Math.floor((x / rect.width) * product.images.length), product.images.length - 1);
// [핵심] 인덱스가 실제로 바뀔 때만 딱 한 번 실행
if (currentHoverIndex !== index) {
currentHoverIndex = index;
const targetImgUrl = product.images[index];
requestAnimationFrame(() => {
// 이미 프리로드된 상태라면 즉시 교체됨
thumbImg.style.backgroundImage = `url("${targetImgUrl}")`;
if (indicator) {
Array.from(indicator.children).forEach((dot, i) => {
if (i === index) {
dot.style.backgroundColor = 'white';
dot.style.transform = 'scale(1.2)';
dot.style.opacity = '1';
} else {
dot.style.backgroundColor = 'rgba(255, 255, 255, 0.4)';
dot.style.transform = 'scale(1)';
dot.style.opacity = '0.7';
}
});
}
});
}
};
window.handleThumbnailLeave = (productId) => {
currentHoverIndex = -1; // 인덱스 초기화
const product = productsData.find((p) => p.id === productId);
if (!product) return;
const thumbImg = document.getElementById(`thumb-${productId}`);
const indicator = document.getElementById(`indicator-${productId}`);
thumbImg.style.backgroundImage = `url("${product.images[0]}")`;
if (indicator) {
Array.from(indicator.children).forEach((dot, i) => {
dot.style.backgroundColor = i === 0 ? 'white' : 'rgba(255,255,255,0.3)';
});
}
};

View File

@@ -24,137 +24,184 @@ export function renderProducts(page = 1) {
if (!grid || !tableWrapper) return; if (!grid || !tableWrapper) return;
// 1. 결과가 0개인 경우 (그리드/테이블 공통 안내) // 1. 결과가 0개인 경우 안내
if (state.visibleProducts.length === 0) { if (state.visibleProducts.length === 0) {
grid.classList.remove('grid', 'hidden'); grid.classList.remove('grid');
grid.innerHTML = ` grid.classList.add('hidden');
<div class="flex flex-col items-center justify-center py-20 w-full text-center"> tableWrapper.classList.add('hidden');
// 검색 결과 없음 메시지를 표시할 별도의 컨테이너가 없다면 grid 영역을 빌려 씁니다.
const emptyMsg = `
<div class="col-span-full flex flex-col items-center justify-center py-20 w-full text-center">
<span class="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-4">search_off</span> <span class="material-symbols-outlined text-6xl text-slate-300 dark:text-slate-700 mb-4">search_off</span>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3> <h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm">입력하신 검색어나 선택한 필터를 확인해 주세요.</p> <p class="text-slate-500 dark:text-slate-400 text-sm">입력하신 검색어나 선택한 필터를 확인해 주세요.</p>
</div> </div>
`; `;
tableWrapper.classList.add('hidden'); grid.innerHTML = emptyMsg;
grid.classList.remove('hidden');
if (paginationContainer) paginationContainer.innerHTML = ''; if (paginationContainer) paginationContainer.innerHTML = '';
return; return;
} }
// 2. 뷰 모드에 따른 컨테이너 노출 // 2. 뷰 모드 설정 및 컨테이너 노출 정리 (hidden/flex/grid 충돌 방지)
if (state.viewMode === 'grid') { if (state.viewMode === 'grid') {
// 그리드 활성화
grid.classList.remove('hidden'); grid.classList.remove('hidden');
grid.classList.add('grid'); grid.classList.add('grid');
// 테이블 및 요약바 비활성화
tableWrapper.classList.add('hidden'); tableWrapper.classList.add('hidden');
if (summaryBar) { if (summaryBar) {
summaryBar.classList.remove('flex'); // flex 제거 summaryBar.classList.remove('flex');
summaryBar.classList.add('hidden'); summaryBar.classList.add('hidden');
} }
} else { } else {
// 그리드 비활성화
grid.classList.remove('grid'); grid.classList.remove('grid');
grid.classList.add('hidden'); grid.classList.add('hidden');
// 테이블 활성화
tableWrapper.classList.remove('hidden'); tableWrapper.classList.remove('hidden');
updateSummary(); // 테이블일 때만 요약바 노출 여부 결정
// 요약바 노출 여부는 데이터 상태에 따라 updateSummary에서 결정
updateSummary();
} }
// 3. 현재 페이지 데이터 슬라이싱 // 3. 현재 페이지 데이터 계산
const startIndex = (page - 1) * ITEMS_PER_PAGE; const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE); const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
if (state.viewMode === 'grid') { if (state.viewMode === 'grid') {
grid.innerHTML = ''; grid.innerHTML = '';
pagedProducts.forEach((product) => { pagedProducts.forEach((product) => {
const isSold = STATUS_META[product.status]?.soldOut === true; // 1. 상태 판별
grid.insertAdjacentHTML('beforeend', ` const isSold = STATUS_META[product.status]?.soldOut === true;
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')"> const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
<div class="relative w-full aspect-card bg-slate-50 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm transition-shadow">
<div class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-80' : 'group-hover:scale-105'} transition-transform duration-500" // 2. 스펙(Condition) 정보 추출
style="background-image: url('${product.images[0]}')"></div> const conditionKey = product.specs?.condition;
<div class="absolute top-3 left-3"> const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${STATUS_COLOR[product.status]} backdrop-blur-md border"> const conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '');
${product.status}
</span> grid.insertAdjacentHTML('beforeend', `
</div> <div class="product-card group flex flex-col gap-4 cursor-pointer"
</div> data-id="${product.id}"
<div class="flex flex-col gap-1"> onclick="window.openModal('${product.id}')"
<div class="flex flex-col sm:flex-row justify-between items-start gap-1"> ${!isSold ? `onmousemove="window.handleThumbnailHover(event, '${product.id}')"
<h3 class="text-slate-900 dark:text-white text-base font-semibold leading-tight break-keep ${isSold ? 'line-through text-slate-400' : ''}">${product.title}</h3> onmouseleave="window.handleThumbnailLeave('${product.id}')"` : ''}>
<p class="text-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
</div> <div class="relative w-full aspect-card bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1">${product.description}</p> <div id="thumb-${product.id}"
</div> class="w-full h-full bg-center bg-no-repeat bg-cover transform ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105 transition-transform duration-500'}"
style="background-image: none; will-change: background-image;">
</div> </div>
`);
});
} else {
// [테이블 렌더링: 스타일 & 체크박스 제어 추가]
tableBody.innerHTML = pagedProducts
.map((product) => {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
// 1. 상태(Condition) 설정 로직
const conditionKey = product.specs.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
let conditionDisplay = '';
let conditionClass = 'text-slate-500';
if (conditionConfig) { <div class="absolute top-3 left-3">
conditionDisplay = conditionConfig.label; <span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${STATUS_COLOR[product.status]} backdrop-blur-md border">
conditionClass = conditionConfig.color; ${product.status}
} else if (conditionKey && conditionKey.trim() !== '') { </span>
conditionDisplay = conditionKey; </div>
} else {
conditionDisplay = '상세 설명 참고 '; ${!isSold && product.images?.length > 1 ? `
conditionClass = 'text-indigo-500 italic'; <div id="indicator-${product.id}" class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5 opacity-0 group-hover:opacity-100 transition-all duration-300 pointer-events-none">
${product.images.map((_, i) => `
<div class="w-1.5 h-1.5 rounded-full transition-all duration-300 shadow-sm ${i === 0 ? 'bg-white scale-125' : 'bg-white/40'}"></div>
`).join('')}
</div>
` : ''}
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-baseline gap-1">
<h3 class="text-slate-900 dark:text-white text-base font-semibold leading-tight break-keep ${isSold ? 'line-through text-slate-400' : ''}">
${product.title}
</h3>
<p class="text-base font-bold whitespace-nowrap ${isNonSale ? 'text-slate-400 font-medium text-xs uppercase' : 'text-slate-900 dark:text-white'}">
${isNonSale ? 'Not for Sale' : `${product.price.toLocaleString()}`}
</p>
</div>
<div class="flex flex-col">
${conditionDisplay ? `<span class="text-[11px] font-medium text-slate-400 mb-0.5">${conditionDisplay}</span>` : ''}
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1 italic">
${product.description}
</p>
</div>
</div>
</div>
`);
});
// 지연 로딩(Lazy Loading) 관찰자 설정
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const card = entry.target;
const productId = card.getAttribute('data-id');
const product = state.visibleProducts.find(p => p.id === productId);
const thumb = document.getElementById(`thumb-${productId}`);
if (product && thumb) {
// 1. 첫 번째 이미지 로드
thumb.style.backgroundImage = `url("${product.images[0]}")`;
// 2. 마우스 올리기 전, 나머지 이미지들 백그라운드 프리로드
if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) {
product.images.slice(1).forEach(url => {
const img = new Image();
img.src = url;
});
}
}
observer.unobserve(card);
} }
});
}, { threshold: 0.1 });
// 2. 행 전체 스타일 및 제목 스타일 document.querySelectorAll('.product-card').forEach(card => observer.observe(card));
const rowClass = isSold ? 'opacity-50 grayscale-[0.5]' : '';
const titleClass = isSold ? 'line-through text-slate-400' : 'text-slate-900 dark:text-white';
return ` } else {
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${isSold ? 'opacity-50 grayscale cursor-not-allowed' : 'cursor-pointer'}" // 테이블 렌더링
onclick="if(event.target.type !== 'checkbox') { tableBody.innerHTML = pagedProducts.map((product) => {
${isSold ? "alert('판매 완료된 상품은 상세 정보를 볼 수 없습니다.');" : `openModal('${product.id}')`} const meta = STATUS_META[product.status];
}"> const isSold = meta?.soldOut === true;
<td class="py-4 px-4 text-center" onclick="event.stopPropagation()"> const isSelectable = meta?.selectable !== false;
<input type="checkbox"
class="product-check rounded border-slate-300 w-4 h-4 ${isSelectable ? 'cursor-pointer text-primary' : 'opacity-20 cursor-not-allowed'}" const conditionKey = product.specs.condition;
${state.selectedIds.has(product.id) ? 'checked' : ''} const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
${isSelectable ? '' : 'disabled'} let conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '상세 설명 참고 ');
onchange="window.toggleSelectItem('${product.id}')"> let conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500';
</td>
<td class="py-4 px-4 font-semibold ${isSold ? 'line-through text-slate-400' : 'text-slate-900 dark:text-white'}">${product.title}</td> return `
<td class="py-4 px-4 text-xs break-keep ${conditionClass}">${conditionDisplay}</td> <tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${isSold ? 'opacity-50 grayscale cursor-not-allowed' : 'cursor-pointer'}"
<td class="py-4 px-4 text-right font-bold text-slate-900 dark:text-white"> onclick="if(event.target.type !== 'checkbox') {
${product.price.toLocaleString()} ${isSold ? "alert('판매 완료된 상품입니다.');" : `window.openModal('${product.id}')`}
</td> }">
<td class="hidden lg:table-cell py-4 px-4 text-center"> <td class="py-4 px-4 text-center" onclick="event.stopPropagation()">
<span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span> <input type="checkbox"
</td> class="product-check rounded border-slate-300 w-4 h-4 ${isSelectable ? 'cursor-pointer text-primary' : 'opacity-20 cursor-not-allowed'}"
</tr>`; ${state.selectedIds.has(product.id) ? 'checked' : ''}
}) ${isSelectable ? '' : 'disabled'}
.join(''); onchange="window.toggleSelectItem('${product.id}')">
</td>
<td class="py-4 px-4 font-semibold ${isSold ? 'line-through text-slate-400' : 'text-slate-900 dark:text-white'}">${product.title}</td>
<td class="py-4 px-4 text-xs break-keep ${conditionClass}">${conditionDisplay}</td>
<td class="py-4 px-4 text-right font-bold text-slate-900 dark:text-white">
${product.price.toLocaleString()}
</td>
<td class="hidden lg:table-cell py-4 px-4 text-center">
<span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span>
</td>
</tr>`;
}).join('');
} }
// [중요] 전체 선택 체크박스 상태 동기화 // 전체 선택 체크박스 상태 동기화
const selectAllCheck = document.getElementById('select-all-current'); const selectAllCheck = document.getElementById('select-all-current');
if (selectAllCheck) { if (selectAllCheck) {
const startIndex = (page - 1) * ITEMS_PER_PAGE; const startIndex = (page - 1) * ITEMS_PER_PAGE;
const currentSelectableItems = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE).filter((p) => STATUS_META[p.status]?.selectable !== false); const currentSelectableItems = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE)
.filter((p) => STATUS_META[p.status]?.selectable !== false);
selectAllCheck.checked = currentSelectableItems.length > 0 && currentSelectableItems.every((p) => state.selectedIds.has(p.id)); selectAllCheck.checked = currentSelectableItems.length > 0 && currentSelectableItems.every((p) => state.selectedIds.has(p.id));
} }
renderPagination(); // 페이지네이션 함수 호출 (이 함수는 외부에 정의되어 있어야 함)
if (typeof renderPagination === 'function') {
renderPagination();
}
} }
export function renderPagination() { export function renderPagination() {