- 태그 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

@@ -10,5 +10,5 @@
"arrowParens": "always", "arrowParens": "always",
"endOfLine": "auto", "endOfLine": "auto",
"htmlWhitespaceSensitivity": "ignore", "htmlWhitespaceSensitivity": "ignore",
"embeddedLanguageFormatting": "off" "embeddedLanguageFormatting": "auto"
} }

View File

@@ -91,7 +91,7 @@ const games = [
tags: ['PC', 'JP', 'Limited Edition', 'R19'], tags: ['PC', 'JP', 'Limited Edition', 'R19'],
images: ['/images/games/b5k8q2zn_01.webp', '/images/games/b5k8q2zn_02.webp', '/images/games/b5k8q2zn_03.webp', '/images/games/b5k8q2zn_04.webp'], images: ['/images/games/b5k8q2zn_01.webp', '/images/games/b5k8q2zn_02.webp', '/images/games/b5k8q2zn_03.webp'],
description: '호화판, 일본 내수용(JP), 한국어 미지원', description: '호화판, 일본 내수용(JP), 한국어 미지원',

View File

@@ -25,69 +25,10 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>sori.inventory</title> <title>sori.inventory</title>
<!-- Tailwind CSS --> <!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <link href="/style/tailwind.css" rel="stylesheet" />
<!-- Google Fonts: Inter --> <!-- Google Fonts: Inter -->
<link href="https://fonts.googleapis.com" rel="preconnect" /> <link href="https://fonts.googleapis.com" rel="preconnect" />
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" /> <link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect" />
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet" />
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#137fec",
"background-light": "#f6f7f8",
"background-dark": "#101922",
},
fontFamily: {
"display": ["Inter", "sans-serif"]
},
borderRadius: {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
screens: {
'xs': '480px',
},
aspectRatio: {
'card': '4 / 5',
},
},
},
}
</script>
<style>
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
#modal-main-carousel-container {
scroll-snap-type: none;
overflow-x: hidden;
}
.modal-open {
overflow: hidden !important;
height: 100% !important;
overscroll-behavior: none !important;
}
#product-modal {
overscroll-behavior: contain;
}
[id^="thumb-"] {
transition: all 0.6s ease-in-out;
background-color: #f1f5f9; /* 로딩 중 배경색 (slate-100) */
}
</style>
</head> </head>
<body class="bg-background-light dark:bg-background-dark transition-colors duration-200"> <body class="bg-background-light dark:bg-background-dark transition-colors duration-200">
<div class="relative flex h-auto min-h-screen w-full flex-col group/design-root overflow-x-hidden"> <div class="relative flex h-auto min-h-screen w-full flex-col group/design-root overflow-x-hidden">
@@ -96,25 +37,31 @@
<header class="sticky top-0 z-50 gap-2 flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-background-dark/80 backdrop-blur-md px-6 md:px-40 py-3"> <header class="sticky top-0 z-50 gap-2 flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-background-dark/80 backdrop-blur-md px-6 md:px-40 py-3">
<div class="flex items-center gap-8"> <div class="flex items-center gap-8">
<div class="flex items-center gap-3 text-slate-900 dark:text-white"> <div class="flex items-center gap-3 text-slate-900 dark:text-white">
<div class="flex items-center justify-center size-8 bg-primary rounded-lg text-white"> <img src="/images/assets/favicon/favicon-32x32.png" alt="sori.inventory logo" class="w-5 h-5" />
<span class="material-symbols-outlined text-xl">inventory_2</span>
</div>
<h2 class="hidden xs:block text-lg font-bold leading-tight tracking-tight cursor-pointer" id="logo-title">sori.inventory</h2> <h2 class="hidden xs:block text-lg font-bold leading-tight tracking-tight cursor-pointer" id="logo-title">sori.inventory</h2>
</div> </div>
</div> </div>
<div class="flex flex-1 justify-end gap-4 items-center"> <div class="flex flex-1 justify-end gap-4 items-center">
<label class="flex flex-col min-w-40 h-10 max-w-64"> <label class="flex flex-col min-w-40 h-10 max-w-64">
<div class="flex w-full flex-1 items-stretch rounded-lg h-full border border-slate-200 dark:border-slate-700"> <div class="flex w-full flex-1 items-stretch rounded-lg h-full border border-slate-200 dark:border-slate-700">
<div class="text-slate-400 flex items-center justify-center pl-3"> <div class="inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-primary transition-colors">
<span class="material-symbols-outlined text-[20px]">search</span> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div> </div>
<input id="search-input" class="form-input flex w-full min-w-0 flex-1 border-none bg-transparent focus:outline-0 focus:ring-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 px-3 text-sm font-normal" placeholder="검색어 입력..." value="" /> <input id="search-input" class="form-input flex w-full min-w-0 flex-1 border-none bg-transparent focus:outline-0 focus:ring-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 px-3 text-sm font-normal" placeholder="검색어 입력..." value="" />
</div> </div>
</label> </label>
<button id="theme-toggle" class="flex items-center justify-center size-10 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"> <div class="flex items-center gap-2 md:gap-3">
<span id="theme-toggle-dark-icon" class="material-symbols-outlined hidden">dark_mode</span> <button id="theme-toggle" class="p-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all">
<span id="theme-toggle-light-icon" class="material-symbols-outlined hidden">light_mode</span> <svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
</button> <path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"></path>
</svg>
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
</button>
</div>
</div> </div>
</header> </header>
<main class="flex-1"> <main class="flex-1">
@@ -134,6 +81,24 @@
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500">Status</h4> <h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500">Status</h4>
<div id="status-chips" class="flex flex-wrap gap-2"></div> <div id="status-chips" class="flex flex-wrap gap-2"></div>
</div> </div>
<!-- 태그 -->
<div class="mb-8">
<div class="flex items-center justify-between mb-3">
<h3 class="text-slate-900 dark:text-white text-sm font-bold flex items-center gap-2">
태그 필터
<span id="active-tag-count" class="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full hidden">0</span>
</h3>
<button id="toggle-tags" class="text-slate-400 hover:text-primary transition-transform duration-300 origin-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
<div id="tag-container" class="relative overflow-hidden transition-all duration-300 max-h-8">
<div id="tag-chips" class="flex flex-wrap gap-1.5"></div>
</div>
</div>
</div> </div>
</section> </section>
<!-- Filters/Chips Section --> <!-- Filters/Chips Section -->
@@ -145,38 +110,61 @@
</div> </div>
<section class="px-6 md:px-40 pt-6 -mb-4"> <section class="px-6 md:px-40 pt-6 -mb-4">
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between gap-y-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 order-1">
<span class="text-slate-400 dark:text-slate-500 text-xs font-bold uppercase tracking-widest">Total Results</span> <span class="text-slate-400 dark:text-slate-500 text-xs font-bold uppercase tracking-widest">Total Results</span>
<span id="total-count" class="px-2 py-0.5 rounded bg-primary/10 text-primary text-sm font-bold">0</span> <span id="total-count" class="px-2 py-0.5 rounded bg-primary/10 text-primary text-sm font-bold">0</span>
</div> </div>
<div id="selection-summary" class="hidden items-center gap-3 bg-primary/10 px-3 py-1.5 rounded-full border border-primary/20">
<span class="text-[11px] font-bold text-primary">
<span id="selected-count">0</span>
items
</span>
<div class="w-px h-3 bg-primary/30"></div>
<span class="text-[11px] font-bold text-primary" id="selected-total-price">₩0</span>
<div class="flex items-center gap-1 ml-2"> <div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-lg order-2 md:order-3">
<button id="view-grid" class="p-1.5 rounded-md text-slate-400 cursor-pointer hover:text-slate-600 dark:hover:text-slate-200 transition-all">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
<button id="view-table" class="p-1.5 rounded-md text-slate-400 cursor-pointer hover:text-slate-600 dark:hover:text-slate-200 transition-all">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
<div id="selection-summary" class="hidden items-center gap-3 bg-primary/10 px-3 py-1.5 rounded-full border border-primary/20 w-full md:w-auto justify-between md:justify-start order-3 md:order-2">
<div class="flex items-center gap-2">
<span class="text-[11px] font-bold text-primary">
<span id="selected-count">0</span>
items
</span>
<div class="w-px h-3 bg-primary/30"></div>
<span class="text-[11px] font-bold text-primary" id="selected-total-price">₩0</span>
</div>
<div class="flex items-center gap-1">
<button onclick="window.exportToExcel()" class="flex items-center gap-1 bg-primary text-white text-[10px] px-2 py-1 rounded-md hover:bg-primary-dark transition-colors"> <button onclick="window.exportToExcel()" class="flex items-center gap-1 bg-primary text-white text-[10px] px-2 py-1 rounded-md hover:bg-primary-dark transition-colors">
<span class="material-symbols-outlined text-sm">download</span> <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Export Export
</button> </button>
<button onclick="window.resetSelection()" class="flex items-center gap-1 bg-white dark:bg-slate-800 text-slate-500 text-[10px] px-2 py-1 rounded-md border border-slate-200 dark:border-slate-700 hover:bg-slate-50 transition-colors"> <button onclick="window.resetSelection()" class="flex items-center gap-1 bg-white dark:bg-slate-800 text-slate-500 text-[10px] px-2 py-1 rounded-md border border-slate-200 dark:border-slate-700 hover:bg-slate-50 transition-colors">
<span class="material-symbols-outlined text-sm">restart_alt</span> <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
</svg>
Reset Reset
</button> </button>
</div> </div>
</div> </div>
<div class="flex bg-slate-100 dark:bg-slate-800 p-1 rounded-lg">
<button id="view-grid" class="p-1.5 rounded-md bg-white dark:bg-slate-700 shadow-sm text-primary transition-all">
<span class="material-symbols-outlined text-xl">grid_view</span>
</button>
<button id="view-table" class="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-all">
<span class="material-symbols-outlined text-xl">format_list_bulleted</span>
</button>
</div>
</div> </div>
</section> </section>
<!-- Table --> <!-- Table -->
@@ -210,12 +198,15 @@
</main> </main>
<!-- Modal --> <!-- Modal -->
<!-- Backdrop Overlay --> <!-- Backdrop Overlay -->
<div id="product-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 backdrop-blur-sm p-4 md:p-10" onclick="if(event.target === this) closeModal()"> <div id="product-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 backdrop-blur-sm p-4 md:p-10" onclick="if (event.target === this) closeModal();">
<!-- Modal Container: 모바일에서 화면 안에 맞춤 (dvh), 이미지 영역 높이 제한 --> <!-- Modal Container: 모바일에서 화면 안에 맞춤 (dvh), 이미지 영역 높이 제한 -->
<div class="relative w-full max-w-6xl max-h-[calc(100dvh-2rem)] md:max-h-[90vh] bg-white dark:bg-background-dark rounded-xl shadow-2xl overflow-hidden flex flex-col md:flex-row border border-gray-200 dark:border-gray-800 mx-auto"> <div class="relative w-full max-w-6xl max-h-[calc(100dvh-2rem)] md:max-h-[90vh] bg-white dark:bg-background-dark rounded-xl shadow-2xl overflow-hidden flex flex-col md:flex-row border border-gray-200 dark:border-gray-800 mx-auto">
<!-- Close Button --> <!-- Close Button -->
<button onclick="closeModal()" class="absolute top-4 right-4 z-50 flex items-center justify-center w-10 h-10 rounded-full bg-white/80 dark:bg-gray-800/80 backdrop-blur hover:bg-white dark:hover:bg-gray-700 transition-colors shadow-sm"> <button onclick="window.closeModal()" class="absolute top-4 right-4 z-40 p-2 rounded-full bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all shadow-sm group">
<span class="material-symbols-outlined text-gray-900 dark:text-white">close</span> <svg class="w-5 h-5 text-gray-900 dark:text-white transition-transform group-hover:rotate-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button> </button>
<!-- Left: Gallery Section (모바일: 상하좌우 패딩으로 이미지 영역 확실히) --> <!-- Left: Gallery Section (모바일: 상하좌우 패딩으로 이미지 영역 확실히) -->
<div class="w-full md:w-3/5 flex flex-col relative overflow-hidden min-h-0 max-h-[40dvh] md:max-h-none p-3 md:p-0"> <div class="w-full md:w-3/5 flex flex-col relative overflow-hidden min-h-0 max-h-[40dvh] md:max-h-none p-3 md:p-0">
@@ -228,7 +219,9 @@
<div <div
class="w-full h-full bg-center bg-no-repeat bg-contain rounded-lg" class="w-full h-full bg-center bg-no-repeat bg-contain rounded-lg"
data-alt="Front view of Sony WH-1000XM5 headphones in black" data-alt="Front view of Sony WH-1000XM5 headphones in black"
style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuCOlUeqiUla7LkapcfcrnlTRF-bvdnD2tGTS8zjFgnxr6MNEwmTehjldThx6SCikcIP4-uUK4fbm1EFxj7XKKckJnQr8AunGsdWdgOlH5Fex0ML3uFw5fCnp997fuXQa2ceXdqXfiGDM17AdqB7tx9kxLoxEUXDxROSiH7I2-KFOweIR1-dBljEbeih1fQ1y_HACne_STdXKwGfrkPsHOXz-Ls7-MBW8uD1i5Mz64d3I8z4sO036qIlTNd2Iz4pqPxp7ucNkhrtQoc");'></div> style="
background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuCOlUeqiUla7LkapcfcrnlTRF-bvdnD2tGTS8zjFgnxr6MNEwmTehjldThx6SCikcIP4-uUK4fbm1EFxj7XKKckJnQr8AunGsdWdgOlH5Fex0ML3uFw5fCnp997fuXQa2ceXdqXfiGDM17AdqB7tx9kxLoxEUXDxROSiH7I2-KFOweIR1-dBljEbeih1fQ1y_HACne_STdXKwGfrkPsHOXz-Ls7-MBW8uD1i5Mz64d3I8z4sO036qIlTNd2Iz4pqPxp7ucNkhrtQoc');
"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -284,12 +277,17 @@
<!-- Footer / CTA: 항상 하단 고정, 버튼이 설명 위로 겹치지 않음 --> <!-- Footer / CTA: 항상 하단 고정, 버튼이 설명 위로 겹치지 않음 -->
<div class="flex flex-col gap-2 shrink-0 pt-4 pb-6 px-4 sm:px-6 md:px-8 lg:px-10 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-background-dark"> <div class="flex flex-col gap-2 shrink-0 pt-4 pb-6 px-4 sm:px-6 md:px-8 lg:px-10 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-background-dark">
<button id="copy-link-btn" class="w-full flex items-center justify-center gap-2 bg-slate-900 dark:bg-white dark:text-slate-900 text-white font-bold py-4 px-6 rounded-xl transition-all shadow-lg"> <button id="copy-link-btn" class="w-full flex items-center justify-center gap-2 bg-slate-900 dark:bg-white dark:text-slate-900 text-white font-bold py-4 px-6 rounded-xl transition-all shadow-lg">
<span class="material-symbols-outlined">link</span> <svg class="w-4 h-4 transition-transform group-hover:rotate-12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<span id="copy-btn-text">상품 링크 복사하기</span> <span id="copy-btn-text">상품 링크 복사하기</span>
</button> </button>
<a href="https://open.kakao.com/o/sPZ3Cnfi" target="_blank" class="w-full flex items-center justify-center gap-2 bg-[#fae100] text-[#3c1e1e] font-bold py-4 px-6 rounded-xl transition-all shadow-lg hover:bg-[#f7d600]"> <a href="https://open.kakao.com/o/sPZ3Cnfi" target="_blank" class="w-full flex items-center justify-center gap-2 bg-[#fae100] text-[#3c1e1e] font-bold py-4 px-6 rounded-xl transition-all shadow-lg hover:bg-[#f7d600]">
<span class="material-symbols-outlined">chat_bubble</span> <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<span>오픈카톡으로 문의하기</span> <span>오픈카톡으로 문의하기</span>
</a> </a>
@@ -299,14 +297,22 @@
</div> </div>
</div> </div>
<!-- selection-reset-modal --> <!-- selection-reset-modal -->
<div id="selection-reset-modal" class="fixed inset-0 z-[100] hidden items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm"> <div id="selection-reset-modal" class="fixed inset-0 z-50 hidden items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
<div class="bg-white dark:bg-slate-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl scale-95 transition-transform duration-200"> <div class="bg-white dark:bg-slate-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl scale-95 transition-transform duration-200">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<div class="size-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4"> <div class="size-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
<span class="material-symbols-outlined text-red-600">delete_sweep</span> <span class="text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor">
<path d="M600-240v-80h160v80H600Zm0-320v-80h280v80H600Zm0 160v-80h240v80H600ZM120-640H80v-80h160v-60h160v60h160v80h-40v360q0 33-23.5 56.5T440-200H200q-33 0-56.5-23.5T120-280v-360Zm80 0v360h240v-360H200Zm0 0v360-360Z" />
</svg>
</span>
</div> </div>
<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 mb-6">현재 체크된 모든 상품의 선택이 해제됩니다.<br>계속하시겠습니까?</p> <p class="text-slate-500 dark:text-slate-400 text-sm mb-6">
현재 체크된 모든 상품의 선택이 해제됩니다.
<br />
계속하시겠습니까?
</p>
<div class="flex gap-3 w-full"> <div class="flex gap-3 w-full">
<button onclick="closeSelectionResetModal()" class="flex-1 py-3 px-4 rounded-xl font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 transition-colors">취소</button> <button onclick="closeSelectionResetModal()" class="flex-1 py-3 px-4 rounded-xl font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 transition-colors">취소</button>
<button onclick="confirmSelectionReset()" class="flex-1 py-3 px-4 rounded-xl font-semibold text-white bg-red-500 hover:bg-red-600 transition-colors">초기화</button> <button onclick="confirmSelectionReset()" class="flex-1 py-3 px-4 rounded-xl font-semibold text-white bg-red-500 hover:bg-red-600 transition-colors">초기화</button>

