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

추후 해야할일: 이미지 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

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

View File

@@ -256,3 +256,59 @@ window.exportToExcel = () => {
link.click();
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;
// 1. 결과가 0개인 경우 (그리드/테이블 공통 안내)
// 1. 결과가 0개인 경우 안내
if (state.visibleProducts.length === 0) {
grid.classList.remove('grid', 'hidden');
grid.innerHTML = `
<div class="flex flex-col items-center justify-center py-20 w-full text-center">
grid.classList.remove('grid');
grid.classList.add('hidden');
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>
<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>
</div>
`;
tableWrapper.classList.add('hidden');
grid.innerHTML = emptyMsg;
grid.classList.remove('hidden');
if (paginationContainer) paginationContainer.innerHTML = '';
return;
}
// 2. 뷰 모드에 따른 컨테이너 노출
// 2. 뷰 모드 설정 및 컨테이너 노출 정리 (hidden/flex/grid 충돌 방지)
if (state.viewMode === 'grid') {
// 그리드 활성화
grid.classList.remove('hidden');
grid.classList.add('grid');
// 테이블 및 요약바 비활성화
tableWrapper.classList.add('hidden');
if (summaryBar) {
summaryBar.classList.remove('flex'); // flex 제거
summaryBar.classList.remove('flex');
summaryBar.classList.add('hidden');
}
} else {
// 그리드 비활성화
grid.classList.remove('grid');
grid.classList.add('hidden');
// 테이블 활성화
tableWrapper.classList.remove('hidden');
// 요약바 노출 여부는 데이터 상태에 따라 updateSummary에서 결정
updateSummary();
updateSummary(); // 테이블일 때만 요약바 노출 여부 결정
}
// 3. 현재 페이지 데이터 슬라이싱
// 3. 현재 페이지 데이터 계산
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const pagedProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
if (state.viewMode === 'grid') {
grid.innerHTML = '';
pagedProducts.forEach((product) => {
const isSold = STATUS_META[product.status]?.soldOut === true;
grid.insertAdjacentHTML('beforeend', `
<div class="group flex flex-col gap-4 cursor-pointer" onclick="openModal('${product.id}')">
<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"
style="background-image: url('${product.images[0]}')"></div>
<div class="absolute top-3 left-3">
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${STATUS_COLOR[product.status]} backdrop-blur-md border">
${product.status}
</span>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-col sm:flex-row justify-between items-start 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-slate-900 dark:text-white text-base font-bold text-nowrap">${product.currency}${product.price.toLocaleString()}</p>
</div>
<p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1">${product.description}</p>
</div>
// 1. 상태 판별
const isSold = STATUS_META[product.status]?.soldOut === true;
const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
// 2. 스펙(Condition) 정보 추출
const conditionKey = product.specs?.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
const conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '');
grid.insertAdjacentHTML('beforeend', `
<div class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="window.openModal('${product.id}')"
${!isSold ? `onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')"` : ''}>
<div class="relative w-full aspect-card bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm">
<div id="thumb-${product.id}"
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>
`);
});
} 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) {
conditionDisplay = conditionConfig.label;
conditionClass = conditionConfig.color;
} else if (conditionKey && conditionKey.trim() !== '') {
conditionDisplay = conditionKey;
} else {
conditionDisplay = '상세 설명 참고 ';
conditionClass = 'text-indigo-500 italic';
<div class="absolute top-3 left-3">
<span class="px-2 py-1 text-[10px] uppercase tracking-wider font-bold rounded ${STATUS_COLOR[product.status]} backdrop-blur-md border">
${product.status}
</span>
</div>
${!isSold && product.images?.length > 1 ? `
<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. 행 전체 스타일 및 제목 스타일
const rowClass = isSold ? 'opacity-50 grayscale-[0.5]' : '';
const titleClass = isSold ? 'line-through text-slate-400' : 'text-slate-900 dark:text-white';
document.querySelectorAll('.product-card').forEach(card => observer.observe(card));
return `
<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') {
${isSold ? "alert('판매 완료된 상품은 상세 정보를 볼 수 없습니다.');" : `openModal('${product.id}')`}
}">
<td class="py-4 px-4 text-center" onclick="event.stopPropagation()">
<input type="checkbox"
class="product-check rounded border-slate-300 w-4 h-4 ${isSelectable ? 'cursor-pointer text-primary' : 'opacity-20 cursor-not-allowed'}"
${state.selectedIds.has(product.id) ? 'checked' : ''}
${isSelectable ? '' : 'disabled'}
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('');
} else {
// 테이블 렌더링
tableBody.innerHTML = pagedProducts.map((product) => {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
const conditionKey = product.specs.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
let conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || '상세 설명 참고 ');
let conditionClass = conditionConfig ? conditionConfig.color : 'text-slate-500';
return `
<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') {
${isSold ? "alert('판매 완료된 상품입니다.');" : `window.openModal('${product.id}')`}
}">
<td class="py-4 px-4 text-center" onclick="event.stopPropagation()">
<input type="checkbox"
class="product-check rounded border-slate-300 w-4 h-4 ${isSelectable ? 'cursor-pointer text-primary' : 'opacity-20 cursor-not-allowed'}"
${state.selectedIds.has(product.id) ? 'checked' : ''}
${isSelectable ? '' : 'disabled'}
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');
if (selectAllCheck) {
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));
}
renderPagination();
// 페이지네이션 함수 호출 (이 함수는 외부에 정의되어 있어야 함)
if (typeof renderPagination === 'function') {
renderPagination();
}
}
export function renderPagination() {