Files
Ivan Fontosh 0f4bb76b41 feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:42:57 +08:00

808 lines
26 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import {
listVisibleSceneNpcTokens,
nextSessionNpcSpawnPoint,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcsApp.module.css';
function ZoomInIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M10.5 7.8v5.4M7.8 10.5h5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomOutIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
/** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = [];
for (const node of nodes) {
const children = pruneEmptyGroupNodes(node.children);
if (node.npcs.length === 0 && children.length === 0) continue;
out.push({ ...node, children });
}
return out;
}
function RuntimeNpcTile({
npc,
selected,
shown,
accentColor,
mapDragEnabled,
onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
npc: ProjectNpc;
selected: boolean;
shown: boolean;
accentColor?: string | null;
mapDragEnabled: boolean;
onActivate: () => void;
onToggleShow: () => void;
onAddToMap: () => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const { t } = useEditorI18n();
const url = useAssetUrl(npc.avatarAssetId);
const menuBtnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const dragRef = useRef<{
pointerId: number;
x: number;
y: number;
started: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
useEffect(() => {
if (!menu) return;
const onPointerDown = (e: PointerEvent) => {
const tgt = e.target;
if (!(tgt instanceof Node)) return;
if (menuBtnRef.current?.contains(tgt)) return;
if (menuRef.current?.contains(tgt)) return;
setMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey);
};
}, [menu]);
return (
<div
className={[styles.tile, selected ? styles.tileSelected : ''].filter(Boolean).join(' ')}
style={accentColor ? { borderLeftColor: accentColor, borderLeftWidth: 3 } : undefined}
>
<button
ref={menuBtnRef}
type="button"
className={styles.tileMenuBtn}
aria-label={t('npcs.tileMenu')}
aria-haspopup="menu"
aria-expanded={menu !== null}
title={t('npcs.tileMenu')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const pad = 8;
const menuW = 180;
const menuH = 48;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.max(pad, Math.min(rect.right + 4, window.innerWidth - menuW - pad));
const y = Math.max(pad, Math.min(rect.top, window.innerHeight - menuH - pad));
setMenu((cur) => (cur ? null : { x, y }));
}}
>
⋮
</button>
<div className={styles.tileMain}>
<button
type="button"
className={styles.tileBody}
onClick={(e) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
return;
}
onActivate();
}}
onPointerDown={(e) => {
if (e.button !== 0 || !mapDragEnabled) return;
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || d.started) return;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
d.started = true;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
onMapDragBegin(npc.id);
}}
onPointerUp={(e) => {
const d = dragRef.current;
if (d && d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
} catch {
/* ignore */
}
if (d?.started) {
suppressClickRef.current = true;
e.preventDefault();
onMapDragEnd();
}
}}
onPointerCancel={() => {
dragRef.current = null;
}}
>
<div className={styles.tileAvatar}>
{url ? <img className={styles.tileAvatarImg} src={url} alt="" draggable={false} /> : null}
</div>
<div className={styles.tileName}>{npc.name}</div>
</button>
<label
className={styles.tileShow}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={shown}
onChange={() => {
onToggleShow();
}}
/>
<span>{t('npcs.showOnScreen')}</span>
</label>
</div>
{menu
? createPortal(
<div
ref={menuRef}
role="menu"
className={styles.menu}
style={{ left: menu.x, top: menu.y }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className={styles.menuItem}
disabled={!mapDragEnabled}
title={mapDragEnabled ? t('npcs.addToMapHint') : t('npcs.addToMapNoScene')}
onClick={() => {
setMenu(null);
if (!mapDragEnabled) return;
onAddToMap();
}}
>
{t('npcs.addToMap')}
</button>
</div>,
document.body,
)
: null}
</div>
);
}
function RuntimeGroupSection({
node,
depth,
isExpanded,
onToggleExpanded,
selectedIds,
shownIds,
mapDragEnabled,
onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
node: NpcGroupTreeNode;
depth: number;
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedIds: ReadonlySet<NpcId>;
shownIds: ReadonlySet<NpcId>;
mapDragEnabled: boolean;
onActivate: (id: NpcId) => void;
onToggleShow: (id: NpcId) => void;
onAddToMap: (id: NpcId) => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
}) {
const g = node.group;
const expanded = isExpanded(g.id);
return (
<div className={styles.groupBlock} style={{ paddingLeft: depth > 0 ? 12 : 0 }}>
<div className={styles.groupHeader}>
<button
type="button"
className={styles.groupToggle}
onClick={() => onToggleExpanded(g.id)}
aria-expanded={expanded}
>
{expanded ? '▾' : '▸'}
</button>
<span className={styles.groupColorDot} style={{ background: g.color }} aria-hidden />
<span className={styles.groupTitle}>{g.name}</span>
</div>
{expanded ? (
<div className={styles.groupBody}>
{node.npcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
accentColor={g.color}
mapDragEnabled={mapDragEnabled}
onActivate={() => onActivate(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => onAddToMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{node.children.map((child) => (
<RuntimeGroupSection
key={child.group.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onActivate}
onToggleShow={onToggleShow}
onAddToMap={onAddToMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>
) : null}
</div>
);
}
function NpcExternalWebview({ src }: { src: string }) {
const hostRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const el = document.createElement('webview');
el.setAttribute('src', src);
el.setAttribute('partition', 'persist:npc-external-link');
el.style.width = '100%';
el.style.height = '100%';
el.style.border = '0';
el.style.display = 'flex';
host.replaceChildren(el);
return () => {
host.replaceChildren();
};
}, [src]);
return <div ref={hostRef} className={styles.webviewHost} />;
}
function NpcInspectCard({
npc,
relations,
expanded,
onToggleExpanded,
}: {
npc: ProjectNpc;
relations: { id: string; text: string }[];
expanded: boolean;
onToggleExpanded: () => void;
}) {
const { t } = useEditorI18n();
const externalUrl = normalizeNpcExternalUrl(npc.externalUrl);
const [tab, setTab] = useState<'description' | 'link'>('description');
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const hasLink = Boolean(externalUrl);
const showLink = hasLink && tab === 'link';
useEffect(() => {
if (!hasLink && tab === 'link') setTab('description');
}, [hasLink, tab]);
return (
<section
className={[
styles.detailCard,
expanded && showLink ? styles.detailCardWebview : '',
]
.filter(Boolean)
.join(' ')}
>
<button
type="button"
className={styles.detailAccordion}
aria-expanded={expanded}
onClick={onToggleExpanded}
>
<span className={styles.detailAccordionMark}>{expanded ? '▾' : '▸'}</span>
<span className={styles.detailName}>{npc.name}</span>
</button>
{expanded ? (
<>
{hasLink ? (
<div className={styles.detailTabs} role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'description'}
className={[styles.detailTab, tab === 'description' ? styles.detailTabActive : '']
.filter(Boolean)
.join(' ')}
onClick={() => setTab('description')}
>
{t('npcs.tabDescription')}
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'link'}
className={[styles.detailTab, tab === 'link' ? styles.detailTabActive : '']
.filter(Boolean)
.join(' ')}
onClick={() => setTab('link')}
>
{t('npcs.tabExternalLink')}
</button>
</div>
) : null}
{showLink && externalUrl ? (
<NpcExternalWebview src={externalUrl} />
) : (
<>
{safeHtml ? (
<div>
{!hasLink ? <div className={styles.detailSectionTitle}>{t('npcs.description')}</div> : null}
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
)}
{relations.length > 0 ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
<div className={styles.relationsList}>
{relations.map((r) => (
<div key={r.id} className={styles.relationItem}>
{r.text}
</div>
))}
</div>
</div>
) : null}
</>
)}
</>
) : null}
</section>
);
}
export function NpcsApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [session, setSession] = useState<SessionState | null>(null);
const [overlay, overlayApi] = useNpcsOverlayState();
const [npcSession, npcSessionApi] = useSceneNpcTokensSession();
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [collapsedInspect, setCollapsedInspect] = useState<Set<NpcId>>(() => new Set());
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
setSession({ project, currentSceneId: project?.currentSceneId ?? null });
});
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
setSession(state);
});
}, [api]);
const activeIds = overlay?.activeNpcIds ?? [];
const [inspectIds, setInspectIds] = useState<NpcId[]>([]);
const hasActive = activeIds.length > 0;
const shownIds = useMemo(() => new Set(activeIds), [activeIds]);
useEffect(() => {
if (!overlay) return;
setInspectIds(overlay.inspectNpcIds ?? []);
}, [overlay?.inspectNpcIds]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (mapDrag.dragging) {
void mapDragApi.cancel();
return;
}
if (hasActive) {
void overlayApi.dispatch({ kind: 'hide' });
return;
}
if (overlay?.zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
window.close();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [hasActive, mapDrag.dragging, mapDragApi, overlay?.zoomTool, overlayApi]);
const zoomTool = overlay?.zoomTool ?? null;
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]);
const detailNpcs = useMemo(
() => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[inspectIds, npcs],
);
useEffect(() => {
const focusId = overlay?.inspectFocusNpcId ?? null;
if (!focusId) return;
setCollapsedInspect((prev) => {
if (!prev.has(focusId)) return prev;
const next = new Set(prev);
next.delete(focusId);
return next;
});
}, [overlay?.inspectFocusNpcId, overlay?.revision]);
useEffect(() => {
setCollapsedInspect((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of prev) {
if (!selectedIds.has(id)) {
next.delete(id);
changed = true;
}
}
return changed ? next : prev;
});
}, [selectedIds]);
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return npcs;
return npcs.filter((n) => n.name.toLowerCase().includes(q));
}, [npcs, query]);
const searching = query.trim().length > 0;
const { roots, ungrouped } = useMemo(() => {
const forest = buildNpcGroupForest(npcGroups, filteredNpcs);
if (!searching) return forest;
return {
roots: pruneEmptyGroupNodes(forest.roots),
ungrouped: forest.ungrouped,
};
}, [filteredNpcs, npcGroups, searching]);
const isExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return true;
return !collapsedGroups.has(id);
},
[collapsedGroups, searching],
);
const toggleExpanded = useCallback(
(id: NpcGroupId) => {
if (searching) return;
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[searching],
);
const relationsByNpcId = useMemo(() => {
const map = new Map<NpcId, { id: string; text: string }[]>();
for (const npc of detailNpcs) {
const list = relations
.filter((r) => r.sourceNpcId === npc.id)
.map((r) => {
const other = npcs.find((n) => n.id === r.targetNpcId);
return { id: r.id, text: `${r.label} ${other?.name ?? '—'}` };
});
map.set(npc.id, list);
}
return map;
}, [detailNpcs, npcs, relations]);
const onSelectTile = useCallback(
(id: NpcId) => {
setInspectIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
void overlayApi.dispatch({ kind: 'inspect.toggle', npcId: id });
},
[overlayApi],
);
const onToggleShow = useCallback(
(id: NpcId) => {
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
},
[overlayApi],
);
const sceneId = session?.currentSceneId ?? null;
const currentScene = sceneId && session?.project ? session.project.scenes[sceneId] : undefined;
const mapDragEnabled = Boolean(sceneId);
const spawnNpcOnMap = useCallback(
(npcId: NpcId) => {
if (!sceneId) return;
const npc = npcs.find((item) => item.id === npcId);
if (!npc) return;
const visible = listVisibleSceneNpcTokens(
currentScene?.npcTokens,
npcSession.spawned,
sceneId,
);
const occupied = visible.map((tok) => {
const override = npcSession.byPlacementId[String(tok.id)];
return { nx: override?.nx ?? tok.nx, ny: override?.ny ?? tok.ny };
});
const point = nextSessionNpcSpawnPoint(occupied);
npcSessionApi.dispatch({
kind: 'spawn',
npcId,
sceneId,
nx: point.nx,
ny: point.ny,
disposition: normalizeNpcDisposition(npc.disposition),
});
},
[currentScene?.npcTokens, npcSession.byPlacementId, npcSession.spawned, npcSessionApi, npcs, sceneId],
);
const onMapDragBegin = useCallback(
(id: NpcId) => {
void mapDragApi.beginDrag(id);
},
[mapDragApi],
);
const onMapDragEnd = useCallback(() => {
void mapDragApi.commit();
}, [mapDragApi]);
return (
<div className={styles.page}>
<div className={styles.toolbar}>
<div className={matStyles.browserToolbarRow}>
<Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomIn')}
ariaLabel={t('npcs.zoomIn')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
});
}}
>
<ZoomInIcon />
</Button>
<Button
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomOut')}
ariaLabel={t('npcs.zoomOut')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
});
}}
>
<ZoomOutIcon />
</Button>
</div>
<div className={matStyles.browserToolbarHint}>
{zoomTool === 'zoomIn'
? t('npcs.zoomInHint')
: zoomTool === 'zoomOut'
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
<div className={styles.toolbarHint}>
{sceneId ? t('npcs.dragToMapHint') : t('npcs.addToMapNoScene')}
</div>
<div className={styles.toolbarRow}>
<Button
title={t('npcs.closeOverlay')}
ariaLabel={t('npcs.closeOverlay')}
tooltipPlacement="bottom"
disabled={!hasActive}
onClick={() => {
void overlayApi.dispatch({ kind: 'hide' });
}}
>
{t('npcs.closeOverlay')}
</Button>
</div>
</div>
<div className={styles.body}>
<div className={styles.detail}>
{detailNpcs.length > 0 ? (
detailNpcs.map((npc) => (
<NpcInspectCard
key={npc.id}
npc={npc}
relations={relationsByNpcId.get(npc.id) ?? []}
expanded={!collapsedInspect.has(npc.id)}
onToggleExpanded={() => {
setCollapsedInspect((prev) => {
const next = new Set(prev);
if (next.has(npc.id)) next.delete(npc.id);
else next.add(npc.id);
return next;
});
}}
/>
))
) : (
<div className={styles.detailCard}>
<div className={styles.detailEmpty}>
{npcs.length === 0 ? t('npcs.windowEmpty') : t('npcs.selectToShow')}
</div>
</div>
)}
</div>
<div className={styles.listCol}>
<Input value={query} onChange={setQuery} placeholder={t('npcs.search')} />
<div className={styles.list}>
{npcGroups.length === 0 ? (
filteredNpcs.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))
) : (
<>
{roots.map((node) => (
<RuntimeGroupSection
key={node.group.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onSelectTile}
onToggleShow={onToggleShow}
onAddToMap={spawnNpcOnMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
{ungrouped.length > 0 || !searching ? (
<div className={styles.ungroupedSection}>
<div className={styles.ungroupedHeader}>{t('npcs.ungrouped')}</div>
<div className={styles.groupBody}>
{ungrouped.map((n) => (
<RuntimeNpcTile
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
/>
))}
</div>
</div>
) : null}
</>
)}
{npcs.length === 0 ? <div className={styles.muted}>{t('npcs.windowEmpty')}</div> : null}
{npcs.length > 0 && filteredNpcs.length === 0 ? (
<div className={styles.muted}>{t('npcs.searchEmpty')}</div>
) : null}
</div>
</div>
</div>
</div>
);
}