View File

@@ -14,14 +14,14 @@ export const SORT_CONFIG = {
export const STATUS_META = { export const STATUS_META = {
미판매: { 미판매: {
selectable: false, // 체크박스 선택 불가 selectable: false, // 체크박스 선택 불가
isDefaultActive: false, // 초기 로드 시 미체크 상태 isDefaultActive: false, // 초기 로드 시 미체크 상태
isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김) isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김)
soldOut: false, soldOut: false,
}, },
판매예정: { 판매예정: {
selectable: false, selectable: false,
isDefaultActive: true, isDefaultActive: false,
isSystemVisible: true, isSystemVisible: true,
soldOut: false, soldOut: false,
}, },
@@ -39,21 +39,18 @@ export const STATUS_META = {
}, },
}; };
export const STATUS_ORDER = {
판매중: 1,
판매예정: 2,
판매완료: 3,
미판매: 4,
};
// STATUS_FILTERS를 수동으로 만들지 않고 META에서 자동으로 생성합니다. // STATUS_FILTERS를 수동으로 만들지 않고 META에서 자동으로 생성합니다.
export const STATUS_FILTERS = Object.keys(STATUS_META) export const STATUS_FILTERS = Object.keys(STATUS_META)
.filter(key => STATUS_META[key].isSystemVisible) // 시스템 가시성이 true인 것만 필터 칩 생성 .filter((key) => STATUS_META[key].isSystemVisible)
.map(key => ({ .map((key) => ({ key, label: key }))
key: key, .sort((a, b) => (STATUS_ORDER[a.key] || 99) - (STATUS_ORDER[b.key] || 99));
label: key === '판매예정' ? '판매 예정' : key,
defaultActive: STATUS_META[key].isDefaultActive
}));
export const STATUS_ORDER = {
판매중: 0,
판매예정: 1,
미판매: 2,
판매완료: 3,
};
export const STATUS_COLOR = { export const STATUS_COLOR = {
판매중: 'bg-primary/10 text-primary border-primary/30', 판매중: 'bg-primary/10 text-primary border-primary/30',
@@ -93,5 +90,5 @@ export const PRODUCT_CONDITIONS = {
INCOMPLETE: { label: 'Incomplete (구성품 누락)', color: 'text-amber-600', level: 'C' }, INCOMPLETE: { label: 'Incomplete (구성품 누락)', color: 'text-amber-600', level: 'C' },
DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' }, DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' },
JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' }, JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' },
OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' } OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' },
}; };

