diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 484a08a..9bbdae8 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -1,5 +1,5 @@ import type { ComponentType } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; @@ -22,6 +22,7 @@ import { IconCalendar, IconCheckCircle, IconChevronDown, + IconClose, IconDashboard, IconDocument, IconExhibitors, @@ -32,6 +33,7 @@ import { IconMenu, IconMoon, IconOperations, + IconSearch, IconSettings, IconSettlement, IconSun, @@ -225,6 +227,53 @@ function hallIdFromLabel(label: string | null | undefined): string { return m ? `H${m[1]}` : 'H7'; } +/** + * 메뉴 검색(WISE NavSearch/filterMenuTree 패턴 이식) — 검색어로 그룹을 prune. + * 매칭: 항목 라벨(i18n 해소값) 부분일치(대소문자 무시). section 헤더는 결과에서 제외. + * 매칭 항목을 보유한 그룹만, 매칭 항목만 담아 반환(WISE 는 매칭 그룹 force-open 렌더). + * resolveLabel 로 i18n 해소값을 넘겨 4개 언어 라벨 모두 검색 가능. + */ +interface FilteredGroup { + group: NavGroupDef; + items: NavItem[]; +} +function filterGroups( + groups: NavGroupDef[], + query: string, + resolveLabel: (item: NavItem) => string, +): FilteredGroup[] { + const nq = query.trim().toLowerCase(); + if (!nq) return []; + const out: FilteredGroup[] = []; + for (const group of groups) { + const items = group.items.filter( + (item) => !item.section && resolveLabel(item).toLowerCase().includes(nq), + ); + if (items.length) out.push({ group, items }); + } + return out; +} + +/** 라벨을 검색어 매칭 구간에서 분할(하이라이트용). 미매칭 시 [{text,hit:false}] 단일. */ +function highlightParts(label: string, query: string): { text: string; hit: boolean }[] { + const nq = query.trim().toLowerCase(); + if (!nq) return [{ text: label, hit: false }]; + const parts: { text: string; hit: boolean }[] = []; + const lower = label.toLowerCase(); + let i = 0; + while (i < label.length) { + const idx = lower.indexOf(nq, i); + if (idx < 0) { + parts.push({ text: label.slice(i), hit: false }); + break; + } + if (idx > i) parts.push({ text: label.slice(i, idx), hit: false }); + parts.push({ text: label.slice(idx, idx + nq.length), hit: true }); + i = idx + nq.length; + } + return parts; +} + /** 현재 경로가 속한 대분류 그룹 id(활성 그룹 자동 펼침용). 정적 to 항목만 매칭. */ function activeGroupId(pathname: string): string | null { for (const g of GROUPS) { @@ -265,6 +314,11 @@ export function AppShell() { // 단일-열림 아코디언(소유자 지시 ②) — 한 번에 대분류 하나만 펼침. WISE LeftNav 동작 그대로. const [openGroup, setOpenGroup] = useState(() => activeGroupId(location.pathname)); + // 메뉴 검색(WISE NavSearch 패턴) — 검색어는 셸 로컬 state(신규 store 없음). + const [menuQuery, setMenuQuery] = useState(''); + const searching = menuQuery.trim().length > 0; + const clearMenuQuery = () => setMenuQuery(''); + // 설정 아이콘 → 우측 레이아웃 커스터마이저(#43) offcanvas 열림. const [customizerOpen, setCustomizerOpen] = useState(false); @@ -300,11 +354,71 @@ export function AppShell() { closeOnMobile(); } + /** + * 리프 항목 렌더(링크 또는 스코프 버튼). highlight=true 시 검색어 매칭 구간을 로 강조. + * 검색 결과 클릭 시 검색어를 지워 아코디언으로 복귀(WISE onNavigate=clearQuery). + */ + const renderLeaf = (item: NavItem, highlight: boolean) => { + const label = navLabel(item.labelKey, item.label); + const body = highlight + ? highlightParts(label, menuQuery).map((p, i) => + p.hit ? ( + {p.text} + ) : ( + {p.text} + ), + ) + : label; + const onNav = () => { + if (highlight) clearMenuQuery(); + closeOnMobile(); + }; + return item.to ? ( + `kx-shell__nav-link ${isActive ? 'is-active' : ''}`} + onClick={onNav} + > + + ) : ( + + ); + }; + // 관리자 그룹은 isAdmin 게이트 유지 + 트랙 노출 매트릭스(plan §2-3). 미지정 그룹=전 트랙. const visibleGroups = GROUPS.filter( (g) => (!g.admin || isAdmin) && trackAllows(g.tracks, activeTrack), ); + /** + * 검색 결과 — 전체 대분류 횡단(global), 항목 라벨(i18n) 부분일치. + * 항목 단위 트랙 게이트(trackAllows) 유지 → 노출 규칙 회귀 없음. + * (visibleGroups·menuQuery·activeTrack·언어 변경 시 재계산) + */ + const searchResults = useMemo( + () => + filterGroups(visibleGroups, menuQuery, (item) => navLabel(item.labelKey, item.label)).map( + ({ group, items }) => ({ + group, + items: items.filter((item) => trackAllows(item.tracks, activeTrack)), + }), + ).filter(({ items }) => items.length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [visibleGroups, menuQuery, activeTrack, t], + ); + return (
@@ -335,7 +449,59 @@ export function AppShell() { )} + {/* 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위, flex:none). */} +
+
+
+ {searching ? ( + /* 검색 — 전체 대분류 횡단(global) 매칭 항목만, 매칭 그룹 force-open(WISE 패턴). 하이라이트. */ + searchResults.length === 0 ? ( +
+ {t('shell.menu.search.noResult', { defaultValue: '일치하는 메뉴가 없습니다' })} +
+ ) : ( + searchResults.map(({ group, items }) => ( +
+
+ + {navLabel(group.labelKey, group.label)} +
+
    + {items.map((item) => ( +
  • {renderLeaf(item, true)}
  • + ))} +
+
+ )) + ) + ) : ( + <> {/* 홈 — 최상위 단일 링크 */}
  • @@ -381,29 +547,7 @@ export function AppShell() { {navLabel(item.labelKey, item.label)}
  • ) : ( -
  • - {item.to ? ( - - `kx-shell__nav-link ${isActive ? 'is-active' : ''}` - } - onClick={closeOnMobile} - > - - ) : ( - - )} -
  • +
  • {renderLeaf(item, false)}
  • ), )}
@@ -411,6 +555,8 @@ export function AppShell() {
); })} + + )}
{/* 하단 사용자 영역 — 반응형 재정렬(소유자 지적 3): 프로필 행 + 액션 아이콘 행. */} diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index 631c9a6..fffebd3 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -98,6 +98,81 @@ text-overflow: ellipsis; } +/* ── 메뉴 검색(WISE NavSearch 이식) — 브랜드/행사 아래 고정(스크롤 영역 위). ── */ +.kx-shell__nav-search { + position: relative; + display: flex; + align-items: center; + flex: none; + padding: var(--space-3) var(--space-4) 0; +} +.kx-shell__nav-search-icon { + position: absolute; + left: calc(var(--space-4) + 11px); + color: var(--color-neutral-500); + pointer-events: none; +} +.kx-shell__nav-search-input { + width: 100%; + height: 34px; + padding: 0 32px 0 34px; + border: 1px solid var(--color-neutral-200); + border-radius: var(--radius-pill); + background: var(--color-neutral-050); + color: var(--color-neutral-900); + font-size: var(--fs-body); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.kx-shell__nav-search-input::placeholder { + color: var(--color-neutral-500); +} +.kx-shell__nav-search-input:focus { + border-color: var(--color-primary-600); + box-shadow: 0 0 0 2px var(--color-primary-100); +} +.kx-shell__nav-search-clear { + position: absolute; + right: calc(var(--space-4) + 6px); + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-neutral-500); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.kx-shell__nav-search-clear:hover { + background: var(--color-neutral-100); + color: var(--color-neutral-900); +} +/* 검색 결과 없음/빈 메뉴 */ +.kx-shell__nav-empty { + padding: var(--space-4); + font-size: var(--fs-caption); + color: var(--color-neutral-500); + text-align: center; +} +/* 검색 결과 그룹 헤더(클릭 불가, 아코디언 토글 없이 그룹 라벨만). */ +.kx-shell__group-title--static { + cursor: default; +} +.kx-shell__group-title--static:hover { + background: transparent; + color: var(--color-neutral-700); +} +/* 검색어 매칭 하이라이트 */ +.kx-shell__nav-hl { + background: var(--color-warning-100, var(--color-primary-100)); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + /* 메뉴 스크롤 영역 — 사이드바 중앙(하단 사용자 고정). */ .kx-shell__nav-scroll { flex: 1 1 auto; diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index c637092..48e64bb 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -33,6 +33,11 @@ "shell": { "menu": { "home": "Home", + "search": { + "placeholder": "Search menu", + "clear": "Clear search", + "noResult": "No matching menu" + }, "groups": { "ops": "Exhibition Ops", "design": "Design & Build", @@ -2817,4 +2822,4 @@ "renderResult": "Result", "noParams": "This template has no parameters" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 767afc7..5d726ad 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -33,6 +33,11 @@ "shell": { "menu": { "home": "ホーム", + "search": { + "placeholder": "メニュー検索", + "clear": "検索をクリア", + "noResult": "一致するメニューがありません" + }, "groups": { "ops": "展示運営", "design": "設計・施工", @@ -2817,4 +2822,4 @@ "renderResult": "レンダリング結果", "noParams": "置換パラメータのないテンプレートです" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index 63b5ec4..f6a8cbd 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -33,6 +33,11 @@ "shell": { "menu": { "home": "홈", + "search": { + "placeholder": "메뉴 검색", + "clear": "검색어 지우기", + "noResult": "일치하는 메뉴가 없습니다" + }, "groups": { "ops": "전시 운영", "design": "설계·시공", @@ -2817,4 +2822,4 @@ "renderResult": "렌더 결과", "noParams": "치환할 파라미터가 없는 템플릿입니다" } -} +} \ No newline at end of file diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index fc12225..16dfa6a 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -33,6 +33,11 @@ "shell": { "menu": { "home": "首页", + "search": { + "placeholder": "搜索菜单", + "clear": "清除搜索", + "noResult": "没有匹配的菜单" + }, "groups": { "ops": "展会运营", "design": "设计·施工", @@ -2817,4 +2822,4 @@ "renderResult": "渲染结果", "noParams": "此模板没有可替换参数" } -} +} \ No newline at end of file