/** * YS Project - 추천 스타일 메인 (/styles/) JavaScript * * 기본 진입 화면(최신 작품 / 최신 작품 출연 배우 / 인기 배우)은 PHP 가 SSR 하므로 여기서 건드리지 않는다. * 이 파일은 검색·필터 결과만 api/styles/search.php 로 가져와 렌더한다. * * 카드 마크업은 styles/index.php 의 stylesRenderWorkCard() / stylesRenderActorCard() 와 1:1로 같아야 한다. * 사용자 데이터는 innerHTML 에 직접 넣지 않고 텍스트용(escapeHTML) / 속성용(escapeAttr) 이스케이프를 구분해 적용한다. * (장르명은 자유 입력 태그이고 업체 API 로도 들어오므로 XSS 위험이 실재한다) * * ── 필터 v2 (기획서 §9.1 / §9.2 / §9.4) ── * ① 장르: 대표 장르만 기본 노출 + 펼치기/접기 + 실시간 텍스트 필터(클라이언트, 서버 왕복 없음) * ② 작품 필터: 하드코딩이 아니라 PHP 가 내려준 data-param-* 속성을 읽어 GET 파라미터명을 결정한다 * ③ 모바일(<600px): 필터를 하단 시트로 전환. **같은 DOM 노드를 옮겨 넣는다** — 마크업을 두 벌 * 만들면 두 모드의 입력값이 갈라지므로, 시트/인라인이 언제나 하나의 state 를 공유하게 한다. */ (function () { 'use strict'; var API_URL = '/api/styles/search.php'; var PER_PAGE = 12; /** 하단 시트로 전환하는 경계. .partnership-modal 이 중앙 모달로 바뀌는 지점(600px)과 같다. */ var SHEET_BREAKPOINT = '(max-width: 599.98px)'; // 주소창 갱신에 location.pathname 을 되읽으면 '//styles/' 같은 변형 진입 시 // replaceState 가 교차 출처('http://styles/')로 판정해 SecurityError 를 던지고, // 그 호출이 runSearch() 의 try 밖이라 fetch 자체가 죽는다 — 검색이 스피너에서 영구 정지(감사 재현). // 이 스크립트는 /styles/ 에서만 로드되므로 고정 리터럴이 항상 옳다. var PAGE_PATH = '/styles/'; var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]),' + ' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; var SORT_OPTIONS = { all: [['latest', '등록 최신순'], ['release', '출시일순'], ['popularity', '인기순']], work: [['latest', '등록 최신순'], ['release', '출시일순']], actor: [['latest', '등록순'], ['popularity', '인기도순']] }; var state = { tab: 'all', keyword: '', genres: [], /** 작품 필터: { GET 파라미터명: 값 }. 항목 구성이 관리자 설정에 따라 바뀌므로 고정 키를 두지 않는다. */ workFilters: {}, sort: 'latest', page: 1, totalPages: 1, isLoading: false, searched: false }; var $ = function (sel) { return document.querySelector(sel); }; var dom = { keyword: $('#stylesKeyword'), searchBtn: $('#stylesSearchBtn'), sort: $('#stylesSort'), tabs: document.querySelectorAll('.styles-tabs__btn'), workFilters: $('#stylesWorkFilters'), actorFilters: $('#stylesActorFilters'), genres: $('#stylesGenres'), genreFilter: $('#stylesGenreFilter'), genreToggle: $('#stylesGenreToggle'), genreSearch: $('#stylesGenreSearch'), genreSearchEmpty: $('#stylesGenreSearchEmpty'), applyBtn: $('#stylesApplyBtn'), resetBtn: $('#stylesResetBtn'), filterHost: $('#stylesFilterHost'), filterForms: $('#stylesFilterForms'), filterTrigger: $('#stylesFilterTrigger'), filterCount: $('#stylesFilterCount'), sheet: $('#stylesFilterSheet'), sheetOverlay: $('#stylesSheetOverlay'), sheetBody: $('#stylesSheetBody'), sheetClose: $('#stylesSheetClose'), applied: $('#stylesApplied'), appliedList: $('#stylesAppliedList'), home: $('#stylesHome'), moreWorksBtn: $('#stylesMoreWorksBtn'), moreActorsBtn: $('#stylesMoreActorsBtn'), results: $('#stylesResults'), workSection: $('#stylesWorkSection'), workGrid: $('#stylesWorkGrid'), workCount: $('#stylesWorkCount'), actorSection: $('#stylesActorSection'), actorGrid: $('#stylesActorGrid'), actorCount: $('#stylesActorCount'), loading: $('#stylesLoading'), empty: $('#stylesEmpty'), pagination: $('#stylesPagination') }; /** 작품 필터 필드 그룹 — { key, label, inputs: [{ param, role, el }] } */ var workGroups = []; /** 배우 특징 필드 그룹 — { key, label, inputs: [{ kind, el }] } */ var attrGroups = []; var sheetQuery = window.matchMedia(SHEET_BREAKPOINT); var sheetLastFocus = null; // ============================================================ // Utility // ============================================================ /** 텍스트 노드용 이스케이프 */ function escapeHTML(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; } /** * 속성값용 이스케이프. * textContent 기반 escapeHTML 은 따옴표를 escape 하지 않아 속성에 쓰면 값을 탈출할 수 있다. */ function escapeAttr(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 이미지 src 안전성 판정 — **공용 구현에 위임**한다. * 규칙 원본: assets/js/style-url-guard.js (+ 서버 includes/style_helpers.php). * 여기에 규칙을 다시 적지 않는 이유: 3벌로 복제하면 반드시 갈라지고, * 갈라진 쪽이 실사용 경로가 되어 구멍이 된다(실제로 그렇게 한 번 뚫렸다). * 가드 파일이 안 실려도 이미지가 안 뜰 뿐 안전한 쪽으로 실패한다(fail-closed). */ function safeUrl(url) { return (typeof window.ysSafeImageUrl === 'function') ? window.ysSafeImageUrl(url) : ''; } function trimValue(el) { if (!el) return ''; return String(el.value == null ? '' : el.value).trim(); } async function fetchJSON(url) { var resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }); var data = await resp.json(); if (!resp.ok || !data.success) { throw new Error(data.error || 'Request failed'); } return data; } var toastTimeout = null; function showToast(message) { var toast = document.querySelector('.toast'); if (!toast) { toast = document.createElement('div'); toast.className = 'toast'; document.body.appendChild(toast); } toast.textContent = message; toast.classList.add('toast--visible'); if (toastTimeout) clearTimeout(toastTimeout); toastTimeout = setTimeout(function () { toast.classList.remove('toast--visible'); }, 2500); } // ============================================================ // Card rendering (styles/index.php 의 SSR 마크업과 동일하게 유지할 것) // ============================================================ function buildImageBlock(url, alt, placeholderEntity) { var src = safeUrl(url); if (src === '') { return '
' + placeholderEntity + '
'; } return '' + escapeAttr(alt) + ''; } function buildMetaItem(value) { if (!value) return ''; return '' + escapeHTML(value) + ''; } function buildWorkCard(work) { var href = '/styles/work.php?code=' + encodeURIComponent(work.code || ''); var tags = ''; if (Array.isArray(work.genres) && work.genres.length > 0) { tags = '
' + work.genres.map(function (g) { return '' + escapeHTML(g) + ''; }).join('') + '
'; } return '' + '
' + buildImageBlock(work.cover_image_url, work.title_ko, '🎬') + '
' + '
' + '

' + escapeHTML(work.title_ko) + '

' + '
' + buildMetaItem(work.title_original) + buildMetaItem(work.release_date) + buildMetaItem(work.series_name) + '
' + tags + '
' + '
'; } function buildActorCard(actor) { var href = '/styles/actor.php?code=' + encodeURIComponent(actor.code || ''); var meta = ''; if (actor.name_original) { meta = '
' + buildMetaItem(actor.name_original) + '
'; } return '' + '
' + buildImageBlock(actor.profile_image_url, actor.name_ko, '👤') + '
' + '
' + '

' + escapeHTML(actor.name_ko) + '

' + meta + '
' + '
'; } // ============================================================ // 필터 필드 수집 (PHP 가 렌더한 것만이 존재한다 — JS 는 항목을 발명하지 않는다) // ============================================================ /** * 작품 필터 필드를 DOM 에서 읽어 그룹으로 만든다. * * GET 파라미터명은 data-param-from / data-param-to / data-param-eq 로 내려온다. * (styleWorkFilterWhitelist() 의 params 맵 그대로 — JS 는 이름을 하드코딩하지 않는다. * release_date=year_from/year_to 같은 v1 파라미터명이 여기로 흘러들어와 링크 호환이 유지된다) */ function buildWorkGroups() { var groups = []; if (!dom.workFilters) return groups; var wraps = dom.workFilters.querySelectorAll('[data-field-key]'); for (var i = 0; i < wraps.length; i++) { var wrap = wraps[i]; var group = { key: wrap.getAttribute('data-field-key') || '', label: wrap.getAttribute('data-field-label') || '', inputs: [] }; ['from', 'to', 'eq'].forEach(function (role) { var param = wrap.getAttribute('data-param-' + role); if (!param) return; var input = wrap.querySelector('[data-input-role="' + role + '"]'); if (!input) return; group.inputs.push({ param: param, role: role, el: input }); }); if (group.inputs.length > 0) groups.push(group); } return groups; } /** * 배우 특징 필터 그룹. * 마크업(현행 유지)을 건드리지 않고 라벨은 같은 필드 안의 .styles-filter-panel__label 에서 읽는다. */ function buildAttrGroups() { var groups = []; if (!dom.actorFilters) return groups; var byKey = {}; var nodes = dom.actorFilters.querySelectorAll('[data-attr-key]'); for (var i = 0; i < nodes.length; i++) { var el = nodes[i]; var key = el.getAttribute('data-attr-key'); if (!key) continue; if (!Object.prototype.hasOwnProperty.call(byKey, key)) { var field = el.closest('.styles-filter-panel__field'); var labelEl = field ? field.querySelector('.styles-filter-panel__label') : null; byKey[key] = { key: key, label: labelEl ? String(labelEl.textContent || '').trim() : key, inputs: [] }; groups.push(byKey[key]); } byKey[key].inputs.push({ kind: el.getAttribute('data-attr-kind') || 'eq', el: el }); } return groups; } /** 배우 특징 항목 입력값 수집. 필터 항목 자체는 서버가 SSR 한 것만 존재한다. */ function collectAttrs() { var result = { eq: {}, min: {}, max: {} }; attrGroups.forEach(function (group) { group.inputs.forEach(function (item) { var value = trimValue(item.el); if (value === '') return; if (item.kind === 'min') { result.min[group.key] = value; } else if (item.kind === 'max') { result.max[group.key] = value; } else { result.eq[group.key] = value; } }); }); return result; } function syncStateFromInputs() { state.keyword = dom.keyword ? dom.keyword.value.trim() : ''; state.sort = dom.sort ? dom.sort.value : 'latest'; state.workFilters = {}; workGroups.forEach(function (group) { group.inputs.forEach(function (item) { var value = trimValue(item.el); if (value !== '') state.workFilters[item.param] = value; }); }); } function buildParams() { var params = new URLSearchParams(); params.set('type', state.tab); params.set('sort', state.sort); params.set('page', String(state.page)); params.set('per_page', String(PER_PAGE)); if (state.keyword) params.set('q', state.keyword); if (state.genres.length > 0) params.set('genre', state.genres.join(',')); Object.keys(state.workFilters).forEach(function (name) { params.set(name, state.workFilters[name]); }); var attrs = collectAttrs(); Object.keys(attrs.eq).forEach(function (k) { params.set('attr[' + k + ']', attrs.eq[k]); }); Object.keys(attrs.min).forEach(function (k) { params.set('attr_min[' + k + ']', attrs.min[k]); }); Object.keys(attrs.max).forEach(function (k) { params.set('attr_max[' + k + ']', attrs.max[k]); }); return params; } // ============================================================ // 적용 중인 조건 칩 (모바일 시트 밖 상단 — 기획서 §9.4) // ============================================================ /** "2020 ~ 2026" / "2020 이후" / "2026 이전" 처럼 범위를 사람이 읽는 문구로 */ function formatRange(from, to, fromSuffix, toSuffix) { if (from !== '' && to !== '') return from + ' ~ ' + to; if (from !== '') return from + ' ' + fromSuffix; return to + ' ' + toSuffix; } function buildAppliedChips() { var chips = []; state.genres.forEach(function (name) { chips.push({ text: '장르: ' + name, kind: 'genre', value: name }); }); workGroups.forEach(function (group) { var from = '', to = '', eq = ''; group.inputs.forEach(function (item) { var value = trimValue(item.el); if (item.role === 'from') from = value; else if (item.role === 'to') to = value; else eq = value; }); if (eq !== '') { chips.push({ text: group.label + ': ' + eq, kind: 'work', value: group.key }); } else if (from !== '' || to !== '') { chips.push({ text: group.label + ': ' + formatRange(from, to, '이후', '이전'), kind: 'work', value: group.key }); } }); attrGroups.forEach(function (group) { var min = '', max = '', eq = ''; group.inputs.forEach(function (item) { var value = trimValue(item.el); if (item.kind === 'min') min = value; else if (item.kind === 'max') max = value; else eq = value; }); if (eq !== '') { chips.push({ text: group.label + ': ' + eq, kind: 'attr', value: group.key }); } else if (min !== '' || max !== '') { chips.push({ text: group.label + ': ' + formatRange(min, max, '이상', '이하'), kind: 'attr', value: group.key }); } }); return chips; } function renderAppliedChips() { var chips = buildAppliedChips(); if (dom.filterCount) { dom.filterCount.textContent = String(chips.length); dom.filterCount.style.display = chips.length > 0 ? '' : 'none'; } if (!dom.applied || !dom.appliedList) return; if (chips.length === 0) { dom.appliedList.innerHTML = ''; dom.applied.style.display = 'none'; return; } dom.appliedList.innerHTML = chips.map(function (chip) { return '' + '' + escapeHTML(chip.text) + '' + '' + ''; }).join(''); // display:'' 로 되돌린다. 데스크톱에서 숨기는 것은 CSS 미디어쿼리의 몫이므로 // 여기서 'flex' 같은 구체적인 값을 넣으면 인라인 스타일이 미디어쿼리를 이겨 버린다. dom.applied.style.display = ''; } function removeChip(kind, value) { if (kind === 'genre') { var index = state.genres.indexOf(value); if (index !== -1) state.genres.splice(index, 1); if (dom.genres) { var btns = dom.genres.querySelectorAll('.styles-genre'); for (var i = 0; i < btns.length; i++) { if (btns[i].getAttribute('data-genre') === value) { applyGenreButtonState(btns[i], false); } } } return; } var groups = (kind === 'work') ? workGroups : attrGroups; groups.forEach(function (group) { if (group.key !== value) return; group.inputs.forEach(function (item) { item.el.value = ''; }); }); } // ============================================================ // Search // ============================================================ async function runSearch(resetPage) { if (state.isLoading) return; if (resetPage) state.page = 1; syncStateFromInputs(); renderAppliedChips(); state.searched = true; state.isLoading = true; if (dom.home) dom.home.style.display = 'none'; dom.results.style.display = 'block'; dom.workSection.style.display = 'none'; dom.actorSection.style.display = 'none'; dom.empty.style.display = 'none'; dom.pagination.style.display = 'none'; dom.pagination.innerHTML = ''; dom.loading.style.display = 'block'; var params = buildParams(); updateAddressBar(params); try { var resp = await fetchJSON(API_URL + '?' + params.toString()); var data = resp.data; dom.loading.style.display = 'none'; state.page = data.page; state.totalPages = data.total_pages; var works = Array.isArray(data.works) ? data.works : []; var actors = Array.isArray(data.actors) ? data.actors : []; if (works.length === 0 && actors.length === 0) { dom.empty.style.display = 'block'; return; } if (works.length > 0) { dom.workGrid.innerHTML = works.map(buildWorkCard).join(''); dom.workCount.textContent = '총 ' + data.works_total + '건'; dom.workSection.style.display = 'block'; } else { dom.workGrid.innerHTML = ''; } if (actors.length > 0) { dom.actorGrid.innerHTML = actors.map(buildActorCard).join(''); dom.actorCount.textContent = '총 ' + data.actors_total + '건'; dom.actorSection.style.display = 'block'; } else { dom.actorGrid.innerHTML = ''; } renderPagination(); } catch (err) { dom.loading.style.display = 'none'; dom.empty.style.display = 'block'; showToast(err && err.message ? err.message : '검색 중 오류가 발생했습니다.'); } finally { state.isLoading = false; } } /** 시트의 '적용' / 인라인의 '검색' 공통 진입점 */ function applyFilters() { closeSheet(true); runSearch(true); } // ============================================================ // Pagination (기존 board-pagination 스타일 재사용) // ============================================================ function renderPagination() { var container = dom.pagination; container.innerHTML = ''; if (state.totalPages <= 1) { container.style.display = 'none'; return; } container.style.display = ''; container.appendChild(createPageBtn('«', state.page - 1, state.page <= 1)); var startPage = Math.max(1, state.page - 2); var endPage = Math.min(state.totalPages, state.page + 2); if (endPage - startPage < 4) { if (startPage === 1) { endPage = Math.min(state.totalPages, 5); } else { startPage = Math.max(1, endPage - 4); } } for (var i = startPage; i <= endPage; i++) { var btn = createPageBtn(String(i), i, false); if (i === state.page) btn.classList.add('board-pagination__btn--active'); container.appendChild(btn); } container.appendChild(createPageBtn('»', state.page + 1, state.page >= state.totalPages)); } function createPageBtn(label, page, disabled) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'board-pagination__btn'; btn.textContent = label; btn.disabled = !!disabled; if (!disabled) { btn.addEventListener('click', function () { state.page = page; runSearch(false); window.scrollTo({ top: 0, behavior: 'smooth' }); }); } return btn; } // ============================================================ // Tabs / filters UI // ============================================================ function setTab(tab, rerender) { state.tab = tab; for (var i = 0; i < dom.tabs.length; i++) { var isActive = dom.tabs[i].getAttribute('data-tab') === tab; dom.tabs[i].classList.toggle('styles-tabs__btn--active', isActive); dom.tabs[i].setAttribute('aria-selected', isActive ? 'true' : 'false'); } if (dom.workFilters) { dom.workFilters.style.display = (tab === 'actor') ? 'none' : ''; } if (dom.actorFilters) { dom.actorFilters.style.display = (tab === 'work') ? 'none' : ''; } rebuildSortOptions(tab); if (rerender) runSearch(true); } /** 탭에 해당하는 정렬만 노출한다 (예: 인기순은 배우에만 의미가 있다) */ function rebuildSortOptions(tab) { if (!dom.sort) return; var options = SORT_OPTIONS[tab] || SORT_OPTIONS.all; var previous = dom.sort.value; dom.sort.innerHTML = ''; var stillValid = false; options.forEach(function (pair) { var opt = document.createElement('option'); opt.value = pair[0]; opt.textContent = pair[1]; dom.sort.appendChild(opt); if (pair[0] === previous) stillValid = true; }); dom.sort.value = stillValid ? previous : 'latest'; state.sort = dom.sort.value; } function toggleGenre(btn) { var genre = btn.getAttribute('data-genre'); if (!genre) return; var index = state.genres.indexOf(genre); var selected = index === -1; if (selected) { state.genres.push(genre); } else { state.genres.splice(index, 1); } applyGenreButtonState(btn, selected); } function applyGenreButtonState(btn, selected) { btn.classList.toggle('styles-genre--active', selected); btn.setAttribute('aria-pressed', selected ? 'true' : 'false'); } // ── 장르 펼치기/접기 + 실시간 텍스트 필터 (기획서 §9.1) ── // 접힌 상태에서 대표 장르만 남기는 것은 CSS 가 처리한다 // (.styles-genre-filter:not(--expanded) .styles-genre--extra:not(--active)). // '선택된 비대표 장르는 접혀도 보인다'는 규칙이 그 :not(--active) 한 조각이다. function setGenreExpanded(expanded) { if (!dom.genreFilter) return; dom.genreFilter.classList.toggle('styles-genre-filter--expanded', expanded); if (dom.genreToggle) { dom.genreToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } if (!expanded) { // 접을 때 텍스트 필터를 비운다 — 다시 펼쳤을 때 목록이 걸러진 채로 남아 있으면 혼란스럽다. if (dom.genreSearch) dom.genreSearch.value = ''; filterGenreList(''); } } /** 서버 왕복 없이 이미 렌더된 칩을 걸러낸다. */ function filterGenreList(raw) { if (!dom.genres) return; var query = String(raw == null ? '' : raw).trim().toLowerCase(); var btns = dom.genres.querySelectorAll('.styles-genre'); var visible = 0; for (var i = 0; i < btns.length; i++) { var name = String(btns[i].getAttribute('data-genre') || '').toLowerCase(); var hit = (query === '' || name.indexOf(query) !== -1); btns[i].classList.toggle('styles-genre--hidden', !hit); if (hit) visible++; } if (dom.genreSearchEmpty) { dom.genreSearchEmpty.style.display = (query !== '' && visible === 0) ? '' : 'none'; } } function resetFilters() { if (dom.keyword) dom.keyword.value = ''; workGroups.forEach(function (group) { group.inputs.forEach(function (item) { item.el.value = ''; }); }); attrGroups.forEach(function (group) { group.inputs.forEach(function (item) { item.el.value = ''; }); }); state.genres = []; if (dom.genres) { var genreBtns = dom.genres.querySelectorAll('.styles-genre'); for (var i = 0; i < genreBtns.length; i++) { applyGenreButtonState(genreBtns[i], false); } } setGenreExpanded(false); state.workFilters = {}; state.page = 1; state.searched = false; setTab('all', false); renderAppliedChips(); dom.results.style.display = 'none'; if (dom.home) dom.home.style.display = ''; window.history.replaceState(null, '', PAGE_PATH); } // ============================================================ // 모바일 하단 시트 (기획서 §9.4) // 기존 제휴문의 모달(includes/_header.php)의 개폐 패턴을 따르되, // 포커스 트랩 · 포커스 복귀 · 스크롤 전파 차단을 더한다. // ============================================================ function isSheetMode() { return sheetQuery.matches; } function sheetIsOpen() { return !!dom.sheet && dom.sheet.classList.contains('styles-sheet--open'); } /** * 뷰포트에 맞춰 필터 폼의 '자리'만 바꾼다. 노드를 옮기므로 입력값·이벤트 리스너가 그대로 따라간다. * (리스너를 #stylesGenres / #stylesFilterForms 같은 컨테이너에 위임해 둔 이유이기도 하다) */ function applyLayoutMode() { if (!dom.filterForms) return; if (isSheetMode()) { if (dom.sheetBody && dom.filterForms.parentNode !== dom.sheetBody) { dom.sheetBody.appendChild(dom.filterForms); } } else { closeSheet(false); if (dom.filterHost && dom.filterForms.parentNode !== dom.filterHost) { dom.filterHost.appendChild(dom.filterForms); } } } /** * 시트 안에서 실제로 포커스를 받을 수 있는 요소들. * getClientRects() 만으로는 부족하다 — visibility:hidden 은 레이아웃 박스를 남기므로 * 사각형이 잡히지만 focus() 는 조용히 무시된다. visibility 까지 봐야 한다. */ function focusablesInSheet() { if (!dom.sheet) return []; return Array.prototype.filter.call( dom.sheet.querySelectorAll(FOCUSABLE), function (el) { return el.getClientRects().length > 0 && window.getComputedStyle(el).visibility !== 'hidden'; } ); } function onSheetKeydown(e) { if (!sheetIsOpen()) return; if (e.key === 'Escape') { e.preventDefault(); closeSheet(true); return; } if (e.key !== 'Tab') return; // 포커스 트랩 — 시트 밖으로 탭이 새어 나가지 않게 양 끝에서 되돌린다. var nodes = focusablesInSheet(); if (nodes.length === 0) { e.preventDefault(); return; } var first = nodes[0]; var last = nodes[nodes.length - 1]; if (!dom.sheet.contains(document.activeElement)) { e.preventDefault(); first.focus(); return; } if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } function openSheet() { // CSS(min-width:600px)와 JS(max-width:599.98px)의 경계가 소수 뷰포트·줌에서 어긋나면 // 폼은 host 에 있는데 트리거는 보이는 상태가 되어 **빈 시트**가 열린다. 여기서 끊는다. if (!isSheetMode()) return; if (!dom.sheet || sheetIsOpen()) return; sheetLastFocus = document.activeElement; dom.sheet.classList.add('styles-sheet--open'); if (dom.sheetOverlay) dom.sheetOverlay.classList.add('styles-sheet-overlay--visible'); if (dom.filterTrigger) dom.filterTrigger.setAttribute('aria-expanded', 'true'); // 배경 스크롤 잠금. 제휴문의 모달은 body 인라인 스타일을 쓰지만 여기서는 클래스로 둔다 // — 두 모달이 서로의 인라인 값을 지워 버리는 사고를 원천 차단한다. document.body.classList.add('styles-sheet-lock'); document.addEventListener('keydown', onSheetKeydown); // 클래스를 붙인 직후에는 아직 스타일이 반영되지 않아 focus() 가 먹지 않을 수 있다. // (visibility:hidden 인 동안의 focus() 는 무시된다) → 다음 프레임에 옮긴다. window.requestAnimationFrame(function () { if (!sheetIsOpen()) return; var nodes = focusablesInSheet(); if (nodes.length > 0) nodes[0].focus(); }); } /** @param {boolean} returnFocus 닫은 뒤 트리거 버튼으로 포커스를 되돌릴지 (뷰포트 전환 때는 false) */ function closeSheet(returnFocus) { if (!dom.sheet) return; var wasOpen = sheetIsOpen(); dom.sheet.classList.remove('styles-sheet--open'); if (dom.sheetOverlay) dom.sheetOverlay.classList.remove('styles-sheet-overlay--visible'); if (dom.filterTrigger) dom.filterTrigger.setAttribute('aria-expanded', 'false'); document.body.classList.remove('styles-sheet-lock'); document.removeEventListener('keydown', onSheetKeydown); if (wasOpen && returnFocus !== false) { // 기획서 §9.4: 닫은 뒤 트리거 버튼으로 포커스 복귀. // 열기 직전 포커스(sheetLastFocus)는 폴백일 뿐이다 — 탭이 아닌 터치로 열면 // 그 값이 라서 복귀시켜도 포커스가 사실상 사라진다. var target = (dom.filterTrigger && dom.filterTrigger.getClientRects().length > 0) ? dom.filterTrigger : (sheetLastFocus && document.contains(sheetLastFocus) ? sheetLastFocus : null); if (target) target.focus(); } sheetLastFocus = null; } // ============================================================ // Address bar sync (결과 공유 / 새로고침 유지) // ============================================================ function updateAddressBar(params) { var shareable = new URLSearchParams(params); shareable.delete('per_page'); if (shareable.get('page') === '1') shareable.delete('page'); var qs = shareable.toString(); window.history.replaceState(null, '', PAGE_PATH + (qs ? '?' + qs : '')); } /** * URL 파라미터로 진입한 경우(예: 작품 상세의 장르 태그 클릭) 입력값을 채우고 바로 검색한다. * 작품 필터는 DOM 에 실제로 렌더된 항목의 파라미터명만 되돌린다 — 관리자가 항목을 내리면 * 그 조건이 담긴 옛 링크는 조용히 무시된다(서버도 같은 정의로 판단하므로 결과가 어긋나지 않는다). * @returns {boolean} 검색을 실행해야 하면 true */ function restoreFromAddressBar() { var params = new URLSearchParams(window.location.search); if (Array.from(params.keys()).length === 0) return false; var tab = params.get('type'); setTab(tab === 'work' || tab === 'actor' ? tab : 'all', false); if (dom.keyword) dom.keyword.value = params.get('q') || ''; workGroups.forEach(function (group) { group.inputs.forEach(function (item) { var value = params.get(item.param); if (value !== null) item.el.value = value; }); }); var sort = params.get('sort'); if (sort && dom.sort) { for (var s = 0; s < dom.sort.options.length; s++) { if (dom.sort.options[s].value === sort) { dom.sort.value = sort; break; } } } var genreParam = params.get('genre'); if (genreParam && dom.genres) { var wanted = genreParam.split(',').map(function (g) { return g.trim(); }) .filter(function (g) { return g !== ''; }); var genreBtns = dom.genres.querySelectorAll('.styles-genre'); for (var i = 0; i < genreBtns.length; i++) { var name = genreBtns[i].getAttribute('data-genre'); if (wanted.indexOf(name) !== -1) { state.genres.push(name); applyGenreButtonState(genreBtns[i], true); } } } // attr[..] / attr_min[..] / attr_max[..] → 해당 입력창에 되돌려 놓는다 attrGroups.forEach(function (group) { group.inputs.forEach(function (item) { var key = item.kind === 'min' ? 'attr_min' : (item.kind === 'max' ? 'attr_max' : 'attr'); var value = params.get(key + '[' + group.key + ']'); if (value !== null) item.el.value = value; }); }); var page = parseInt(params.get('page'), 10); state.page = (page && page > 0) ? page : 1; return true; } // ============================================================ // Events // ============================================================ function bindEvents() { if (dom.searchBtn) { dom.searchBtn.addEventListener('click', function () { runSearch(true); }); } if (dom.keyword) { dom.keyword.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); runSearch(true); } }); } if (dom.applyBtn) { dom.applyBtn.addEventListener('click', applyFilters); } if (dom.resetBtn) { // 시트는 닫지 않는다 — 초기화 직후 다른 조건을 이어서 고르는 흐름이 자연스럽다. dom.resetBtn.addEventListener('click', resetFilters); } if (dom.sort) { dom.sort.addEventListener('change', function () { runSearch(true); }); } for (var i = 0; i < dom.tabs.length; i++) { dom.tabs[i].addEventListener('click', function (e) { var tab = e.currentTarget.getAttribute('data-tab'); if (!tab || tab === state.tab) return; setTab(tab, state.searched); }); } if (dom.genres) { dom.genres.addEventListener('click', function (e) { var btn = e.target.closest('.styles-genre'); if (!btn) return; toggleGenre(btn); // 시트 모드에서는 '적용' 을 눌러야 검색한다 (조작 중 목록이 계속 갱신되지 않게). if (!isSheetMode()) runSearch(true); }); } if (dom.genreToggle) { dom.genreToggle.addEventListener('click', function () { setGenreExpanded(!dom.genreFilter.classList.contains('styles-genre-filter--expanded')); }); } if (dom.genreSearch) { dom.genreSearch.addEventListener('input', function () { filterGenreList(dom.genreSearch.value); }); } // 필터 입력창에서 Enter = 적용 (장르 좁히기 입력창은 목록만 거르므로 제외) if (dom.filterForms) { dom.filterForms.addEventListener('keydown', function (e) { if (e.key !== 'Enter') return; var el = e.target; if (!el || el.tagName !== 'INPUT' || el === dom.genreSearch) return; e.preventDefault(); applyFilters(); }); } if (dom.filterTrigger) { dom.filterTrigger.addEventListener('click', openSheet); } if (dom.sheetClose) { dom.sheetClose.addEventListener('click', function () { closeSheet(true); }); } if (dom.sheetOverlay) { dom.sheetOverlay.addEventListener('click', function () { closeSheet(true); }); } if (dom.appliedList) { dom.appliedList.addEventListener('click', function (e) { var btn = e.target.closest('.styles-applied__remove'); if (!btn) return; removeChip(btn.getAttribute('data-chip-kind'), btn.getAttribute('data-chip-value')); runSearch(true); }); } if (sheetQuery.addEventListener) { sheetQuery.addEventListener('change', applyLayoutMode); } else if (sheetQuery.addListener) { sheetQuery.addListener(applyLayoutMode); // Safari 13 이하 } if (dom.moreWorksBtn) { dom.moreWorksBtn.addEventListener('click', function () { setTab('work', false); runSearch(true); }); } if (dom.moreActorsBtn) { dom.moreActorsBtn.addEventListener('click', function () { setTab('actor', false); runSearch(true); }); } } // ============================================================ // Init // ============================================================ function init() { if (!dom.results) return; // 권한 게이트로 페이지가 바뀐 경우 등 workGroups = buildWorkGroups(); attrGroups = buildAttrGroups(); bindEvents(); applyLayoutMode(); setTab('all', false); if (restoreFromAddressBar()) { runSearch(false); } } // 자리 이동만은 DOMContentLoaded 를 기다리지 않는다. // 이 스크립트는 직전이라 대상 노드가 이미 존재하고, 기다리면 모바일에서 // 인라인 패널이 한 프레임 보였다가 시트로 빨려 들어가는 깜빡임이 생긴다. applyLayoutMode(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();