/**
* 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 ' ';
}
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();
}
})();