- 태그 AND 검색 도입
- UI/UX 디자인 개선 (칩 & 배지)
- 모바일 최적화 및 레이아웃
- 성능 및 리소스 최적화 (Zero-Dependency 아이콘)
- 데이터 안정성 및 기타
- 그 외 오류 복구
- Tailwind CDN 제거
This commit is contained in:
2026-02-12 17:25:56 +09:00
parent a7817d2113
commit 555321fe70
10 changed files with 990 additions and 428 deletions

View File

@@ -2,9 +2,9 @@
import { state, saveSelection } from './state.js';
import { ITEMS_PER_PAGE, STATUS_META, STATUS_COLOR, PRODUCT_CONDITIONS } from './config.js';
import { updateSummary } from './main.js';
import { openModal } from './modal.js';
// 1. 체크박스 전역 핸들러 등록
window.toggleSelectItem = function(id) {
window.toggleSelectItem = function (id) {
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
} else {
@@ -29,12 +29,17 @@ export function renderProducts(page = 1) {
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>
<svg class="w-20 h-20 text-slate-300 dark:text-slate-700 mb-6"
viewBox="0 -960 960 960"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M138.5-138.5Q80-197 80-280t58.5-141.5Q197-480 280-480t141.5 58.5Q480-363 480-280t-58.5 141.5Q363-80 280-80t-141.5-58.5ZM824-120 568-376q-12-13-25.5-26.5T516-428q38-24 61-64t23-88q0-75-52.5-127.5T420-760q-75 0-127.5 52.5T240-580q0 6 .5 11.5T242-557q-18 2-39.5 8T164-535q-2-11-3-22t-1-23q0-109 75.5-184.5T420-840q109 0 184.5 75.5T680-580q0 43-13.5 81.5T629-428l251 252-56 56Zm-615-61 71-71 70 71 29-28-71-71 71-71-28-28-71 71-71-71-28 28 71 71-71 71 28 28Z"/>
</svg>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm">입력하신 검색어나 선택한 필터를 확인해 주세요.</p>
</div>
`;
@@ -67,93 +72,98 @@ export function renderProducts(page = 1) {
if (state.viewMode === 'grid') {
grid.innerHTML = '';
pagedProducts.forEach((product) => {
// 1. 상태 판별
const isSold = STATUS_META[product.status]?.soldOut === true;
const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
// 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 || '');
// 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="if(!window.isDragging) window.openModal('${product.id}')"
${!isSold ? `
onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')"
ontouchstart="window.handleTouchStart(event)"
ontouchmove="window.handleTouchMove(event, '${product.id}')"
ontouchend="window.handleTouchEnd(event, '${product.id}')"
` : `
ontouchend="window.handleTouchEnd(event, '${product.id}')"
`}>
grid.insertAdjacentHTML(
'beforeend',
`
<div
class="product-card group flex flex-col gap-4 cursor-pointer"
data-id="${product.id}"
onclick="if(!window.isDragging) window.openModal('${product.id}')"
${
!isSold
? `
onmousemove="window.handleThumbnailHover(event, '${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')"
ontouchstart="window.handleTouchStart(event)"
ontouchmove="window.handleTouchMove(event, '${product.id}')"
ontouchend="window.handleTouchEnd(event, '${product.id}')"
`
: `
ontouchend="window.handleTouchEnd(event, '${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 absolute inset-0 transition-all duration-500 ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, transform;">
</div>
<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 absolute inset-0 transform transition-transform duration-500 ${isSold ? 'grayscale opacity-60' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image;">
</div>
<div id="thumb-fade-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 opacity-0 pointer-events-none transition-all duration-500 ${isSold ? 'grayscale' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, opacity, transform;">
</div>
<div id="thumb-fade-${product.id}"
class="w-full h-full bg-center bg-no-repeat bg-cover absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none transform transition-transform duration-500 ${isSold ? 'grayscale' : 'group-hover:scale-105'}"
style="background-image: none; will-change: background-image, opacity;">
</div>
<div class="absolute top-3 left-3 z-10">
<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 class="absolute top-3 left-3 z-10">
<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 ? `
${
!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 z-10">
${product.images.map((_, i) => `
${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('')}
`,
)
.join('')}
</div>
` : ''}
</div>
`
: ''
}
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col 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.currency || '₩'}${product.price.toLocaleString()}`}
</p>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col 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.currency || '₩'}${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>
`);
});
setupLazyLoading();
<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>
`,
);
});
setupLazyLoading();
} 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';
tableBody.innerHTML = pagedProducts
.map((product) => {
const meta = STATUS_META[product.status];
const isSold = meta?.soldOut === true;
const isSelectable = meta?.selectable !== false;
return `
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}')`}
@@ -174,15 +184,15 @@ setupLazyLoading();
<span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span>
</td>
</tr>`;
}).join('');
})
.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));
}
@@ -193,33 +203,36 @@ setupLazyLoading();
}
function setupLazyLoading() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const card = entry.target;
const productId = card.getAttribute('data-id');
// state.js 등에서 가져온 데이터 활용
const product = state.visibleProducts.find(p => p.id === productId);
const thumb = document.getElementById(`thumb-${productId}`);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const card = entry.target;
const productId = card.getAttribute('data-id');
// state.js 등에서 가져온 데이터 활용
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;
});
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);
}
observer.unobserve(card);
}
});
}, { threshold: 0.1 });
});
},
{ threshold: 0.1 },
);
document.querySelectorAll('.product-card').forEach(card => observer.observe(card));
document.querySelectorAll('.product-card').forEach((card) => observer.observe(card));
}
export function renderPagination() {