View File

@@ -3,12 +3,21 @@ import { state, productsData } from './state.js';
import { ITEMS_PER_PAGE, STATUS_META, STATUS_FILTERS, STATUS_ORDER, STATUS_COLOR, SEARCH_CONFIG, SORT_CONFIG } from './config.js'; import { ITEMS_PER_PAGE, STATUS_META, STATUS_FILTERS, STATUS_ORDER, STATUS_COLOR, SEARCH_CONFIG, SORT_CONFIG } from './config.js';
import { renderProducts } from './productList.js'; import { renderProducts } from './productList.js';
// 1 & 2. 칩 사이즈 통일 및 미판매 활성화 스타일 강화
function getStatusChipClass(status, isActive) { function getStatusChipClass(status, isActive) {
// 1. 태그 칩과 동일한 사이즈 (텍스트 크기 포함)
const commonSize = 'min-w-[70px] justify-center px-3 py-1 text-[11px] md:text-xs';
const base = STATUS_COLOR[status] ?? ''; const base = STATUS_COLOR[status] ?? '';
if (isActive) { if (isActive) {
return `${base} opacity-100 shadow-sm`; if (status === '미판매') {
// 2. 미판매 활성화 시 특수 컬러 (인디고)
return `${commonSize} bg-indigo-600 text-white border-indigo-700 shadow-md ring-1 ring-indigo-300 opacity-100`;
}
return `${commonSize} ${base} opacity-100 shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700`;
} }
return `bg-slate-50 text-slate-400 border-slate-200 opacity-30 grayscale hover:opacity-50`;
return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`;
} }
export function renderStatusChips() { export function renderStatusChips() {
@@ -16,15 +25,20 @@ export function renderStatusChips() {
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
// 이제 config에서 자동 생성된 STATUS_FILTERS를 사용합니다.
STATUS_FILTERS.forEach(({ key, label }) => { STATUS_FILTERS.forEach(({ key, label }) => {
const isActive = state.activeStatuses.has(key); const isActive = state.activeStatuses.has(key);
const chip = document.createElement('button'); const chip = document.createElement('button');
// getStatusChipClass 함수가 기존에 정의되어 있다면 그대로 사용하세요.
chip.className = `status-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition-all duration-200 border ${getStatusChipClass(key, isActive)}`; // 칩 공통 스타일 적용
chip.className = `status-chip flex items-center rounded-full font-bold transition-all duration-200 border ${getStatusChipClass(key, isActive)}`;
chip.textContent = label; chip.textContent = label;
chip.onclick = () => toggleStatusFilter(key);
chip.onclick = () => {
if (state.activeStatuses.has(key)) state.activeStatuses.delete(key);
else state.activeStatuses.add(key);
renderStatusChips();
applyFilters();
};
container.appendChild(chip); container.appendChild(chip);
}); });
} }
@@ -42,6 +56,68 @@ function toggleStatusFilter(status) {
renderStatusChips(); renderStatusChips();
} }
export function renderTagChips() {
const container = document.getElementById('tag-chips');
if (!container) return;
// 1. 모든 상품에서 유니크한 태그 추출
const allTags = new Set();
productsData.forEach((p) => {
if (p.tags) p.tags.forEach((tag) => allTags.add(tag));
});
const sortedTags = Array.from(allTags).sort();
// 2. 리셋 버튼 HTML (맨 앞에 배치)
// 활성화된 태그가 있을 때만 강조되거나, 항상 보이게 설정 가능합니다.
const hasActiveTags = state.activeTags.size > 0;
const resetBtnHtml = `
<button id="tag-reset-btn" class="flex items-center justify-center px-2 py-1 rounded-full border transition-all duration-200
${hasActiveTags ? 'bg-red-50 text-red-500 border-red-200 hover:bg-red-100' : 'bg-slate-50 text-slate-400 border-slate-200 opacity-60'}"
title="태그 초기화">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
`;
// 3. 태그 칩들과 합치기
container.innerHTML =
resetBtnHtml +
sortedTags
.map((tag) => {
const isActive = state.activeTags.has(tag);
return `
<button class="tag-chip flex items-center justify-center px-3 py-1 rounded-full text-[11px] md:text-xs font-bold transition-all duration-200 border
${isActive ? 'bg-primary text-white border-primary shadow-sm' : 'bg-slate-50 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700 hover:border-primary'}"
data-tag="${tag}">
#${tag}
</button>
`;
})
.join('');
// 4. 리셋 버튼 이벤트 바인딩
document.getElementById('tag-reset-btn').onclick = () => {
if (state.activeTags.size === 0) return;
state.activeTags.clear();
renderTagChips(); // UI 갱신
applyFilters(); // 필터 적용
};
// 5. 개별 태그 칩 이벤트 바인딩
container.querySelectorAll('.tag-chip').forEach((chip) => {
chip.onclick = () => {
const tag = chip.dataset.tag;
if (state.activeTags.has(tag)) state.activeTags.delete(tag);
else state.activeTags.add(tag);
renderTagChips();
applyFilters();
};
});
}
// [핵심] 필터 적용 함수 // [핵심] 필터 적용 함수
export function applyFilters() { export function applyFilters() {
const keyword = state.searchKeyword.toLowerCase(); const keyword = state.searchKeyword.toLowerCase();
@@ -64,21 +140,29 @@ export function applyFilters() {
const searchMatch = const searchMatch =
keyword === '' || keyword === '' ||
(() => { (() => {
// 검색어를 공백 기준으로 쪼개서 배열로 만듭니다 (예: "jp r19" -> ["jp", "r19"])
const searchTerms = keyword.split(/\s+/).filter((term) => term.length > 0);
const searchPool = []; const searchPool = [];
if (SEARCH_CONFIG.USE_TITLE) searchPool.push(product.title); if (SEARCH_CONFIG.USE_TITLE) searchPool.push(product.title);
if (SEARCH_CONFIG.USE_CUSTOM_TAG && product.customTag) searchPool.push(product.customTag); if (SEARCH_CONFIG.USE_CUSTOM_TAG && product.customTag) searchPool.push(product.customTag);
if (SEARCH_CONFIG.USE_DESCRIPTION && product.description) searchPool.push(product.description); if (SEARCH_CONFIG.USE_DESCRIPTION && product.description) searchPool.push(product.description);
// 태그 배열과 상세 설명 배열을 문자열 풀에 합칩니다.
if (SEARCH_CONFIG.USE_TAGS && product.tags) searchPool.push(...product.tags); if (SEARCH_CONFIG.USE_TAGS && product.tags) searchPool.push(...product.tags);
if (SEARCH_CONFIG.USE_FULL_DESCRIPTION && product.fullDescription) searchPool.push(...product.fullDescription); if (SEARCH_CONFIG.USE_FULL_DESCRIPTION && product.fullDescription) searchPool.push(...product.fullDescription);
return searchPool.some((text) => // 모든 텍스트를 하나의 검색용 문자열로 결합
String(text || '') const combinedText = searchPool.map((text) => String(text || '').toLowerCase()).join(' ');
.toLowerCase()
.includes(keyword), // [핵심] 사용자가 입력한 모든 단어(searchTerms)가 결합된 텍스트에 포함되어 있는지 확인 (AND 조건)
); return searchTerms.every((term) => combinedText.includes(term));
})(); })();
return statusMatch && categoryMatch && searchMatch; // [5] 태그 필터 체크
const tagMatch = state.activeTags.size === 0 || Array.from(state.activeTags).every((tag) => product.tags && product.tags.includes(tag));
return statusMatch && categoryMatch && searchMatch && tagMatch;
}) })
.sort((a, b) => { .sort((a, b) => {
// 0. 스위치가 모두 꺼져있다면 정렬하지 않고 원본 순서 유지 // 0. 스위치가 모두 꺼져있다면 정렬하지 않고 원본 순서 유지
@@ -138,19 +222,17 @@ export function getCategories(products) {
} }
export function renderCategoryChips(products) { export function renderCategoryChips(products) {
const container = document.getElementById('filter-chips'); const container = document.getElementById('filter-chips'); // 혹은 HTML 구조에 맞춰 'category-chips'
if (!container) return; if (!container) return;
// [핵심] 시스템 가시성이 true인 상품의 카테고리만 추출합니다. // 1. 가시성 있는 상품의 카테고리만 추출
const validCategories = products const validCategories = products
.filter((p) => { .filter((p) => {
const meta = STATUS_META[p.status]; const meta = STATUS_META[p.status];
// 해당 상태가 정의되어 있고, 시스템에서 보여주기로 한 경우만 포함
return meta && meta.isSystemVisible; return meta && meta.isSystemVisible;
}) })
.map((p) => p.category); .map((p) => p.category);
// 'All'은 항상 포함하고, 필터링된 카테고리들만 중복 제거하여 합침
const categories = ['All', ...new Set(validCategories)]; const categories = ['All', ...new Set(validCategories)];
container.innerHTML = ''; container.innerHTML = '';
@@ -158,10 +240,19 @@ export function renderCategoryChips(products) {
categories.forEach((cat) => { categories.forEach((cat) => {
const isActive = state.activeCategories.has(cat); const isActive = state.activeCategories.has(cat);
const chip = document.createElement('button'); const chip = document.createElement('button');
chip.className = `filter-chip px-3 py-1.5 md:px-4 md:py-2 rounded-full text-xs md:text-sm font-medium transition border ${isActive ? 'bg-primary text-white border-primary shadow-sm' : 'bg-slate-50 text-slate-600 border-slate-200'}`;
// [수정] 태그 칩 사이즈(px-3 py-1) 및 폰트 크기(text-[11px]) 통일
// min-w-[65px]를 주면 'All' 같은 짧은 글자도 정갈하게 보입니다.
chip.className = `filter-chip flex items-center justify-center min-w-[65px] px-3 py-1 rounded-full text-[11px] md:text-xs font-bold transition-all duration-200 border
${isActive ? 'bg-primary text-white border-primary shadow-sm ring-1 ring-offset-1 ring-slate-200 dark:ring-slate-700 opacity-100' : 'bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale hover:opacity-60'}`;
chip.textContent = cat; chip.textContent = cat;
chip.dataset.category = cat; chip.dataset.category = cat;
chip.onclick = () => toggleCategory(cat);
chip.onclick = () => {
toggleCategory(cat);
};
container.appendChild(chip); container.appendChild(chip);
}); });
} }
@@ -172,10 +263,16 @@ export function toggleCategory(category) {
state.activeCategories.add('All'); state.activeCategories.add('All');
} else { } else {
state.activeCategories.delete('All'); state.activeCategories.delete('All');
state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category); if (state.activeCategories.has(category)) {
if (state.activeCategories.size === 0) state.activeCategories.add('All'); state.activeCategories.delete(category);
} else {
state.activeCategories.add(category);
}
if (state.activeCategories.size === 0) {
state.activeCategories.add('All');
}
} }
renderCategoryChips(productsData); renderCategoryChips(productsData); // 디자인(활성화 상태) 즉시 반영
applyFilters(); applyFilters();
} }

View File

@@ -1,6 +1,6 @@
/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */ /** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */
import { state, productsData, saveSelection } from './state.js'; import { state, productsData, saveSelection } from './state.js';
import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter } from './filters.js'; import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter, renderTagChips } from './filters.js';
import { ITEMS_PER_PAGE, STATUS_META } from './config.js'; import { ITEMS_PER_PAGE, STATUS_META } from './config.js';
import { renderProducts, changePage } from './productList.js'; import { renderProducts, changePage } from './productList.js';
import { openModal, closeModal } from './modal.js'; import { openModal, closeModal } from './modal.js';
@@ -8,7 +8,7 @@ import { scrollToImage } from './carousel.js';
console.log('Total products loaded:', productsData.length); console.log('Total products loaded:', productsData.length);
// HTML onclick에서 사용 // HTML onclick에서 사용하기 위한 전역 등록
window.openModal = openModal; window.openModal = openModal;
window.closeModal = closeModal; window.closeModal = closeModal;
window.changePage = changePage; window.changePage = changePage;
@@ -17,12 +17,12 @@ window.scrollToImage = scrollToImage;
let fadeTimers = {}; let fadeTimers = {};
// 뷰 전환 이벤트 // 뷰 전환 이벤트
// 뷰 전환 이벤트 바인딩
document.getElementById('view-grid').onclick = () => { document.getElementById('view-grid').onclick = () => {
state.viewMode = 'grid'; state.viewMode = 'grid';
updateViewButtons(); updateViewButtons();
renderProducts(state.currentPage); renderProducts(state.currentPage);
}; };
document.getElementById('view-table').onclick = () => { document.getElementById('view-table').onclick = () => {
state.viewMode = 'table'; state.viewMode = 'table';
updateViewButtons(); updateViewButtons();
@@ -31,10 +31,25 @@ document.getElementById('view-table').onclick = () => {
function updateViewButtons() { function updateViewButtons() {
const isGrid = state.viewMode === 'grid'; const isGrid = state.viewMode === 'grid';
document.getElementById('view-grid').classList.toggle('bg-white', isGrid); const gridBtn = document.getElementById('view-grid');
document.getElementById('view-grid').classList.toggle('text-primary', isGrid); const tableBtn = document.getElementById('view-table');
document.getElementById('view-table').classList.toggle('bg-white', !isGrid);
document.getElementById('view-table').classList.toggle('text-primary', !isGrid); if (!gridBtn || !tableBtn) return;
const active = ['bg-white', 'dark:bg-slate-700', 'shadow-sm', 'text-primary'];
const inactive = ['text-slate-400'];
if (isGrid) {
gridBtn.classList.add(...active);
gridBtn.classList.remove(...inactive);
tableBtn.classList.add(...inactive);
tableBtn.classList.remove(...active);
} else {
tableBtn.classList.add(...active);
tableBtn.classList.remove(...inactive);
gridBtn.classList.add(...inactive);
gridBtn.classList.remove(...active);
}
} }
// [전역 함수] 체크박스 토글 및 합계 업데이트 // [전역 함수] 체크박스 토글 및 합계 업데이트
@@ -45,30 +60,25 @@ window.toggleSelectItem = (id) => {
updateSummary(); updateSummary();
}; };
/** 선택 요약 바 업데이트 (모바일 레이아웃 대응) */
export function updateSummary() { export function updateSummary() {
const summary = document.getElementById('selection-summary'); const summaryBar = document.getElementById('selection-summary');
const countEl = document.getElementById('selected-count'); const countEl = document.getElementById('selected-count');
const priceEl = document.getElementById('selected-total-price'); const priceEl = document.getElementById('selected-total-price');
if (!summary) return; if (state.selectedIds.size > 0) {
summaryBar.classList.remove('hidden');
// 테이블 모드이면서 선택된 항목이 있을 때만 노출 summaryBar.classList.add('flex'); // flex-wrap은 HTML에 이미 적용됨
if (state.viewMode === 'table' && state.selectedIds.size > 0) { countEl.textContent = state.selectedIds.size;
// [수정] flex를 추가할 때 hidden은 확실히 제거
summary.classList.remove('hidden');
summary.classList.add('flex');
const total = Array.from(state.selectedIds).reduce((sum, id) => { const total = Array.from(state.selectedIds).reduce((sum, id) => {
const p = productsData.find((item) => item.id === id); const p = productsData.find((item) => item.id === id);
return sum + (p ? p.price : 0); return sum + (p ? p.price : 0);
}, 0); }, 0);
countEl.textContent = state.selectedIds.size;
priceEl.textContent = `${total.toLocaleString()}`; priceEl.textContent = `${total.toLocaleString()}`;
} else { } else {
// [수정] hidden을 추가할 때 flex는 확실히 제거 summaryBar.classList.add('hidden');
summary.classList.remove('flex'); summaryBar.classList.remove('flex');
summary.classList.add('hidden');
} }
} }
@@ -137,15 +147,53 @@ themeToggleBtn.addEventListener('click', function () {
updateIcons(); updateIcons();
}); });
// 초기 렌더
renderCategoryChips(productsData);
bindCategoryFilter(productsData);
renderStatusChips();
applyFilters();
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log('Total products loaded:', productsData.length);
// 1. UI 컴포넌트 렌더링
renderCategoryChips(productsData);
bindCategoryFilter(productsData);
renderStatusChips();
renderTagChips();
// 2. 초기 데이터 계산 및 첫 페이지 렌더링 (순서 중요)
applyFilters();
renderProducts(state.currentPage); renderProducts(state.currentPage);
checkUrlAndOpenModal(); updateViewButtons();
updateSummary();
// 테마 설정 (기존 로직 유지)
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
updateIcons();
// 3. 태그 토글 로직 (SVG 회전 포함)
const toggleBtn = document.getElementById('toggle-tags');
const tagContainer = document.getElementById('tag-container');
if (toggleBtn && tagContainer) {
toggleBtn.onclick = () => {
const isExpanded = tagContainer.classList.contains('expanded');
const svgIcon = toggleBtn.querySelector('svg');
if (isExpanded) {
tagContainer.style.maxHeight = tagContainer.scrollHeight + 'px';
requestAnimationFrame(() => {
tagContainer.style.maxHeight = '34px';
tagContainer.classList.remove('expanded');
if (svgIcon) svgIcon.style.transform = 'rotate(0deg)';
});
} else {
tagContainer.style.maxHeight = tagContainer.scrollHeight + 'px';
tagContainer.classList.add('expanded');
if (svgIcon) svgIcon.style.transform = 'rotate(180deg)';
setTimeout(() => {
if (tagContainer.classList.contains('expanded')) tagContainer.style.maxHeight = 'none';
}, 300);
}
};
}
}); });
// 데이터용 새 ID 생성기 // 데이터용 새 ID 생성기
@@ -308,7 +356,7 @@ function updateThumbnailWithFade(productId, newImageUrl, index) {
window.handleThumbnailLeave = (productId) => { window.handleThumbnailLeave = (productId) => {
currentHoverIndex = -1; // 인덱스 초기화 currentHoverIndex = -1; // 인덱스 초기화
resetThumbnail(productId); resetThumbnail(productId);
}; };
@@ -332,7 +380,7 @@ function resetThumbnail(productId) {
// 2. 페이드 레이어를 즉시 숨김 (transition 방해 금지) // 2. 페이드 레이어를 즉시 숨김 (transition 방해 금지)
fadeThumb.style.transition = 'none'; fadeThumb.style.transition = 'none';
fadeThumb.style.opacity = '0'; fadeThumb.style.opacity = '0';
// 3. 두 레이어 모두 첫 번째 이미지로 강제 일치 // 3. 두 레이어 모두 첫 번째 이미지로 강제 일치
mainThumb.style.backgroundImage = firstImg; mainThumb.style.backgroundImage = firstImg;
fadeThumb.style.backgroundImage = firstImg; fadeThumb.style.backgroundImage = firstImg;
@@ -342,7 +390,7 @@ function resetThumbnail(productId) {
fadeThumb.style.transition = 'opacity 0.3s ease-in-out'; fadeThumb.style.transition = 'opacity 0.3s ease-in-out';
}, 50); }, 50);
} }
if (indicator) updateIndicatorUI(indicator, 0); if (indicator) updateIndicatorUI(indicator, 0);
} }
@@ -356,7 +404,6 @@ function updateIndicatorUI(indicator, activeIndex) {
}); });
} }
// 터치 상태 관리를 위한 변수 // 터치 상태 관리를 위한 변수
let touchStartX = 0; let touchStartX = 0;
let isDragging = false; let isDragging = false;
@@ -387,7 +434,7 @@ window.handleTouchMove = (e, productId) => {
const mainThumb = document.getElementById(`thumb-${productId}`); const mainThumb = document.getElementById(`thumb-${productId}`);
const fadeThumb = document.getElementById(`thumb-fade-${productId}`); const fadeThumb = document.getElementById(`thumb-fade-${productId}`);
if (mainThumb && fadeThumb) { if (mainThumb && fadeThumb) {
// 드래그 중에는 페이드 없이 즉시 교체 (반응성 우선) // 드래그 중에는 페이드 없이 즉시 교체 (반응성 우선)
mainThumb.style.backgroundImage = `url("${product.images[index]}")`; mainThumb.style.backgroundImage = `url("${product.images[index]}")`;
@@ -418,4 +465,3 @@ function updateIndicator(productId, index) {
}); });
} }
} }

View File

@@ -2,9 +2,9 @@
import { state, saveSelection } from './state.js'; import { state, saveSelection } from './state.js';
import { ITEMS_PER_PAGE, STATUS_META, STATUS_COLOR, PRODUCT_CONDITIONS } from './config.js'; import { ITEMS_PER_PAGE, STATUS_META, STATUS_COLOR, PRODUCT_CONDITIONS } from './config.js';
import { updateSummary } from './main.js'; import { updateSummary } from './main.js';
import { openModal } from './modal.js';
// 1. 체크박스 전역 핸들러 등록 // 1. 체크박스 전역 핸들러 등록
window.toggleSelectItem = function(id) { window.toggleSelectItem = function (id) {
if (state.selectedIds.has(id)) { if (state.selectedIds.has(id)) {
state.selectedIds.delete(id); state.selectedIds.delete(id);
} else { } else {
@@ -29,12 +29,17 @@ export function renderProducts(page = 1) {
grid.classList.remove('grid'); grid.classList.remove('grid');
grid.classList.add('hidden'); grid.classList.add('hidden');
tableWrapper.classList.add('hidden'); tableWrapper.classList.add('hidden');
// 검색 결과 없음 메시지를 표시할 별도의 컨테이너가 없다면 grid 영역을 빌려 씁니다. // 검색 결과 없음 메시지를 표시할 별도의 컨테이너가 없다면 grid 영역을 빌려 씁니다.
const emptyMsg = ` const emptyMsg = `
<div class="col-span-full flex flex-col items-center justify-center py-20 w-full text-center"> <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> <svg class="w-20 h-20 text-slate-300 dark:text-slate-700 mb-6"
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2">검색 결과가 없습니다</h3> 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> <p class="text-slate-500 dark:text-slate-400 text-sm">입력하신 검색어나 선택한 필터를 확인해 주세요.</p>
</div> </div>
`; `;
@@ -67,93 +72,98 @@ export function renderProducts(page = 1) {
if (state.viewMode === 'grid') { if (state.viewMode === 'grid') {
grid.innerHTML = ''; grid.innerHTML = '';
pagedProducts.forEach((product) => { pagedProducts.forEach((product) => {
// 1. 상태 판별
// 1. 상태 판별 const isSold = STATUS_META[product.status]?.soldOut === true;
const isSold = STATUS_META[product.status]?.soldOut === true; const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
const isNonSale = product.status === '미판매'; // 상태값이 '미판매'일 때
// 2. 스펙(Condition) 정보 추출 // 2. 스펙(Condition) 정보 추출
const conditionKey = product.specs?.condition; const conditionKey = product.specs?.condition;
const conditionConfig = PRODUCT_CONDITIONS[conditionKey]; const conditionConfig = PRODUCT_CONDITIONS[conditionKey];
const conditionDisplay = conditionConfig ? conditionConfig.label : (conditionKey || ''); const conditionDisplay = conditionConfig ? conditionConfig.label : conditionKey || '';
grid.insertAdjacentHTML('beforeend', ` grid.insertAdjacentHTML(
<div class="product-card group flex flex-col gap-4 cursor-pointer" 'beforeend',
data-id="${product.id}" `
onclick="if(!window.isDragging) window.openModal('${product.id}')" <div
class="product-card group flex flex-col gap-4 cursor-pointer"
${!isSold ? ` data-id="${product.id}"
onmousemove="window.handleThumbnailHover(event, '${product.id}')" onclick="if(!window.isDragging) window.openModal('${product.id}')"
onmouseleave="window.handleThumbnailLeave('${product.id}')" ${
ontouchstart="window.handleTouchStart(event)" !isSold
ontouchmove="window.handleTouchMove(event, '${product.id}')" ? `
ontouchend="window.handleTouchEnd(event, '${product.id}')" onmousemove="window.handleThumbnailHover(event, '${product.id}')"
` : ` onmouseleave="window.handleThumbnailLeave('${product.id}')"
ontouchend="window.handleTouchEnd(event, '${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-fade-${product.id}"
<div id="thumb-${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'}"
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, opacity, transform;">
style="background-image: none; will-change: background-image;"> </div>
</div>
<div id="thumb-fade-${product.id}" <div class="absolute top-3 left-3 z-10">
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'}" <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>
style="background-image: none; will-change: background-image, opacity;"> </div>
</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"> !isSold && product.images?.length > 1
${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 z-10"> <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> <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>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<div class="flex flex-col justify-between items-start sm:items-baseline gap-1"> <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' : ''}"> <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>
${product.title} <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>
</h3> </div>
<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"> <div class="flex flex-col">
${conditionDisplay ? `<span class="text-[11px] font-medium text-slate-400 mb-0.5">${conditionDisplay}</span>` : ''} ${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"> <p class="text-slate-500 dark:text-slate-400 text-sm font-normal line-clamp-1 italic">${product.description}</p>
${product.description} </div>
</p> </div>
</div> </div>
</div> `,
</div> );
`); });
});
setupLazyLoading();
setupLazyLoading();
} else { } else {
// 테이블 렌더링 // 테이블 렌더링
tableBody.innerHTML = pagedProducts.map((product) => { tableBody.innerHTML = pagedProducts
const meta = STATUS_META[product.status]; .map((product) => {
const isSold = meta?.soldOut === true; const meta = STATUS_META[product.status];
const isSelectable = meta?.selectable !== false; 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 ` 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'}" <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') { onclick="if(event.target.type !== 'checkbox') {
${isSold ? "alert('판매 완료된 상품입니다.');" : `window.openModal('${product.id}')`} ${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> <span class="px-2 py-0.5 rounded text-[10px] font-bold border ${STATUS_COLOR[product.status]}">${product.status}</span>
</td> </td>
</tr>`; </tr>`;
}).join(''); })
.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) const currentSelectableItems = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE).filter((p) => STATUS_META[p.status]?.selectable !== false);
.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));
} }
@@ -193,33 +203,36 @@ setupLazyLoading();
} }
function setupLazyLoading() { function setupLazyLoading() {
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver(
entries.forEach(entry => { (entries) => {
if (entry.isIntersecting) { entries.forEach((entry) => {
const card = entry.target; if (entry.isIntersecting) {
const productId = card.getAttribute('data-id'); const card = entry.target;
// state.js 등에서 가져온 데이터 활용 const productId = card.getAttribute('data-id');
const product = state.visibleProducts.find(p => p.id === productId); // state.js 등에서 가져온 데이터 활용
const thumb = document.getElementById(`thumb-${productId}`); const product = state.visibleProducts.find((p) => p.id === productId);
const thumb = document.getElementById(`thumb-${productId}`);
if (product && thumb) { if (product && thumb) {
// 1. 첫 번째 이미지 로드 // 1. 첫 번째 이미지 로드
thumb.style.backgroundImage = `url("${product.images[0]}")`; thumb.style.backgroundImage = `url("${product.images[0]}")`;
// 2. 나머지 이미지 프리로드 (반짝임 방지) // 2. 나머지 이미지 프리로드 (반짝임 방지)
if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) { if (!STATUS_META[product.status]?.soldOut && product.images.length > 1) {
product.images.slice(1).forEach(url => { product.images.slice(1).forEach((url) => {
const img = new Image(); const img = new Image();
img.src = url; 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() { export function renderPagination() {

View File

@@ -11,12 +11,12 @@ export const state = {
searchKeyword: '', searchKeyword: '',
viewMode: 'grid', viewMode: 'grid',
selectedIds: new Set(JSON.parse(sessionStorage.getItem('selectedProductIds') || '[]')), selectedIds: new Set(JSON.parse(sessionStorage.getItem('selectedProductIds') || '[]')),
activeTags: new Set([]), // 선택된 태그들을 저장 (비어있으면 전체 노출)
// visible이 true인 상태만 초기 활성 필터로 저장 // visible이 true인 상태만 초기 활성 필터로 저장
activeStatuses: new Set( activeStatuses: new Set(
Object.entries(STATUS_META) Object.entries(STATUS_META)
.filter(([_, meta]) => meta.isSystemVisible && meta.isDefaultActive) .filter(([_, meta]) => meta.isSystemVisible && meta.isDefaultActive)
.map(([status]) => status) .map(([status]) => status),
), ),
}; };

View File

@@ -1 +1,72 @@
@import "tailwindcss"; @import 'tailwindcss';
@theme {
/* 기존 HTML에서 쓰던 이름 그대로 등록 (bg-background-light 등) */
--color-background-light: var(--bg-light);
--color-background-dark: var(--bg-dark);
--breakpoint-xs: 480px;
--color-primary: #137fec;
--font-display: 'Inter', sans-serif;
--radius-xl: 0.75rem;
--aspect-card: 4 / 5;
}
/* 라이트 모드 변수 설정 */
:root {
--bg-light: #f6f7f8;
--bg-dark: #ffffff; /* 헤더 등에 쓰이는 밝은색 */
}
/* 다크 모드일 때 변수값 갈아끼우기 */
.dark {
--bg-light: #101922;
--bg-dark: #101922; /* 다크모드에선 둘 다 어둡게 */
}
/* 모든 버튼에 마우스 포인터(손가락 모양)를 자동으로 적용 */
@layer base {
button,
[type='button'],
[type='reset'],
[type='submit'] {
cursor: pointer;
}
/* 비활성화된(disabled) 버튼은 클릭할 수 없으므로 기본 화살표로 유지 */
button:disabled {
cursor: default;
}
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.modal-open {
overflow: hidden !important;
height: 100% !important;
overscroll-behavior: none !important;
}
}
@layer components {
#modal-main-carousel-container {
scroll-snap-type: none;
overflow-x: hidden;
}
#product-modal {
overscroll-behavior: contain;
}
[id^='thumb-'] {
transition: all 0.6s ease-in-out;
background-color: #f1f5f9;
}
}
/* 2. 핵심: 테일윈드 v4에게 클래스 기반 다크모드임을 명시 */
@custom-variant dark (&:where(.dark, .dark *));

File diff suppressed because it is too large Load Diff