Phase 0.5: React UI 기반 계층 구축 — API 클라이언트 + TanStack Query + shadcn UI
1. 서버/UI 상태 분리 (선택지 B 채택)
- ontologySlice: entities/relations 등 서버 상태 제거 → OntologyDraft(편집 폼)만
- crawlSlice: progress/stats/logs 제거 → CrawlUiState(토글/확장 상태)만
- 서버 상태는 전부 TanStack Query로 이전
2. API 클라이언트 계층 (src/lib/api/)
- client.ts: fetch 래퍼 + Zod 스키마 검증 + ApiError
- projects.ts: projectsApi.list/detail/create + 도메인 스키마
3. TanStack Query 훅 (src/hooks/)
- useProjects, useProject, useCreateProject
- queryKeys 팩토리로 키 일관성
4. shadcn 스타일 UI 프리미티브 (src/components/ui/)
- button, card, skeleton
- lib/utils.ts: cn() helper (clsx + tailwind-merge)
5. 레이아웃 (src/components/layout/)
- AppShell: 축소 가능 Sidebar + Header + Outlet
- App.tsx 라우트를 AppShell로 중첩
6. DashboardPage End-to-end 연결
- useProjects()로 백엔드 /projects 호출
- loading skeleton / error+retry / empty state / 카드 그리드 4가지 상태 모두 처리
7. i18n locale 파일 추가
- public/locales/ko/common.json (한국어)
- public/locales/en/common.json (영문 fallback)
- 키: nav.*, dashboard.*, common.*, app.*
8. 레거시 정리
- vanilla JS/CSS 24개 → src/legacy/로 git mv
- tailwind content를 *.{ts,tsx}로 좁혀 legacy 제외
다음 단계: Phase 1 — OnboardingPage 파일 업로드 + useCreateProject 연결
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
172
crawler_platform/app/web/frontend/src/legacy/search.js
Normal file
172
crawler_platform/app/web/frontend/src/legacy/search.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
|
||||
let debounceTimer = null;
|
||||
let lastQuery = "";
|
||||
let resultsCache = null;
|
||||
|
||||
export function mountGlobalSearch() {
|
||||
const input = $("globalSearchInput");
|
||||
const panel = $("globalSearchPanel");
|
||||
applyI18n(input.parentElement);
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim();
|
||||
if (debounceTimer) window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => runSearch(q), 200);
|
||||
});
|
||||
input.addEventListener("focus", () => {
|
||||
if (lastQuery) renderPanel(resultsCache); else renderHint();
|
||||
panel.hidden = false;
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") { input.value = ""; panel.hidden = true; input.blur(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const first = panel.querySelector("[data-search-item]");
|
||||
first?.focus();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.contains(event.target) && event.target !== input) panel.hidden = true;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && !event.target.matches("input, textarea, select")) {
|
||||
event.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
onLangChange(() => {
|
||||
applyI18n(input.parentElement);
|
||||
if (resultsCache) renderPanel(resultsCache);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!state.selectedProject) return;
|
||||
lastQuery = q;
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!q) { renderHint(); return; }
|
||||
try {
|
||||
resultsCache = await api(`/projects/${encodeURIComponent(state.selectedProject)}/search?q=${encodeURIComponent(q)}&limit=8`);
|
||||
renderPanel(resultsCache);
|
||||
} catch (error) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(error.message)}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint() {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.hint"))}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderPanel(results) {
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!results) return;
|
||||
const groups = [
|
||||
{ key: "entities", items: results.entities, render: renderEntity },
|
||||
{ key: "claims", items: results.claims, render: renderClaim },
|
||||
{ key: "pages", items: results.pages, render: renderPage },
|
||||
{ key: "predicates", items: results.predicates, render: renderPredicate },
|
||||
].filter((g) => (g.items ?? []).length > 0);
|
||||
|
||||
if (!groups.length) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.empty"))}</div>`;
|
||||
} else {
|
||||
panel.innerHTML = groups.map((g) => `
|
||||
<div class="search-group">
|
||||
<div class="search-group-head">${escapeHtml(t(`search.group.${g.key}`))}</div>
|
||||
${g.items.map((item, idx) => g.render(item, idx)).join("")}
|
||||
</div>
|
||||
`).join("");
|
||||
panel.querySelectorAll("[data-search-item]").forEach((node) => {
|
||||
node.addEventListener("click", () => handleClick(node.dataset.searchItem, JSON.parse(node.dataset.payload)));
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); node.click(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
(node.nextElementSibling || panel.querySelector("[data-search-item]"))?.focus?.();
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
(node.previousElementSibling || panel.querySelector("[data-search-item]:last-of-type"))?.focus?.();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
$("globalSearchInput").focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderEntity(e) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="entity" data-payload='${escapeHtmlAttr(JSON.stringify(e))}'>
|
||||
<span class="search-item-main">${escapeHtml(e.name)}</span>
|
||||
<span class="search-item-meta">${escapeHtml(e.type ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaim(c) {
|
||||
const obj = c.object_value ? JSON.stringify(c.object_value) : "";
|
||||
return `
|
||||
<button class="search-item" data-search-item="claim" data-payload='${escapeHtmlAttr(JSON.stringify(c))}'>
|
||||
<span class="search-item-main">${escapeHtml(c.subject ?? "")} · ${escapeHtml(c.predicate ?? "")} ${escapeHtml(obj)}</span>
|
||||
<span class="search-item-meta">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPage(p) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="page" data-payload='${escapeHtmlAttr(JSON.stringify(p))}'>
|
||||
<span class="search-item-main">${escapeHtml(p.title ?? p.url ?? "")}</span>
|
||||
<span class="search-item-meta">${escapeHtml(p.url ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPredicate(name) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="predicate" data-payload='${escapeHtmlAttr(JSON.stringify(name))}'>
|
||||
<span class="search-item-main">${escapeHtml(name)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(value) {
|
||||
return String(value).replaceAll("&", "&").replaceAll("'", "'").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function handleClick(kind, payload) {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.hidden = true;
|
||||
if (kind === "entity") {
|
||||
activateTab("entities");
|
||||
state.selection = { kind: "entity", data: { id: payload.id, name: payload.name, type: payload.type, metadata: {} } };
|
||||
refreshInspector();
|
||||
} else if (kind === "claim") {
|
||||
activateTab("claims");
|
||||
state.selection = { kind: "claim", data: { ...payload, object: undefined } };
|
||||
refreshInspector();
|
||||
} else if (kind === "page") {
|
||||
if (payload.url) window.open(payload.url, "_blank", "noreferrer");
|
||||
} else if (kind === "predicate") {
|
||||
activateTab("graph");
|
||||
const sel = $("graphPredicate");
|
||||
if (sel) { sel.value = payload; sel.dispatchEvent(new Event("change")); }
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((n) => n.classList.toggle("active", n.dataset.tab === name));
|
||||
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.toggle("active", n.id === name));
|
||||
}
|
||||
Reference in New Issue
Block a user