sketchbook5_MH 게시판 스킨에 분류를 드릴다운 스타일(단계별 하위분류) 추가 하는 방법


skin.xml "카테고리 메뉴 스타일" 에 추가
<var name="cnb" type="radio">
<title xml:lang="ko">카테고리 메뉴 스타일</title>
<title xml:lang="en">Category Menu Style</title>
<description xml:lang="ko">'표시 안함' 게시판 설정의 '게시판 정보' 메뉴의 '분류 사용' 항목과 관계없이 표시하지 않습니다. '박스 스타일'은 2차 카테고리를 지원하지 않습니다.</description>
<description xml:lang="en">'Hide' will not display regardless of the 'Use Category' item in the 'Board Info' menu of board settings. 'Box style' does not support secondary categories.</description>
<options value="">
<title xml:lang="ko">그라데이션 스타일(기본)</title>
<title xml:lang="en">Gradation Style (Default)</title>
</options>
<options value="cTab">
<title xml:lang="ko">탭 스타일</title>
<title xml:lang="en">Tab Style</title>
</options>
<options value="cnb3">
<title xml:lang="ko">박스 스타일</title>
<title xml:lang="en">Box Style</title>
</options>
<options value="cnb4">
<title xml:lang="ko">박스+테두리 스타일</title>
<title xml:lang="en">Box+Border Style</title>
</options>
<options value="cDrill">
<title xml:lang="ko">드릴다운 스타일(단계별 하위분류)</title>
<title xml:lang="en">Drilldown Style (Step-by-step Subcategory)</title>
</options>
<options value="N">
<title xml:lang="ko">표시 안함</title>
<title xml:lang="en">Hide</title>
</options>
</var>
<var name="cnb_drill_preview" type="radio">
<title xml:lang="ko"> └ 드릴다운 1차 분류 미리보기</title>
<title xml:lang="en"> └ Drilldown Level-1 Hover Preview</title>
<description xml:lang="ko">'드릴다운 스타일'에서 1차 분류에 마우스를 올렸을 때 하위 분류를 미리 보여줍니다.</description>
<description xml:lang="en">In 'Drilldown Style', shows a preview of subcategories when hovering over a level-1 category.</description>
<options value="Y">
<title xml:lang="ko">사용(기본)</title>
<title xml:lang="en">Use (Default)</title>
</options>
<options value="">
<title xml:lang="ko">사용 안함</title>
<title xml:lang="en">Disable</title>
</options>
</var>
list.html 에 추가
<!--// 카테고리 -->
<div class="cnb_n_list">
~ 생략 ~
<block cond="$mi->cnb=='cDrill'">
{@
$mh_cnb_flat = array();
foreach($category_list as $mh_ck=>$mh_cv):
$mh_cnb_flat[] = array(
'srl' => (int)$mh_cv->category_srl,
'parent' => (int)$mh_cv->parent_srl,
'title' => $mh_cv->title,
'count' => (int)$mh_cv->document_count,
'color' => $mh_cv->color
);
endforeach;
$mh_cnb_json = json_encode($mh_cnb_flat, JSON_HEX_TAG|JSON_UNESCAPED_UNICODE);
}
<div class="mh_cnb_drill<!--@if(!$mi->select_lst)--> mh_cnb_has_type_selector<!--@end-->" data-current_category="{$category}" data-total_count="{$total_count}" data-preview="{$mi->cnb_drill_preview}" data-show_count="{$mi->cnb_count}" data-list_url_tpl="{getUrl('category','__MH_CATEGORY__','page','','document_srl','')}">
<script type="application/json" class="mh_cnb_data">{$mh_cnb_json}</script>
<div class="mh_cnb_levels"></div>
</div>
</block>
</div>
<!--// 게시판 유형 선택 -->
board.css 에 추가
/* Drill Style */
.mh_cnb_drill{margin-bottom:15px}
.mh_cnb_levels{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.mh_cnb_has_type_selector .mh_cnb_levels{padding-right:100px}
.mh_cnb_levels a.mh_cnb_flat{display:inline-block;margin:0;padding:7px 16px;border:1px solid #e2e2e2;border-radius:20px;background:#fff;box-shadow:none;color:#555;text-decoration:none;font-size:13px;font-weight:normal;line-height:normal;transition:background .15s,border-color .15s}
.mh_cnb_levels a.mh_cnb_flat:hover{background:#f5f5f5}
.mh_cnb_levels a.mh_cnb_flat.on{background:#555;border-color:#d7e2ee;color:#fff;font-weight:bold}
.mh_cnb_levels a.mh_cnb_reset{display:inline-block;margin:0;padding:7px 16px;border:1px solid #e2e2e2;border-radius:20px;background:#555;box-shadow:none;color:#fff;text-decoration:none;font-size:13px;font-weight:normal;line-height:normal;transition:background .15s,border-color .15s}
.mh_cnb_levels a.mh_cnb_reset:hover{background:#eee;box-shadow:none;color:#444;text-decoration:none;}
.mh_cnb_levels .mh_cnb_arrow{color:#bbb;font-size:13px}
.mh_cnb_levels select{padding:7px 10px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#333;font-size:13px;min-width:130px;max-width:220px}
.mh_cnb_levels select:focus{outline:none;border-color:#999}
.mh_cnb_flat_wrap{position:relative;display:inline-block}
.mh_cnb_preview{display:none;position:absolute;bottom:100%;left:0;margin-bottom:8px;background:#fff;border:1px solid #eee;border-radius:6px;box-shadow:0 2px 10px rgba(0,0,0,.1);padding:8px 14px 5px;white-space:nowrap;z-index:50}
.mh_cnb_preview::after{content:'';position:absolute;left:0;right:0;bottom:-8px;height:8px;background:transparent}
.mh_cnb_flat_wrap:hover .mh_cnb_preview{display:flex;gap:14px}
.mh_cnb_preview a{color:#333;font-size:12px;text-decoration:none;padding:0;border:0;background:none;font-weight:normal}
.mh_cnb_preview a:hover{color:#0070f3;text-decoration:none}
mh_category_drill.js 파일 생성해서 js 폴더에 업로드
/*
mh_category_drill.js
------------------------------------------------------------
sketchbook5_MH 게시판 스킨용 - 카테고리 드릴다운 내비게이션
list.html 에서 $mi->cnb=='cDrill' 일 때 렌더링되는 .mh_cnb_drill 영역을 만든다.
*/
(function($){
function mhCnbDrillInit(){
var $wraps = $('.mh_cnb_drill');
if(!$wraps.length) return;
$wraps.each(function(){
var $wrap = $(this);
if($wrap.data('mh_drill_bound')) return; // 중복 초기화 방지
$wrap.data('mh_drill_bound', true);
var $dataTag = $wrap.find('script.mh_cnb_data');
var flat = [];
try{
flat = JSON.parse($dataTag.text() || '[]');
}catch(e){
flat = [];
}
var bySrl = {};
var childrenOf = {};
var i, node, p;
for(i=0; i<flat.length; i++){
node = flat[i];
bySrl[node.srl] = node;
p = node.parent || 0;
if(!childrenOf[p]) childrenOf[p] = [];
childrenOf[p].push(node);
}
var totalCount = $wrap.attr('data-total_count') || '0';
var urlTpl = $wrap.attr('data-list_url_tpl') || '';
var currentCategory = parseInt($wrap.attr('data-current_category'), 10) || 0;
var previewEnabled = $wrap.attr('data-preview') === 'Y';
var showCount = $wrap.attr('data-show_count') === 'Y';
var $levels = $wrap.find('.mh_cnb_levels');
var $ajaxTarget = $('#mh_ajax_target');
function ancestorPath(srl){
var path = [];
var n = bySrl[srl];
while(n){
path.unshift(n);
n = n.parent ? bySrl[n.parent] : null;
}
return path;
}
function buildUrl(srl){
return urlTpl.replace('__MH_CATEGORY__', srl);
}
function escText(str){
return $('<div>').text(str == null ? '' : String(str)).html();
}
function labelWithCount(title, count){
return showCount ? (title + ' (' + count + ')') : title;
}
// parentSrl 밑의 자식들을 고르는 select 하나를 만든다.
function buildSelect(parentSrl, kids, selectedSrl){
var $select = $('<select></select>').attr('data-level-parent', parentSrl);
var totalLabel = (parentSrl === 0) ? labelWithCount('전체', totalCount) : '전체';
$select.append($('<option></option>').attr('value', parentSrl).text(totalLabel));
for(var k=0; k<kids.length; k++){
var kid = kids[k];
$select.append($('<option></option>').attr('value', kid.srl).text(labelWithCount(kid.title, kid.count)));
}
$select.val(selectedSrl != null ? selectedSrl : parentSrl);
return $select;
}
function renderFlat(){
// 아무것도 선택 안 된 초기 상태: 기존 cTab 스타일 텍스트 나열
$levels.empty();
var $all = $('<a href="#" class="mh_cnb_flat on"></a>')
.attr('data-srl', 0)
.text(labelWithCount('전체', totalCount));
$levels.append($all);
var topKids = childrenOf[0] || [];
for(var t=0; t<topKids.length; t++){
var tk = topKids[t];
var $a = $('<a href="#" class="mh_cnb_flat"></a>')
.attr('data-srl', tk.srl)
.text(labelWithCount(tk.title, tk.count));
if(tk.color && tk.color !== 'transparent'){
$a.css('color', tk.color);
}
var grandKids = previewEnabled ? childrenOf[tk.srl] : null;
if(grandKids && grandKids.length){
var $preview = $('<div class="mh_cnb_preview"></div>');
for(var g=0; g<grandKids.length; g++){
var gk = grandKids[g];
$preview.append(
$('<a href="#"></a>')
.attr('data-srl', gk.srl)
.text(labelWithCount(gk.title, gk.count))
);
}
var $wrap = $('<span class="mh_cnb_flat_wrap"></span>').append($a).append($preview);
$levels.append($wrap);
}else{
$levels.append($a);
}
}
}
function renderSelects(path){
// 1차 이상 선택된 상태: 전체(리셋 링크) > select > select ...
$levels.empty();
var $reset = $('<a href="#" class="mh_cnb_reset"></a>').attr('data-srl', 0).text('전체');
$levels.append($reset);
$levels.append('<span class="mh_cnb_arrow">›</span>');
var parentSrl = 0;
while(true){
var kids = childrenOf[parentSrl];
if(!kids || !kids.length) break;
var chosen = null;
for(var pi=0; pi<path.length; pi++){
if(path[pi].parent === parentSrl){ chosen = path[pi]; break; }
}
$levels.append(buildSelect(parentSrl, kids, chosen ? chosen.srl : parentSrl));
if(!chosen) break; // "전체" 선택 상태면 더 이상 하위 select는 안 그림
parentSrl = chosen.srl;
}
}
function renderAll(path){
if(!path.length){
renderFlat();
}else{
renderSelects(path);
}
}
function reinitAjaxContent(){
var bdZine = $ajaxTarget.find('ol.bd_zine');
if(bdZine.length && bdZine.attr('data-masonry') && $.fn.imagesLoaded && $.fn.masonry){
bdZine.imagesLoaded(function(){
bdZine.masonry({ itemSelector:'li', isFitWidth:true });
});
}
if($.fn.imagesLoaded){
$ajaxTarget.find('.tmb').each(function(){
var $t = $(this);
$t.imagesLoaded(function(){
$t.parent().addClass('fin_load').fadeIn(250);
});
});
}
}
function loadList(srl, pushState){
var url = buildUrl(srl);
if(!$ajaxTarget.length){
window.location.href = url;
return;
}
$ajaxTarget.css('opacity', 0.4);
$.get(url).done(function(html){
var $doc = $('<div></div>').html(html);
var $newTarget = $doc.find('#mh_ajax_target');
if($newTarget.length){
$ajaxTarget.html($newTarget.html());
}
$ajaxTarget.css('opacity', 1);
reinitAjaxContent();
if(pushState !== false && window.history && window.history.pushState){
window.history.pushState({ mh_category: srl }, '', url);
}
}).fail(function(){
$ajaxTarget.css('opacity', 1);
// 서버 오류 시 전체 페이지 이동시키지 않고 목록 영역에만 안내 문구 표시
$ajaxTarget.find('#board_list').html('<p class="mh_cnb_error" style="padding:40px 0;text-align:center;color:#999">게시물을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.</p>');
});
}
function selectCategory(srl, pushState){
srl = parseInt(srl, 10) || 0;
var path = srl ? ancestorPath(srl) : [];
currentCategory = srl;
renderAll(path);
loadList(srl, pushState);
}
$levels.on('click', 'a[data-srl]', function(e){
e.preventDefault();
selectCategory($(this).attr('data-srl'), true);
});
$levels.on('change', 'select', function(){
selectCategory($(this).val(), true);
});
$(window).on('popstate.mhCnbDrill', function(e){
var state = e.originalEvent ? e.originalEvent.state : null;
var srl = (state && typeof state.mh_category !== 'undefined') ? state.mh_category : currentCategory;
var path = srl ? ancestorPath(srl) : [];
currentCategory = srl;
renderAll(path);
loadList(srl, false);
});
// 초기 렌더 (현재 선택된 카테고리 기준, 목록은 서버가 이미 그려둔 상태라 재요청 안 함)
var initPath = currentCategory ? ancestorPath(currentCategory) : [];
renderAll(initPath);
});
}
$(mhCnbDrillInit);
})(jQuery);