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:
taekyoungc
2026-06-08 19:52:53 +09:00
parent e8d58b5837
commit 1a443de02e
8 changed files with 205 additions and 14 deletions

View File

@@ -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>

View File

@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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>

View File

@@ -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>';
}

View File

@@ -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; ?>