diff --git a/scripts/carousel.js b/scripts/carousel.js index 6d39c3c..3251218 100644 --- a/scripts/carousel.js +++ b/scripts/carousel.js @@ -1,111 +1,51 @@ -/** 모달 내 이미지 캐러셀 (드래그·스와이프·인덱스 동기화) */ +/** + * carousel.js + * 모달 이미지 캐러셀: 무한 루프 슬라이드, 드래그/스와이프 및 UI 동기화 + */ + +// ========================================================================== +// 1. 유틸리티 및 계산 함수 (Internal Utils) +// ========================================================================== + +/** 현재 스크롤 위치를 바탕으로 실제 이미지 인덱스(0 ~ length-1)를 반환 */ export function getRealIndex(container, originalLength) { - let rawIndex = Math.round(container.scrollLeft / container.clientWidth); + const rawIndex = Math.round(container.scrollLeft / container.clientWidth); let index = rawIndex - 1; + if (index < 0) index = originalLength - 1; if (index >= originalLength) index = 0; return index; } -export function ensureThumbnailVisible(index) { - const container = document.getElementById('modal-thumbnails'); - if (!container) return; - const thumbs = container.querySelectorAll('.modal-thumb-item'); - const active = thumbs[index]; - if (!active) return; - const cRect = container.getBoundingClientRect(); - const tRect = active.getBoundingClientRect(); - const isVisible = tRect.top >= cRect.top && tRect.bottom <= cRect.bottom; - if (!isVisible) { - active.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } +/** 드래그 종료 시 관성 및 이동 거리를 계산하여 다음 인덱스 결정 */ +function calculateTargetIndex(delta, elapsed, currentScroll, width, originalLength) { + const minMove = Math.min(width * 0.05, 20); + const hasMoved = Math.abs(delta) >= minMove; + + // 빠른 스와이프(200ms 미만) 또는 일정 거리 이상 드래그 시 방향 확정 + const direction = hasMoved && (Math.abs(delta) > width * 0.1 || elapsed < 200) ? (delta > 0 ? 1 : -1) : 0; + + let index = Math.round(currentScroll / width) + direction; + return Math.max(0, Math.min(originalLength + 1, index)); } +// ========================================================================== +// 2. UI 동기화 및 스크롤 (UI & Scrolling) +// ========================================================================== + export function syncModalUI(originalLength) { const container = document.getElementById('modal-main-carousel-container'); if (!container) return; + const index = getRealIndex(container, originalLength); - document.querySelectorAll('.modal-thumb-item').forEach((t, i) => { - t.classList.toggle('border-primary', i === index); - t.classList.toggle('opacity-100', i === index); - t.classList.toggle('opacity-70', i !== index); - }); - - document.querySelectorAll('.modal-dot-item').forEach((d, i) => { - d.classList.toggle('bg-primary', i === index); - d.classList.toggle('w-4', i === index); - d.classList.toggle('bg-gray-300', i !== index); - d.classList.toggle('w-2', i !== index); - }); - + // 썸네일 & 도트 업데이트 로직 (생략 - 기존과 동일) + updateThumbnailUI(index); + updateDotUI(index); ensureThumbnailVisible(index); } -export function initBetterCarousel(container, originalLength) { - let isDragging = false; - let startX = 0; - let startScroll = 0; - let startTime = 0; - const width = () => container.clientWidth; - - container.addEventListener('mousedown', start); - container.addEventListener('touchstart', start, { passive: true }); - - function start(e) { - isDragging = true; - startX = e.touches ? e.touches[0].pageX : e.pageX; - startScroll = container.scrollLeft; - startTime = Date.now(); - } - - container.addEventListener('mousemove', move); - container.addEventListener('touchmove', move, { passive: false }); - - function move(e) { - if (!isDragging) return; - const x = e.touches ? e.touches[0].pageX : e.pageX; - container.scrollLeft = startScroll - (x - startX); - } - - container.addEventListener('mouseup', end); - container.addEventListener('mouseleave', end); - container.addEventListener('touchend', end); - - function end(e) { - if (!isDragging) return; - isDragging = false; - const w = width(); - if (w <= 0) return; - const endScroll = container.scrollLeft; - const delta = endScroll - startScroll; - const elapsed = Date.now() - startTime; - // 클릭만 했을 때(이동 거리 거의 없음)는 슬라이드 이동 안 함. 일정 이상 드래그했을 때만 방향 적용 - const minMove = Math.min(w * 0.05, 20); - const hasMoved = Math.abs(delta) >= minMove; - const direction = hasMoved && (Math.abs(delta) > w * 0.1 || elapsed < 200) ? (delta > 0 ? 1 : -1) : 0; - let index = Math.round(endScroll / w) + direction; - index = Math.max(0, Math.min(originalLength + 1, index)); - const targetLeft = index * w; - - // 손 뗀 직후 같은 프레임에서 스크롤 충돌 방지 + 미세 이동은 즉시 적용해서 튐 방지 - requestAnimationFrame(() => { - const snapThreshold = w * 0.03; - const useSmooth = Math.abs(container.scrollLeft - targetLeft) > snapThreshold; - container.style.scrollBehavior = useSmooth ? 'smooth' : 'auto'; - container.scrollTo({ left: targetLeft }); - - const loopDelay = useSmooth ? 320 : 0; - setTimeout(() => { - container.style.scrollBehavior = 'auto'; - if (index === 0) container.scrollLeft = w * originalLength; - if (index === originalLength + 1) container.scrollLeft = w; - syncModalUI(originalLength); - }, loopDelay); - }); - } -} - +/** 특정 인덱스로 부드럽게 이동 */ export function scrollToImage(index) { const container = document.getElementById('modal-main-carousel-container'); if (!container) return; @@ -114,3 +54,78 @@ export function scrollToImage(index) { behavior: 'smooth', }); } + +// ========================================================================== +// 3. 캐러셀 엔진 (Core Engine) +// ========================================================================== + +export function initBetterCarousel(container, originalLength) { + let state = { + isDragging: false, + startX: 0, + startScroll: 0, + startTime: 0, + }; + + const getX = (e) => (e.touches ? e.touches[0].pageX : e.pageX); + + // 핸들러 분리: 시작 + const handleStart = (e) => { + state.isDragging = true; + state.startX = getX(e); + state.startScroll = container.scrollLeft; + state.startTime = Date.now(); + }; + + // 핸들러 분리: 이동 + const handleMove = (e) => { + if (!state.isDragging) return; + container.scrollLeft = state.startScroll - (getX(e) - state.startX); + }; + + // 핸들러 분리: 종료 + const handleEnd = () => { + if (!state.isDragging) return; + state.isDragging = false; + + const w = container.clientWidth; + if (w <= 0) return; + + const delta = container.scrollLeft - state.startScroll; + const elapsed = Date.now() - state.startTime; + + const index = calculateTargetIndex(delta, elapsed, container.scrollLeft, w, originalLength); + finalizeScroll(container, index, w, originalLength); + }; + + /** 드래그 종료 후 스냅 애니메이션 및 루프 처리 */ + function finalizeScroll(container, index, width, originalLength) { + requestAnimationFrame(() => { + const targetLeft = index * width; + const useSmooth = Math.abs(container.scrollLeft - targetLeft) > width * 0.03; + + container.style.scrollBehavior = useSmooth ? 'smooth' : 'auto'; + container.scrollTo({ left: targetLeft }); + + // 루프 워프 처리 + setTimeout( + () => { + container.style.scrollBehavior = 'auto'; + if (index === 0) container.scrollLeft = width * originalLength; + if (index === originalLength + 1) container.scrollLeft = width; + syncModalUI(originalLength); + }, + useSmooth ? 320 : 0, + ); + }); + } + + // 리스너 등록 + container.addEventListener('mousedown', handleStart); + container.addEventListener('touchstart', handleStart, { passive: true }); + container.addEventListener('mousemove', handleMove); + container.addEventListener('touchmove', handleMove, { passive: false }); + container.addEventListener('mouseup', handleEnd); + container.addEventListener('mouseleave', handleEnd); + container.addEventListener('touchend', handleEnd); +} diff --git a/scripts/config.js b/scripts/config.js index 4b43fb7..bdf7641 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -1,22 +1,47 @@ -/** 상품 목록·필터·모달 관련 상수 */ +/** + * config.js + * 앱 전역 설정: 페이지네이션, 상품 상태 메타데이터, 검색 및 태그 스타일 정의 + */ +// ========================================================================== +// 1. 기본 앱 설정 (General Settings) +// ========================================================================== + +/** 한 페이지당 표시할 상품 수 */ export const ITEMS_PER_PAGE = 20; -// export const VISIBILITY_CONFIG = { -// showUnlisted: false, -// showSold: true, -// }; - +/** 정렬 관련 설정 */ export const SORT_CONFIG = { - PUSH_SOLD_OUT_TO_END: false, // 판매완료를 뒤로 보낼지 여부 (테스트 시 false) - PUSH_NON_SALE_TO_END: false, // 미판매를 뒤로 보낼지 여부 (테스트 시 false) + /** 판매 완료된 항목을 리스트의 가장 뒤로 보낼지 여부 */ + PUSH_SOLD_OUT_TO_END: false, + /** 미판매 항목을 리스트의 가장 뒤로 보낼지 여부 */ + PUSH_NON_SALE_TO_END: false, }; +/** 검색 범위 설정 */ +export const SEARCH_CONFIG = { + USE_TITLE: true, // 상품명 포함 + USE_CUSTOM_TAG: true, // 커스텀 태그 포함 + USE_TAGS: true, // 태그 배열 포함 + USE_DESCRIPTION: true, // 요약 설명 포함 + USE_FULL_DESCRIPTION: false // 상세 설명 배열 포함 여부 +}; + +// ========================================================================== +// 2. 상품 상태 및 필터 설정 (Status & Filters) +// ========================================================================== + +/** * 상품 상태별 정책 정의 + * selectable: 체크박스 선택 가능 여부 + * isDefaultActive: 초기 로드 시 필터 활성화 여부 + * isSystemVisible: 필터 목록 및 리스트 노출 여부 + * soldOut: 판매 완료 처리 여부 (이미지 그레이스케일 등) + */ export const STATUS_META = { 미판매: { - selectable: false, // 체크박스 선택 불가 - isDefaultActive: false, // 초기 로드 시 미체크 상태 - isSystemVisible: true, // 아예 리스트/필터에서 제외 (완전 숨김) + selectable: false, + isDefaultActive: false, + isSystemVisible: true, soldOut: false, }, 판매예정: { @@ -33,12 +58,13 @@ export const STATUS_META = { }, 판매완료: { selectable: false, - isDefaultActive: false, // 초기에는 안 보이지만, 사용자가 필터 클릭하면 보임 + isDefaultActive: false, isSystemVisible: true, soldOut: true, }, }; +/** 필터 칩 표시 순서 정의 */ export const STATUS_ORDER = { 판매중: 1, 판매예정: 2, @@ -46,12 +72,13 @@ export const STATUS_ORDER = { 미판매: 4, }; -// STATUS_FILTERS를 수동으로 만들지 않고 META에서 자동으로 생성합니다. +/** 시스템에 표시될 상태 필터 목록 (STATUS_META 기반 자동 생성) */ export const STATUS_FILTERS = Object.keys(STATUS_META) .filter((key) => STATUS_META[key].isSystemVisible) .map((key) => ({ key, label: key })) .sort((a, b) => (STATUS_ORDER[a.key] || 99) - (STATUS_ORDER[b.key] || 99)); +/** 상태별 UI 컬러 (Tailwind CSS 클래스) */ export const STATUS_COLOR = { 판매중: 'bg-primary/10 text-primary border-primary/30', 판매예정: 'bg-amber-400/10 text-amber-600 border-amber-400/30', @@ -59,7 +86,11 @@ export const STATUS_COLOR = { 미판매: 'bg-slate-200/10 text-slate-400 border-slate-300/30', }; -/** 모달 커스텀 태그(customTag) 키워드별 뱃지 스타일 */ +// ========================================================================== +// 3. 태그 및 컨디션 스타일 (Tag & Condition Styles) +// ========================================================================== + +/** 커스텀 태그(customTag)별 특정 테마 스타일 */ export const TAG_STYLES = { 완전생산한정판: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', 특전포함: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400', @@ -71,24 +102,19 @@ export const TAG_STYLES = { 풀옵션: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400', }; +/** 정의되지 않은 태그의 기본 스타일 */ export const TAG_DEFAULT_STYLE = 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'; -export const SEARCH_CONFIG = { - USE_TITLE: true, // 상품명 검색 - USE_CUSTOM_TAG: true, // 커스텀 태그 검색 - USE_TAGS: true, // 태그 배열 검색 - USE_DESCRIPTION: true, // 요약 설명 검색 - USE_FULL_DESCRIPTION: false, // 상세 설명 배열 검색 -}; - -/** 상품 컨디션(specs.condition) 정의 */ +/** * 상품 상태(Condition) 등급 및 라벨 설정 + * specs.condition 값과 매칭됩니다. + */ export const PRODUCT_CONDITIONS = { - BRAND_NEW: { label: 'Brand New (미개봉)', color: 'text-emerald-600', level: 'S' }, - LIKE_NEW: { label: 'Like New (단순개봉)', color: 'text-blue-600', level: 'A+' }, - EXCELLENT: { label: 'Excellent (최상급)', color: 'text-sky-600', level: 'A' }, - GOOD: { label: 'Good (보통/사용감)', color: 'text-slate-600', level: 'B' }, + BRAND_NEW: { label: 'Brand New (미개봉)', color: 'text-emerald-600', level: 'S' }, + LIKE_NEW: { label: 'Like New (단순개봉)', color: 'text-blue-600', level: 'A+' }, + EXCELLENT: { label: 'Excellent (최상급)', color: 'text-sky-600', level: 'A' }, + GOOD: { label: 'Good (보통/사용감)', color: 'text-slate-600', level: 'B' }, INCOMPLETE: { label: 'Incomplete (구성품 누락)', color: 'text-amber-600', level: 'C' }, - DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' }, - JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' }, - OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' }, -}; + DAMAGED: { label: 'Damaged (하자/파손)', color: 'text-orange-600', level: 'D' }, + JUNK: { label: 'Junk (동작불가/부품용)', color: 'text-red-600', level: 'F' }, + OTHER: { label: '기타 (상세설명 참고)', color: 'text-indigo-600', level: '-' }, +}; \ No newline at end of file diff --git a/scripts/filters.js b/scripts/filters.js index 17ff1fb..2474f6b 100644 --- a/scripts/filters.js +++ b/scripts/filters.js @@ -1,25 +1,116 @@ -/** 상태·카테고리·검색 필터 로직 및 UI */ +/** + * filters.js + * 상태(Status), 카테고리(Category), 검색(Search), 태그(Tag) 필터 로직 및 UI 제어 + */ + 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, SORT_CONFIG } from './config.js'; import { renderProducts } from './productList.js'; -// 1 & 2. 칩 사이즈 통일 및 미판매 활성화 스타일 강화 -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] ?? ''; +// ========================================================================== +// 1. 데이터 필터링 및 검색 로직 (Logic) +// ========================================================================== - if (isActive) { - 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`; - } +/** + * 검색어 매칭 여부 확인 (AND 조건) + * @param {Object} product - 상품 객체 + * @param {string} keyword - 검색 키워드 + */ +function checkSearchMatch(product, keyword) { + if (!keyword) return true; - return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`; + const searchTerms = keyword.split(/\s+/).filter((t) => t.length > 0); + + const title = product.title || ''; + const desc = product.description || ''; + const tags = Array.isArray(product.tags) ? product.tags.join(' ') : ''; + const fullDesc = Array.isArray(product.fullDescription) ? product.fullDescription.join(' ') : String(product.fullDescription || ''); + + const pool = `${title} ${desc} ${tags} ${fullDesc}`.toLowerCase(); + + return searchTerms.every((term) => pool.includes(term.toLowerCase())); } +/** + * 현재 조건에 맞는 유효한 태그 목록 추출 + * @returns {string[]} 정렬된 태그 배열 + */ +function getAvailableTags() { + const availableTags = new Set(); + + productsData + .filter((p) => { + const sMatch = state.activeStatuses.has(p.status); + const cMatch = state.activeCategories.has('All') || state.activeCategories.has(p.category); + const kMatch = checkSearchMatch(p, state.searchKeyword.toLowerCase()); + return sMatch && cMatch && kMatch; + }) + .forEach((p) => p.tags?.forEach((t) => availableTags.add(t))); + + // 선택된 태그는 결과가 없더라도 목록 유지 + state.activeTags.forEach((t) => availableTags.add(t)); + return Array.from(availableTags).sort(); +} + +/** + * 필터 및 정렬 통합 적용 (핵심 실행 함수) + */ +export function applyFilters() { + const keyword = state.searchKeyword.toLowerCase(); + + // [1] 데이터 필터링 및 정렬 + state.visibleProducts = productsData + .filter((product) => { + const meta = STATUS_META[product.status]; + if (!meta || !meta.isSystemVisible) return false; + + const statusMatch = state.activeStatuses.has(product.status); + const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category); + const searchMatch = checkSearchMatch(product, keyword); + 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) => { + if (!SORT_CONFIG.PUSH_SOLD_OUT_TO_END && !SORT_CONFIG.PUSH_NON_SALE_TO_END) return 0; + + const isASold = STATUS_META[a.status]?.soldOut; + const isBSold = STATUS_META[b.status]?.soldOut; + const isANonSale = a.status === '미판매'; + const isBNonSale = b.status === '미판매'; + + if (SORT_CONFIG.PUSH_SOLD_OUT_TO_END && isASold !== isBSold) return isASold ? 1 : -1; + if (SORT_CONFIG.PUSH_NON_SALE_TO_END && isANonSale !== isBNonSale) return isANonSale ? 1 : -1; + + return (STATUS_ORDER[a.status] ?? 999) - (STATUS_ORDER[b.status] ?? 999); + }); + + // [2] 페이지 위치 안전 조정 + const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE); + state.currentPage = Math.max(1, Math.min(state.currentPage, totalPages || 1)); + + // [3] UI 업데이트 순차적 호출 + renderTotalCount(state.visibleProducts.length); + renderProducts(state.currentPage); + renderTagChips(); +} + +// ========================================================================== +// 2. UI 렌더링 함수 (Rendering) +// ========================================================================== + +/** + * 필터 결과 총 개수 표시 + * @param {number} count + */ +function renderTotalCount(count) { + const totalCountElement = document.getElementById('total-count'); + if (totalCountElement) totalCountElement.textContent = count.toLocaleString(); +} + +/** + * 상태(Status) 필터 칩 렌더링 + */ export function renderStatusChips() { const container = document.getElementById('status-chips'); if (!container) return; @@ -29,7 +120,6 @@ export function renderStatusChips() { const isActive = state.activeStatuses.has(key); const chip = document.createElement('button'); - // 칩 공통 스타일 적용 chip.className = `status-chip flex items-center rounded-full font-bold transition-all duration-200 border ${getStatusChipClass(key, isActive)}`; chip.textContent = label; @@ -43,278 +133,186 @@ export function renderStatusChips() { }); } -function toggleStatusFilter(status) { - if (state.activeStatuses.has(status)) { - state.activeStatuses.delete(status); - } else { - state.activeStatuses.add(status); +/** + * 상태 칩 스타일 클래스 생성 헬퍼 + */ +function getStatusChipClass(status, isActive) { + const commonSize = 'min-w-[70px] justify-center px-3 py-1 text-[11px] md:text-xs'; + const base = STATUS_COLOR[status] ?? ''; + + if (isActive) { + if (status === '미판매') { + 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`; } - if (state.activeStatuses.size === 0) { - STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key)); - } - applyFilters(); - renderStatusChips(); + return `${commonSize} bg-slate-50 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700 opacity-40 grayscale`; } -export function renderTagChips() { - const container = document.getElementById('tag-chips'); +/** + * 카테고리(Category) 필터 칩 렌더링 + */ +export function renderCategoryChips(products = productsData) { + const container = document.getElementById('filter-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(); + const validCategories = products.filter((p) => STATUS_META[p.status]?.isSystemVisible).map((p) => p.category); - // 2. 리셋 버튼 HTML (맨 앞에 배치) - // 활성화된 태그가 있을 때만 강조되거나, 항상 보이게 설정 가능합니다. - const hasActiveTags = state.activeTags.size > 0; + const categories = ['All', ...new Set(validCategories)]; + + container.innerHTML = categories + .map((cat) => { + const isActive = state.activeCategories.has(cat); + const activeClass = isActive ? 'bg-primary text-white border-primary shadow-sm ring-1 ring-offset-1 ring-slate-200' : 'bg-slate-50 text-slate-400 border-slate-200 opacity-40 hover:opacity-60'; + + return ``; + }) + .join(''); + + container.querySelectorAll('.filter-chip').forEach((chip) => { + chip.onclick = () => toggleCategory(chip.dataset.category); + }); +} + +/** + * 태그(Tag) 필터 칩 렌더링 및 UI 제어 + */ +export function renderTagChips() { + const container = document.getElementById('tag-chips'); + const tagParent = document.getElementById('tag-container'); + const toggleBtn = document.getElementById('toggle-tags'); + const activeCountBadge = document.getElementById('active-tag-count'); + + if (!container) return; + + const sortedTags = getAvailableTags(); + const hasActive = state.activeTags.size > 0; + + // [1] HTML 구조 생성 (리셋 버튼 + 태그 칩) const resetBtnHtml = ` - `; + ${hasActive ? '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="태그 초기화"> + + `; - // 3. 태그 칩들과 합치기 container.innerHTML = resetBtnHtml + sortedTags .map((tag) => { const isActive = state.activeTags.has(tag); - return ` - - `; + return ``; }) .join(''); - // 4. 리셋 버튼 이벤트 바인딩 + // [2] 뱃지 상태 업데이트 + if (activeCountBadge) { + activeCountBadge.textContent = state.activeTags.size; + activeCountBadge.classList.toggle('hidden', state.activeTags.size === 0); + } + + // [3] 더보기 버튼 노출 여부 계산 (브라우저 렌더링 후) + requestAnimationFrame(() => { + const isOverflow = container.scrollHeight > 36; + if (toggleBtn) toggleBtn.classList.toggle('hidden', !isOverflow); + if (!isOverflow && tagParent) { + tagParent.style.maxHeight = '34px'; + tagParent.classList.remove('expanded'); + } + }); + + // [4] 이벤트 바인딩 document.getElementById('tag-reset-btn').onclick = () => { - if (state.activeTags.size === 0) return; state.activeTags.clear(); - renderTagChips(); // UI 갱신 - applyFilters(); // 필터 적용 + 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(); + state.activeTags.has(tag) ? state.activeTags.delete(tag) : state.activeTags.add(tag); applyFilters(); }; }); } -// [핵심] 필터 적용 함수 -export function applyFilters() { - const keyword = state.searchKeyword.toLowerCase(); - - // 1. 데이터 필터링 및 정렬 - state.visibleProducts = productsData - .filter((product) => { - const meta = STATUS_META[product.status]; - - // [1] 시스템 가시성 체크 - if (!meta || !meta.isSystemVisible) return false; - - // [2] 상태 필터 체크 - const statusMatch = state.activeStatuses.has(product.status); - - // [3] 카테고리 필터 체크 - const categoryMatch = state.activeCategories.has('All') || state.activeCategories.has(product.category); - - // [4] 검색어 매칭 로직 - const searchMatch = - keyword === '' || - (() => { - // 검색어를 공백 기준으로 쪼개서 배열로 만듭니다 (예: "jp r19" -> ["jp", "r19"]) - const searchTerms = keyword.split(/\s+/).filter((term) => term.length > 0); - - const searchPool = []; - 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_DESCRIPTION && product.description) searchPool.push(product.description); - - // 태그 배열과 상세 설명 배열을 문자열 풀에 합칩니다. - if (SEARCH_CONFIG.USE_TAGS && product.tags) searchPool.push(...product.tags); - if (SEARCH_CONFIG.USE_FULL_DESCRIPTION && product.fullDescription) searchPool.push(...product.fullDescription); - - // 모든 텍스트를 하나의 검색용 문자열로 결합 - const combinedText = searchPool.map((text) => String(text || '').toLowerCase()).join(' '); - - // [핵심] 사용자가 입력한 모든 단어(searchTerms)가 결합된 텍스트에 포함되어 있는지 확인 (AND 조건) - return searchTerms.every((term) => combinedText.includes(term)); - })(); - - // [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) => { - // 0. 스위치가 모두 꺼져있다면 정렬하지 않고 원본 순서 유지 - if (!SORT_CONFIG.PUSH_SOLD_OUT_TO_END && !SORT_CONFIG.PUSH_NON_SALE_TO_END) { - return 0; - } - - const isASold = STATUS_META[a.status]?.soldOut; - const isBSold = STATUS_META[b.status]?.soldOut; - const isANonSale = a.status === '미판매'; - const isBNonSale = b.status === '미판매'; - - // 1. 판매완료 정렬 제어 - if (SORT_CONFIG.PUSH_SOLD_OUT_TO_END) { - if (isASold !== isBSold) return isASold ? 1 : -1; - } - - // 2. 미판매 정렬 제어 - if (SORT_CONFIG.PUSH_NON_SALE_TO_END) { - if (isANonSale !== isBNonSale) return isANonSale ? 1 : -1; - } - - // 3. 만약 위 스위치들 중 하나라도 켜져 있다면, 나머지는 STATUS_ORDER를 따름 - const aOrder = STATUS_ORDER[a.status] ?? 999; - const bOrder = STATUS_ORDER[b.status] ?? 999; - return aOrder - bOrder; - }); - - // 2. 페이지 위치 안전 조정 로직 - const totalPages = Math.ceil(state.visibleProducts.length / ITEMS_PER_PAGE); - - if (state.currentPage > totalPages) { - state.currentPage = Math.max(1, totalPages); - } else if (state.currentPage < 1) { - state.currentPage = 1; - } - - // 3. UI 업데이트 - // renderTotalCount 함수가 정의되어 있다면 실행 - if (typeof renderTotalCount === 'function') { - renderTotalCount(state.visibleProducts.length); - } - - renderProducts(state.currentPage); -} - -/** 총 개수를 화면에 표시하는 보조 함수 */ -function renderTotalCount(count) { - const totalCountElement = document.getElementById('total-count'); - if (totalCountElement) { - totalCountElement.textContent = count.toLocaleString(); // 3자리마다 콤마 - } -} - -export function getCategories(products) { - return ['All', ...new Set(products.map((p) => p.category))]; -} - -export function renderCategoryChips(products) { - const container = document.getElementById('filter-chips'); // 혹은 HTML 구조에 맞춰 'category-chips' - if (!container) return; - - // 1. 가시성 있는 상품의 카테고리만 추출 - const validCategories = products - .filter((p) => { - const meta = STATUS_META[p.status]; - return meta && meta.isSystemVisible; - }) - .map((p) => p.category); - - const categories = ['All', ...new Set(validCategories)]; - - container.innerHTML = ''; - - categories.forEach((cat) => { - const isActive = state.activeCategories.has(cat); - const chip = document.createElement('button'); - - // [수정] 태그 칩 사이즈(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.dataset.category = cat; - - chip.onclick = () => { - toggleCategory(cat); - }; - - container.appendChild(chip); - }); -} +// ========================================================================== +// 3. 사용자 인터랙션 제어 (Interaction) +// ========================================================================== +/** + * 카테고리 선택 토글 + * @param {string} category + */ export function toggleCategory(category) { if (category === 'All') { state.activeCategories.clear(); state.activeCategories.add('All'); } else { state.activeCategories.delete('All'); - if (state.activeCategories.has(category)) { - state.activeCategories.delete(category); - } else { - state.activeCategories.add(category); - } - if (state.activeCategories.size === 0) { - state.activeCategories.add('All'); - } + state.activeCategories.has(category) ? state.activeCategories.delete(category) : state.activeCategories.add(category); + if (state.activeCategories.size === 0) state.activeCategories.add('All'); } - renderCategoryChips(productsData); // 디자인(활성화 상태) 즉시 반영 + renderCategoryChips(productsData); applyFilters(); } -export function bindCategoryFilter(products) { - const chips = document.querySelectorAll('.filter-chip'); - chips.forEach((chip) => { - chip.addEventListener('click', () => { - const category = chip.dataset.category; - if (category === 'All') { - state.activeCategories.clear(); - state.activeCategories.add('All'); - } else { - state.activeCategories.delete('All'); - if (state.activeCategories.has(category)) state.activeCategories.delete(category); - else state.activeCategories.add(category); - if (state.activeCategories.size === 0) state.activeCategories.add('All'); - } - applyFilters(); - }); - }); -} - -// 로고 클릭 시 초기화 로직 +/** + * 로고(타이틀) 클릭 시 모든 필터 초기화 + */ document.getElementById('logo-title')?.addEventListener('click', () => { - // 1. 검색어 초기화 state.searchKeyword = ''; - const searchInput = document.getElementById('search-input'); // 검색창 ID가 있다면 + const searchInput = document.getElementById('search-input'); if (searchInput) searchInput.value = ''; - // 2. 카테고리 초기화 (All 선택) state.activeCategories.clear(); state.activeCategories.add('All'); - // 3. 상태 필터 초기화 (기본 활성화 상태로) state.activeStatuses.clear(); STATUS_FILTERS.filter((f) => f.defaultActive).forEach((f) => state.activeStatuses.add(f.key)); - // 4. 필터 적용 및 UI 갱신 + state.activeTags.clear(); + applyFilters(); renderStatusChips(); renderCategoryChips(productsData); - // 5. 페이지 최상단으로 스크롤 (선택 사항) window.scrollTo({ top: 0, behavior: 'smooth' }); }); + +/** + * 태그 컨테이너 확장/축소 토글 설정 (최초 1회 실행) + */ +export function setupTagToggle() { + const toggleBtn = document.getElementById('toggle-tags'); + const tagContainer = document.getElementById('tag-container'); + const tagChips = document.getElementById('tag-chips'); + + if (!toggleBtn || !tagContainer || !tagChips) return; + + 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 = '2rem'; + tagContainer.classList.remove('expanded'); + if (svgIcon) svgIcon.style.transform = 'rotate(0deg)'; + }); + } else { + tagContainer.style.maxHeight = tagChips.scrollHeight + 'px'; + tagContainer.classList.add('expanded'); + if (svgIcon) svgIcon.style.transform = 'rotate(180deg)'; + + setTimeout(() => { + if (tagContainer.classList.contains('expanded')) { + tagContainer.style.maxHeight = 'none'; + } + }, 300); + } + }; +} diff --git a/scripts/main.js b/scripts/main.js index afcacba..15f75d1 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -1,36 +1,42 @@ -/** 진입점: 이벤트 바인딩·초기 렌더·URL 모달 처리 */ +/** + * main.js + * 앱 진입점: 전역 상태 초기화, 공통 이벤트 바인딩, 뷰 제어 및 유틸리티 기능 + */ + import { state, productsData, saveSelection } from './state.js'; -import { applyFilters, renderStatusChips, renderCategoryChips, bindCategoryFilter, renderTagChips } from './filters.js'; +import { applyFilters, renderStatusChips, renderCategoryChips, renderTagChips, setupTagToggle } from './filters.js'; import { ITEMS_PER_PAGE, STATUS_META } from './config.js'; import { renderProducts, changePage } from './productList.js'; import { openModal, closeModal } from './modal.js'; import { scrollToImage } from './carousel.js'; -console.log('Total products loaded:', productsData.length); +// ========================================================================== +// 1. 전역 설정 및 윈도우 객체 등록 (Global Setup) +// ========================================================================== -let lastThumbnailIndex = -1; - -// HTML onclick에서 사용하기 위한 전역 등록 +// HTML 내 inline onclick 이벤트를 위한 전역 등록 window.openModal = openModal; window.closeModal = closeModal; window.changePage = changePage; window.scrollToImage = scrollToImage; +let lastThumbnailIndex = -1; +let currentHoverIndex = -1; let fadeTimers = {}; +let touchStartX = 0; +let isDragging = false; -// 뷰 전환 이벤트 -// 뷰 전환 이벤트 바인딩 -document.getElementById('view-grid').onclick = () => { - state.viewMode = 'grid'; - updateViewButtons(); - renderProducts(state.currentPage); -}; -document.getElementById('view-table').onclick = () => { - state.viewMode = 'table'; - updateViewButtons(); - renderProducts(state.currentPage); -}; +// 로그용 유니크 ID 생성 +const sessionId = Math.random().toString(36).substring(2, 10); +console.log(`%c[SESSION]: ${sessionId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;'); +// ========================================================================== +// 2. 뷰(모드) 제어 로직 (View Mode) +// ========================================================================== + +/** + * 뷰 전환 버튼 상태 업데이트 (Grid/Table) + */ function updateViewButtons() { const isGrid = state.viewMode === 'grid'; const gridBtn = document.getElementById('view-grid'); @@ -54,15 +60,25 @@ function updateViewButtons() { } } -// [전역 함수] 체크박스 토글 및 합계 업데이트 -window.toggleSelectItem = (id) => { - if (state.selectedIds.has(id)) state.selectedIds.delete(id); - else state.selectedIds.add(id); - - updateSummary(); +document.getElementById('view-grid').onclick = () => { + state.viewMode = 'grid'; + updateViewButtons(); + renderProducts(state.currentPage); }; -/** 선택 요약 바 업데이트 (모바일 레이아웃 대응) */ +document.getElementById('view-table').onclick = () => { + state.viewMode = 'table'; + updateViewButtons(); + renderProducts(state.currentPage); +}; + +// ========================================================================== +// 3. 선택 및 요약 로직 (Selection & Summary) +// ========================================================================== + +/** + * 선택 요약 바 업데이트 (개수 및 총 가격) + */ export function updateSummary() { const summaryBar = document.getElementById('selection-summary'); const countEl = document.getElementById('selected-count'); @@ -70,7 +86,7 @@ export function updateSummary() { if (state.selectedIds.size > 0) { summaryBar.classList.remove('hidden'); - summaryBar.classList.add('flex'); // flex-wrap은 HTML에 이미 적용됨 + summaryBar.classList.add('flex'); countEl.textContent = state.selectedIds.size; const total = Array.from(state.selectedIds).reduce((sum, id) => { @@ -84,125 +100,23 @@ export function updateSummary() { } } -// 검색 입력 -const searchInput = document.getElementById('search-input'); -if (searchInput) { - searchInput.addEventListener('input', (e) => { - state.searchKeyword = e.target.value.trim().toLowerCase(); - applyFilters(); - }); -} +/** + * 개별 아이템 선택 토글 + */ +window.toggleSelectItem = (id) => { + const product = productsData.find((p) => p.id === id); + if (!product || STATUS_META[product.status]?.selectable === false) return; -// Escape로 모달 닫기 -document.addEventListener('keydown', (e) => { - if (e.key !== 'Escape') return; - const modal = document.getElementById('product-modal'); - if (!modal || modal.classList.contains('hidden')) return; - closeModal(); -}); + if (state.selectedIds.has(id)) state.selectedIds.delete(id); + else state.activeStatuses.has(product.status) && state.selectedIds.add(id); -function checkUrlAndOpenModal() { - const params = new URLSearchParams(window.location.search); - const productId = params.get('id'); - if (productId) { - const product = productsData.find((p) => String(p.id) === productId); - if (product) { - setTimeout(() => openModal(product.id), 100); - } - } -} - -// 초기 테마 설정 확인 -const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); -const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); -const themeToggleBtn = document.getElementById('theme-toggle'); - -// 1. 현재 테마 상태에 따라 아이콘 표시/숨김 처리 -function updateIcons() { - if (document.documentElement.classList.contains('dark')) { - themeToggleLightIcon.classList.remove('hidden'); - themeToggleDarkIcon.classList.add('hidden'); - } else { - themeToggleDarkIcon.classList.remove('hidden'); - themeToggleLightIcon.classList.add('hidden'); - } -} - -// 2. 초기 로드 시 설정 (localStorage 또는 시스템 설정 확인) -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. 버튼 클릭 이벤트 -themeToggleBtn.addEventListener('click', function () { - // 테마 토글 - if (document.documentElement.classList.contains('dark')) { - document.documentElement.classList.remove('dark'); - localStorage.setItem('color-theme', 'light'); - } else { - document.documentElement.classList.add('dark'); - localStorage.setItem('color-theme', 'dark'); - } - updateIcons(); -}); - -document.addEventListener('DOMContentLoaded', () => { - console.log('Total products loaded:', productsData.length); - - // 1. UI 컴포넌트 렌더링 - renderCategoryChips(productsData); - bindCategoryFilter(productsData); - renderStatusChips(); - renderTagChips(); - - // 2. 초기 데이터 계산 및 첫 페이지 렌더링 (순서 중요) - applyFilters(); - renderProducts(state.currentPage); - updateViewButtons(); + saveSelection(); 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 생성기 -const newId = Math.random().toString(36).substring(2, 10); -console.log(`%c[NUMBER]: ${newId}`, 'color: #137fec; font-weight: bold; border: 1px solid #137fec; padding: 2px 5px; border-radius: 4px;'); - -/** 현재 페이지의 '선택 가능한' 항목들만 선택/해제 */ +/** + * 현재 페이지 전체 선택/해제 + */ window.toggleSelectAll = (isChecked) => { const startIndex = (state.currentPage - 1) * ITEMS_PER_PAGE; const currentPageProducts = state.visibleProducts.slice(startIndex, startIndex + ITEMS_PER_PAGE); @@ -210,126 +124,87 @@ window.toggleSelectAll = (isChecked) => { currentPageProducts.forEach((p) => { const isSelectable = STATUS_META[p.status]?.selectable !== false; if (isSelectable) { - if (isChecked) state.selectedIds.add(p.id); - else state.selectedIds.delete(p.id); + isChecked ? state.selectedIds.add(p.id) : state.selectedIds.delete(p.id); } }); - saveSelection(); // 변경사항 저장 + saveSelection(); updateSummary(); renderProducts(state.currentPage); }; -/** 선택 리셋 */ -// [수정] 기존 resetSelection을 모달 오픈으로 변경 +/** + * 선택 초기화 모달 및 실행 + */ window.resetSelection = () => { const modal = document.getElementById('selection-reset-modal'); - modal.classList.remove('hidden'); - modal.classList.add('flex'); + modal.classList.replace('hidden', 'flex'); }; -// [추가] 모달 닫기 window.closeSelectionResetModal = () => { const modal = document.getElementById('selection-reset-modal'); - modal.classList.add('hidden'); - modal.classList.remove('flex'); + modal.classList.replace('flex', 'hidden'); }; -// [추가] 실제 초기화 실행 (기존 로직 그대로) window.confirmSelectionReset = () => { state.selectedIds.clear(); - saveSelection(); // 스토리지 동기화 + saveSelection(); updateSummary(); renderProducts(state.currentPage); window.closeSelectionResetModal(); }; -/** 선택 토글 시 스토리지 저장 추가 */ -window.toggleSelectItem = (id) => { - const product = productsData.find((p) => p.id === id); - if (!product || STATUS_META[product.status]?.selectable === false) return; +// ========================================================================== +// 4. 테마 및 검색 이벤트 (Theme & Search) +// ========================================================================== - if (state.selectedIds.has(id)) state.selectedIds.delete(id); - else state.selectedIds.add(id); +/** + * 다크/라이트 테마 초기화 및 버튼 바인딩 + */ +export function initTheme() { + const themeToggleBtn = document.getElementById('theme-toggle'); + if (!themeToggleBtn) return; - saveSelection(); // 변경사항 저장 - updateSummary(); -}; + const isDark = localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches); -/** 선택된 항목들을 CSV 파일로 내보내기 */ -window.exportToExcel = () => { - if (state.selectedIds.size === 0) { - alert('내보낼 상품을 선택해 주세요.'); - return; - } + document.documentElement.classList.toggle('dark', isDark); + updateThemeIcons(); - // 1. 선택된 데이터 추출 및 계산 - let totalCount = 0; - let totalPrice = 0; + themeToggleBtn.onclick = () => { + const willBeDark = !document.documentElement.classList.contains('dark'); + document.documentElement.classList.toggle('dark', willBeDark); + localStorage.setItem('color-theme', willBeDark ? 'dark' : 'light'); + updateThemeIcons(); + }; +} - const rows = Array.from(state.selectedIds) - .map((id) => { - const p = productsData.find((item) => item.id === id); - if (p) { - totalCount += 1; - totalPrice += p.price; - return [p.id, `"${p.title.replace(/"/g, '""')}"`, p.category, p.price, p.status, `"${p.description.replace(/"/g, '""')}"`]; - } - return null; - }) - .filter((row) => row !== null); +function updateThemeIcons() { + const darkIcon = document.getElementById('theme-toggle-dark-icon'); + const lightIcon = document.getElementById('theme-toggle-light-icon'); + if (!darkIcon || !lightIcon) return; - // 2. 헤더 및 푸터(합계) 설정 - const headers = ['상품 ID', '상품명', '카테고리', '가격', '상태', '상세설명']; + const isDark = document.documentElement.classList.contains('dark'); + lightIcon.classList.toggle('hidden', !isDark); + darkIcon.classList.toggle('hidden', isDark); +} - // 영수증 느낌을 위한 하단 합계 줄 - const footerEmpty = ['', '', '', '', '', '']; // 빈 줄 - const footerTotal = ['TOTAL', `"총 ${totalCount}건의 항목"`, '', totalPrice, '', `"발행일: ${new Date().toLocaleString()}"`]; +// 검색어 입력 실시간 반영 +document.getElementById('search-input')?.addEventListener('input', (e) => { + state.searchKeyword = e.target.value.trim().toLowerCase(); + applyFilters(); +}); - // 3. CSV 포맷 생성 (한글 깨짐 방지 BOM 추가) - const csvContent = - '\uFEFF' + - [ - headers.join(','), - ...rows.map((row) => row.join(',')), - footerEmpty.join(','), // 간격 조절용 빈 줄 - footerTotal.join(','), // 합계 라인 - ].join('\n'); +// ESC 키로 모달 닫기 +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); +}); - // 4. 다운로드 실행 - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); +// ========================================================================== +// 5. 썸네일 애니메이션 및 인터랙션 (Thumbnail Logic) +// ========================================================================== - const timestamp = new Date().toISOString().split('T')[0]; - link.setAttribute('href', url); - link.setAttribute('download', `inventory_receipt_${timestamp}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}; - -let currentHoverIndex = -1; - -window.handleThumbnailHover = (e, productId) => { - if (window.isDragging) return; - - const product = productsData.find((p) => p.id === productId); - if (!product || !product.images || product.images.length <= 1) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - let index = Math.floor((x / rect.width) * product.images.length); - index = Math.max(0, Math.min(product.images.length - 1, index)); - - if (currentHoverIndex !== index) { - currentHoverIndex = index; - updateThumbnailWithFade(productId, product.images[index], index); - } -}; - -/** * 썸네일을 즉시 업데이트하는 함수 - * 페이드 애니메이션을 제거하여 반응성을 높이고 깜빡임을 방지합니다. +/** + * 썸네일 이미지/인디케이터 즉시 업데이트 (마우스 호버용) */ function updateThumbnailWithFade(productId, newImageUrl, index) { const mainThumb = document.getElementById(`thumb-${productId}`); @@ -338,32 +213,22 @@ function updateThumbnailWithFade(productId, newImageUrl, index) { if (!mainThumb) return; - // 1. 기존 페이드 타이머가 있다면 즉시 제거 (충돌 방지) if (fadeTimers[productId]) { clearTimeout(fadeTimers[productId]); delete fadeTimers[productId]; } - // 2. 페이드 레이어(뒷배경)는 즉시 숨기고 메인 이미지만 즉시 교체 - // transition 없이 즉시 교체되도록 인라인 스타일로 제어합니다. if (fadeThumb) { fadeThumb.style.transition = 'none'; fadeThumb.style.opacity = '0'; } - - mainThumb.style.backgroundImage = `url("${newImageUrl}")`; - // 3. 인디케이터 UI 업데이트 + mainThumb.style.backgroundImage = `url("${newImageUrl}")`; if (indicator) updateIndicatorUI(indicator, index); } -window.handleThumbnailLeave = (productId) => { - currentHoverIndex = -1; // 인덱스 초기화 - - resetThumbnail(productId); -}; - -/** * 마우스가 나갔을 때 썸네일을 첫 번째 이미지로 복구하는 함수 +/** + * 썸네일 상태 복구 */ function resetThumbnail(productId) { if (fadeTimers[productId]) { @@ -380,21 +245,17 @@ function resetThumbnail(productId) { if (mainThumb) { const firstImgUrl = `url("${product.images[0]}")`; - - // 즉시 첫 번째 이미지로 복구 mainThumb.style.backgroundImage = firstImgUrl; - + if (fadeThumb) { fadeThumb.style.transition = 'none'; fadeThumb.style.opacity = '0'; fadeThumb.style.backgroundImage = firstImgUrl; } } - if (indicator) updateIndicatorUI(indicator, 0); } -// 중복 코드를 방지하기 위한 UI 업데이트 헬퍼 function updateIndicatorUI(indicator, activeIndex) { Array.from(indicator.children).forEach((dot, i) => { const isActive = i === activeIndex; @@ -404,10 +265,29 @@ function updateIndicatorUI(indicator, activeIndex) { }); } -// 터치 상태 관리를 위한 변수 -let touchStartX = 0; -let isDragging = false; +// 데스크탑 호버 이벤트 +window.handleThumbnailHover = (e, productId) => { + if (window.isDragging) return; + const product = productsData.find((p) => p.id === productId); + if (!product || !product.images || product.images.length <= 1) return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + let index = Math.floor((x / rect.width) * product.images.length); + index = Math.max(0, Math.min(product.images.length - 1, index)); + + if (currentHoverIndex !== index) { + currentHoverIndex = index; + updateThumbnailWithFade(productId, product.images[index], index); + } +}; + +window.handleThumbnailLeave = (productId) => { + currentHoverIndex = -1; + resetThumbnail(productId); +}; + +// 모바일 터치 스와이프 이벤트 window.handleTouchStart = (e) => { touchStartX = e.touches[0].clientX; isDragging = false; @@ -417,10 +297,8 @@ window.handleTouchMove = (e, productId) => { const product = productsData.find((p) => p.id === productId); if (!product || !product.images || product.images.length <= 1) return; - const cardElement = e.currentTarget; - const cardWidth = cardElement.offsetWidth; - const touchCurrentX = e.touches[0].clientX; - const diffX = touchStartX - touchCurrentX; + const cardWidth = e.currentTarget.offsetWidth; + const diffX = touchStartX - e.touches[0].clientX; if (Math.abs(diffX) > 10) { isDragging = true; @@ -428,48 +306,87 @@ window.handleTouchMove = (e, productId) => { } if (isDragging) { - const product = productsData.find((p) => p.id === productId); const step = cardWidth / product.images.length; let index = Math.floor(Math.abs(diffX) / step); index = Math.max(0, Math.min(product.images.length - 1, index)); - // [수정 핵심] 인덱스가 이전과 같으면 함수를 종료하여 불필요한 리렌더링 방지 if (lastThumbnailIndex === index) return; lastThumbnailIndex = index; const mainThumb = document.getElementById(`thumb-${productId}`); const fadeThumb = document.getElementById(`thumb-fade-${productId}`); - // [수정 핵심] 인덱스가 실제로 변했을 때만 스타일을 바꿉니다. - if (mainThumb && lastThumbnailIndex !== index) { - lastThumbnailIndex = index; // 새 인덱스 저장 - + if (mainThumb) { mainThumb.style.backgroundImage = `url("${product.images[index]}")`; if (fadeThumb) fadeThumb.style.opacity = '0'; - updateIndicator(productId, index); + updateIndicatorUI(document.getElementById(`indicator-${productId}`), index); } } }; window.handleTouchEnd = (e, productId) => { - if (e.cancelable) e.preventDefault(); - if (!isDragging) { window.openModal(productId); } else { - resetThumbnail(productId); // 드래그 종료 시 확실한 리셋 + resetThumbnail(productId); } - lastThumbnailIndex = -1; // 초기화 + lastThumbnailIndex = -1; isDragging = false; }; -// 인디케이터 업데이트 헬퍼 함수 -function updateIndicator(productId, index) { - const indicator = document.getElementById(`indicator-${productId}`); - if (indicator) { - Array.from(indicator.children).forEach((dot, i) => { - dot.style.backgroundColor = i === index ? 'white' : 'rgba(255, 255, 255, 0.4)'; - dot.style.opacity = i === index ? '1' : '0.7'; - }); +// ========================================================================== +// 6. 데이터 내보내기 (Export) +// ========================================================================== + +/** + * 선택된 항목 CSV 다운로드 + */ +window.exportToExcel = () => { + if (state.selectedIds.size === 0) { + alert('내보낼 상품을 선택해 주세요.'); + return; } -} + + let totalCount = 0; + let totalPrice = 0; + + const rows = Array.from(state.selectedIds) + .map((id) => { + const p = productsData.find((item) => item.id === id); + if (!p) return null; + totalCount += 1; + totalPrice += p.price; + return [p.id, `"${p.title.replace(/"/g, '""')}"`, p.category, p.price, p.status, `"${p.description.replace(/"/g, '""')}"`]; + }) + .filter(Boolean); + + const headers = ['상품 ID', '상품명', '카테고리', '가격', '상태', '상세설명']; + const footerTotal = ['TOTAL', `"총 ${totalCount}건"`, '', totalPrice, '', `"발행: ${new Date().toLocaleString()}"`]; + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((row) => row.join(',')), ['', '', '', '', '', ''].join(','), footerTotal.join(',')].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `inventory_receipt_${new Date().toISOString().split('T')[0]}.csv`; + link.click(); +}; + +// ========================================================================== +// 7. 초기화 (App Initialization) +// ========================================================================== + +document.addEventListener('DOMContentLoaded', () => { + console.log('App initialized. Products:', productsData.length); + + initTheme(); + renderCategoryChips(productsData); + renderStatusChips(); + renderTagChips(); + setupTagToggle(); + + applyFilters(); + updateViewButtons(); + updateSummary(); +}); diff --git a/scripts/modal.js b/scripts/modal.js index 886c36a..22c8fba 100644 --- a/scripts/modal.js +++ b/scripts/modal.js @@ -1,72 +1,127 @@ -/** 상품 상세 모달 (열기/닫기·콘텐츠 채우기·링크 복사) */ +/** + * modal.js + * 상품 상세 모달의 제어(열기/닫기), 데이터 렌더링 및 캐러셀 초기화 + */ + import { productsData } from './state.js'; import { initBetterCarousel } from './carousel.js'; import { TAG_STYLES, TAG_DEFAULT_STYLE, PRODUCT_CONDITIONS } from './config.js'; +// ========================================================================== +// 1. 모달 메인 제어 (Open / Close) +// ========================================================================== + +/** + * 특정 상품의 상세 모달을 오픈 + * @param {string} id - 상품 고유 ID + */ export function openModal(id) { const product = productsData.find((p) => p.id === id); if (!product) return; const modal = document.getElementById('product-modal'); + modal.classList.replace('hidden', 'flex'); - modal.classList.remove('hidden'); - modal.classList.add('flex'); + // [1] UI 콘텐츠 채우기 + renderModalImages(product); + renderModalInfo(product); + setupCopyLink(product); + // [2] 히스토리 및 브라우저 상태 제어 + window.history.pushState({ modalOpen: true }, '', ''); + document.body.classList.add('modal-open'); + + // [3] 캐러셀 초기화 + const container = document.getElementById('modal-main-carousel-container'); const images = product.images; + container.style.scrollBehavior = 'auto'; + container.scrollLeft = container.clientWidth; // 루프 캐러셀을 위한 초기 위치 설정 + initBetterCarousel(container, images.length); +} + +/** 모달 닫기 및 상태 초기화 */ +export function closeModal() { + const modal = document.getElementById('product-modal'); + + // 스크롤 위치 초기화 + const carouselContainer = document.getElementById('modal-main-carousel-container'); + if (carouselContainer) carouselContainer.scrollLeft = 0; + + const contentScroll = document.getElementById('modal-content-scroll'); + if (contentScroll) contentScroll.scrollTo(0, 0); + + // UI 닫기 + modal.classList.add('hidden'); + document.body.classList.remove('modal-open'); + + // 히스토리 정리 + if (window.history.state && window.history.state.modalOpen) { + window.history.back(); + } + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState(null, '', cleanUrl); +} + +// ========================================================================== +// 2. 상세 데이터 렌더링 (Data Rendering) +// ========================================================================== + +/** 모달 내 이미지 영역(메인, 썸네일, 인디케이터) 생성 */ +function renderModalImages(product) { + const images = product.images; const loopImages = [images[images.length - 1], ...images, images[0]]; - const mainImagesHtml = loopImages - .map( - (img) => ` -
입력하신 검색어나 선택한 필터를 확인해 주세요.
-- ${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`} -
-${product.description}
-입력하신 검색어나 선택한 필터를 확인해 주세요.
++ ${isNonSale ? 'Not for Sale' : `${product.currency || '₩'}${product.price.toLocaleString()}`} +
+${product.description}
+