feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지
- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가 - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트 - 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관) - 워크스페이스: 탭 목록을 sessionStorage에 저장·복원 - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위) - 복원으로 무의미해진 beforeunload 새로고침 경고 제거 - e2e: 관리자 이동 후 탭 복원 케이스 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,17 @@ if ($effectiveLgIdx) {
|
||||
catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; }
|
||||
}
|
||||
|
||||
var STORE_KEY = 'jrj_ws_tabs';
|
||||
function persist() {
|
||||
try {
|
||||
var data = {
|
||||
tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }),
|
||||
active: activeId
|
||||
};
|
||||
sessionStorage.setItem(STORE_KEY, JSON.stringify(data));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function activate(id) {
|
||||
if (!tabs[id]) return;
|
||||
activeId = id;
|
||||
@@ -113,6 +124,7 @@ if ($effectiveLgIdx) {
|
||||
tabs[k].btn.classList.toggle('active', k === id);
|
||||
});
|
||||
if (empty) empty.style.display = 'none';
|
||||
persist();
|
||||
}
|
||||
|
||||
function reloadTab(id) {
|
||||
@@ -132,6 +144,7 @@ if ($effectiveLgIdx) {
|
||||
var next = order[order.length - 1];
|
||||
if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; }
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
function openTab(url, title) {
|
||||
@@ -194,17 +207,18 @@ if ($effectiveLgIdx) {
|
||||
});
|
||||
});
|
||||
|
||||
// 브라우저 전체 새로고침/이탈 시 경고 (기본 대시보드 외 탭이 열려 있을 때)
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (order.length > 1) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '열어 둔 탭이 모두 닫힙니다. 계속할까요?';
|
||||
return e.returnValue;
|
||||
// 첫 화면: 세션에 저장된 탭이 있으면 복원(관리자 페이지 등 전체 이동 후 복귀·새로고침 대응),
|
||||
// 없으면 대시보드 탭 자동 열기
|
||||
(function restore() {
|
||||
var saved = null;
|
||||
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
||||
if (saved && saved.tabs && saved.tabs.length) {
|
||||
saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title); });
|
||||
if (saved.active && tabs[saved.active]) activate(saved.active);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// 첫 화면: 대시보드 탭 자동 열기
|
||||
openTab('<?= base_url('/') ?>', '업무 현황');
|
||||
openTab('<?= base_url('/') ?>', '업무 현황');
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -17,6 +17,7 @@ $slugs = array_keys($pages);
|
||||
$pos = array_search($current, $slugs, true);
|
||||
$prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null;
|
||||
$nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null;
|
||||
$searchQ = (string) (service('request')->getGet('q') ?? '');
|
||||
?>
|
||||
<style>
|
||||
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 Tailwind 에 typography 플러그인 없음) */
|
||||
@@ -54,6 +55,16 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
|
||||
<!-- 좌측 목차 -->
|
||||
<nav class="manual-toc no-print w-56 shrink-0 sticky top-0 self-start">
|
||||
<div class="bg-title-bar text-white text-sm font-bold px-3 py-2 rounded-t">사용자 매뉴얼</div>
|
||||
<!-- 매뉴얼 전체 검색 -->
|
||||
<div class="manual-search" style="position:relative;padding:6px;border:1px solid #d1d5db;border-top:0;background:#fff;">
|
||||
<div style="position:relative;">
|
||||
<input id="manualSearchInput" type="search" autocomplete="off" placeholder="매뉴얼 검색 (예: LOT, 불출)"
|
||||
value="<?= esc($searchQ, 'attr') ?>"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1.5 pr-7 text-xs focus:outline-none focus:ring-2 focus:ring-[#243a5e]/30"/>
|
||||
<i class="fa-solid fa-magnifying-glass" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:11px;"></i>
|
||||
</div>
|
||||
<ul id="manualSearchResults" class="hidden" style="position:absolute;left:6px;right:6px;top:100%;z-index:40;max-height:300px;overflow-y:auto;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.12);margin:2px 0 0;padding:0;list-style:none;"></ul>
|
||||
</div>
|
||||
<ul class="border border-t-0 border-gray-300 rounded-b divide-y divide-gray-100 bg-white text-sm">
|
||||
<?php foreach ($pages as $slug => $p): ?>
|
||||
<li>
|
||||
@@ -90,3 +101,75 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// 현재 출처 기준 경로(앱 baseURL 호스트와 접속 호스트가 달라도 동일 출처로 요청)
|
||||
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
|
||||
var input = document.getElementById('manualSearchInput');
|
||||
var box = document.getElementById('manualSearchResults');
|
||||
|
||||
function esc(s) { return String(s || '').replace(/[&<>]/g, function (c) { return { '&': '&', '<': '<', '>': '>' }[c]; }); }
|
||||
|
||||
function doSearch(q) {
|
||||
q = (q || '').trim();
|
||||
if (!q) { box.classList.add('hidden'); box.innerHTML = ''; return; }
|
||||
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var res = (d && d.results) || [];
|
||||
if (!res.length) {
|
||||
box.innerHTML = '<li style="padding:7px 10px;color:#9ca3af;font-size:12px;">검색 결과가 없습니다.</li>';
|
||||
box.classList.remove('hidden'); return;
|
||||
}
|
||||
box.innerHTML = res.map(function (m) {
|
||||
return '<li><a href="' + BASE + encodeURIComponent(m.slug) + '?q=' + encodeURIComponent(q) +
|
||||
'" style="display:block;padding:7px 10px;border-bottom:1px solid #f1f5f9;text-decoration:none;">' +
|
||||
'<span style="font-size:12px;font-weight:700;color:#1a2b4b;">' + esc(m.title) +
|
||||
' <span style="color:#94a3b8;font-weight:400;">(' + (m.hits || 1) + ')</span></span>' +
|
||||
'<span style="display:block;font-size:11px;color:#64748b;margin-top:2px;line-height:1.4;">' + esc(m.snippet) + '</span></a></li>';
|
||||
}).join('');
|
||||
box.classList.remove('hidden');
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
if (input) {
|
||||
var timer = null;
|
||||
input.addEventListener('input', function () { clearTimeout(timer); var v = input.value; timer = setTimeout(function () { doSearch(v); }, 250); });
|
||||
input.addEventListener('focus', function () { if (input.value.trim()) doSearch(input.value); });
|
||||
document.addEventListener('click', function (e) { if (!e.target.closest('.manual-search')) box.classList.add('hidden'); });
|
||||
}
|
||||
|
||||
// 현재 페이지 본문에서 검색어 하이라이트
|
||||
if (Q0 && Q0.trim()) {
|
||||
var prose = document.querySelector('.manual-prose');
|
||||
if (prose) {
|
||||
var needle = Q0.trim().toLowerCase();
|
||||
var walker = document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, null);
|
||||
var nodes = [], first = null;
|
||||
while (walker.nextNode()) { nodes.push(walker.currentNode); }
|
||||
nodes.forEach(function (node) {
|
||||
var t = node.nodeValue, lt = t.toLowerCase(), idx = lt.indexOf(needle);
|
||||
if (idx < 0) return;
|
||||
var frag = document.createDocumentFragment(), last = 0;
|
||||
while (idx >= 0) {
|
||||
frag.appendChild(document.createTextNode(t.slice(last, idx)));
|
||||
var mark = document.createElement('mark');
|
||||
mark.textContent = t.slice(idx, idx + needle.length);
|
||||
mark.style.background = '#fde68a';
|
||||
frag.appendChild(mark);
|
||||
if (!first) first = mark;
|
||||
last = idx + needle.length;
|
||||
idx = lt.indexOf(needle, last);
|
||||
}
|
||||
frag.appendChild(document.createTextNode(t.slice(last)));
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
});
|
||||
if (first) { try { first.scrollIntoView({ block: 'center' }); } catch (e) {} }
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
|
||||
if (child.href) {
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
|
||||
'<span class="menu-ico">' + (on ? '×' : '+') + '</span>' + child.name + '</a>';
|
||||
'<span class="menu-ico">' + (on ? '▸' : '·') + '</span>' + child.name + '</a>';
|
||||
} else {
|
||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
<li>
|
||||
<?php if ($child['href'] !== ''): ?>
|
||||
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
|
||||
<span class="menu-ico"><?= $isChildActive ? '×' : '+' ?></span>
|
||||
<span class="menu-ico"><?= $isChildActive ? '▸' : '·' ?></span>
|
||||
<?= esc($child['name']) ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="menu-sub" style="opacity:.65;cursor:default;">
|
||||
<span class="menu-ico">+</span><?= esc($child['name']) ?>
|
||||
<span class="menu-ico">·</span><?= esc($child['name']) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
@@ -35,7 +35,7 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
||||
<?php elseif ($activeParent['href'] !== ''): ?>
|
||||
<li>
|
||||
<a href="<?= esc($activeParent['url']) ?>" class="active">
|
||||
<span class="menu-ico">×</span><?= esc($activeParent['name']) ?>
|
||||
<span class="menu-ico">▸</span><?= esc($activeParent['name']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
Reference in New Issue
Block a user