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:
lasta
2026-05-14 14:17:26 +09:00
parent 1be2c7d3a3
commit 37cad40472
41 changed files with 669 additions and 232 deletions

View 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("&", "&amp;").replaceAll("'", "&#039;").replaceAll('"', "&quot;");
}
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));
}