Files
jongryangje/app/Views/bag/manual.php
taekyoungc b9dd24082c feat: 화면설명 소제목 스크롤·강조 + 글씨크기 메뉴 확대 + 드로어 개선
- screenHelp 앵커(?hl=)로 '이 화면 설명' 클릭 시 해당 소제목으로 스크롤·강조, 재오픈 시 재강조(postMessage)
- 글씨 크기(A−/A+)가 상단 대메뉴·좌측 사이드바까지 확대, 관리자 페이지에도 조절 기능 추가
- 화면 설명 드로어 양방향 리사이즈(좁히기 가능) + 기본 너비 2배

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:31:31 +09:00

208 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/**
* 사용자 매뉴얼 뷰.
*
* @var array<string, array{title:string,file:string}> $pages 목차
* @var string $current 현재 slug
* @var string $title 현재 페이지 제목
* @var string $body commonmark 로 변환된 HTML (신뢰된 콘텐츠)
*/
$pages = $pages ?? [];
$current = (string) ($current ?? '');
$title = (string) ($title ?? '사용자 매뉴얼');
$body = (string) ($body ?? '');
$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 플러그인 없음) */
.manual-prose { color: #1f2937; font-size: 0.95rem; line-height: 1.75; max-width: 52rem; }
.manual-prose h1 { font-size: 1.6rem; font-weight: 800; margin: 0 0 1rem; color: #111827; }
.manual-prose h2 { font-size: 1.25rem; font-weight: 700; margin: 1.8rem 0 0.7rem; padding-bottom: 0.35rem; border-bottom: 2px solid #e5e7eb; color: #1d4ed8; scroll-margin-top: 1rem; }
.manual-prose h3 { font-size: 1.05rem; font-weight: 700; margin: 1.3rem 0 0.5rem; color: #374151; }
.manual-prose p { margin: 0.6rem 0; }
.manual-prose ul, .manual-prose ol { margin: 0.6rem 0 0.6rem 1.4rem; }
.manual-prose ul { list-style: disc; }
.manual-prose ol { list-style: decimal; }
.manual-prose li { margin: 0.25rem 0; }
.manual-prose li > ul, .manual-prose li > ol { margin-top: 0.25rem; }
.manual-prose a { color: #1a2b4b; text-decoration: underline; }
.manual-prose a:hover { color: #2563eb; }
.manual-prose strong { font-weight: 700; color: #111827; }
.manual-prose blockquote { margin: 0.9rem 0; padding: 0.6rem 1rem; border-left: 4px solid #60a5fa; background: #eff6ff; color: #1e3a8a; border-radius: 0 6px 6px 0; }
.manual-prose code { background: #f3f4f6; color: #b91c1c; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.manual-prose pre { background: #1e293b; color: #e2e8f0; padding: 0.9rem 1rem; border-radius: 8px; overflow-x: auto; margin: 0.9rem 0; font-size: 0.85rem; line-height: 1.6; }
.manual-prose pre code { background: transparent; color: inherit; padding: 0; }
.manual-prose table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.875rem; }
.manual-prose th, .manual-prose td { border: 1px solid #d1d5db; padding: 0.45rem 0.7rem; text-align: left; vertical-align: top; }
.manual-prose th { background: #e9ecef; font-weight: 700; color: #333; }
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
.manual-toc a.active { background: #1a2b4b; color: #fff; font-weight: 700; }
/* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */
.manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; }
@keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } }
@media print {
.manual-toc, .manual-actions, .manual-nav { display: none !important; }
.manual-layout { display: block !important; }
.manual-prose { max-width: none; }
}
</style>
<div class="manual-layout flex gap-6 items-start max-w-6xl mx-auto">
<!-- 좌측 목차 -->
<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>
<a href="<?= base_url('bag/manual/' . $slug) ?>"
class="block px-3 py-2 text-gray-700 hover:bg-blue-50 <?= $slug === $current ? 'active' : '' ?>">
<?= esc($p['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<!-- 본문 -->
<div class="flex-grow min-w-0">
<div class="manual-actions no-print flex justify-end mb-3">
<button type="button" onclick="window.print()"
class="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50">
인쇄
</button>
</div>
<article class="manual-prose"><?= $body ?></article>
<!-- 이전/다음 -->
<div class="manual-nav no-print flex justify-between mt-8 pt-4 border-t border-gray-200 text-sm">
<?php if ($prevSlug !== null): ?>
<a href="<?= base_url('bag/manual/' . $prevSlug) ?>" class="text-blue-700 hover:underline">&larr; <?= esc($pages[$prevSlug]['title']) ?></a>
<?php else: ?>
<span></span>
<?php endif; ?>
<?php if ($nextSlug !== null): ?>
<a href="<?= base_url('bag/manual/' . $nextSlug) ?>" class="text-blue-700 hover:underline"><?= esc($pages[$nextSlug]['title']) ?> &rarr;</a>
<?php endif; ?>
</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) ?>;
// "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조.
// 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다.
var hlFlashTimer = null;
function runHl() {
var hl = '';
try { hl = new URLSearchParams(location.search).get('hl') || ''; } catch (e) {}
hl = hl.trim();
if (!hl) return;
var norm = function (s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); };
var needle = norm(hl);
var prose = document.querySelector('.manual-prose');
if (!prose) return;
var heads = prose.querySelectorAll('h2, h3'), target = null;
for (var i = 0; i < heads.length; i++) {
if (norm(heads[i].textContent).indexOf(needle) >= 0) { target = heads[i]; break; }
}
if (!target) return;
clearTimeout(hlFlashTimer);
target.classList.remove('hl-flash');
void target.offsetWidth; // 리플로우 → 애니메이션 재시작
target.classList.add('hl-flash');
try { target.scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (e) { target.scrollIntoView(); }
hlFlashTimer = setTimeout(function () { target.classList.remove('hl-flash'); }, 2700);
}
runHl();
window.addEventListener('message', function (e) {
if (e.origin !== location.origin) return;
if (e.data && e.data.type === 'manual-hl') runHl();
});
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>