Compare commits

...

3 Commits

Author SHA1 Message Date
LASTA_DEV01\lasta
de46b657c2 domain builder 2026-05-20 20:59:40 +09:00
LASTA_DEV01\lasta
6a6befc485 2 2026-05-20 18:44:49 +09:00
LASTA_DEV01\lasta
651c47d698 1 2026-05-20 18:43:08 +09:00
45 changed files with 11265 additions and 2032 deletions

View File

@@ -1,4 +0,0 @@
INFO: Started server process [38332]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

View File

@@ -1,60 +0,0 @@
INFO: 127.0.0.1:51497 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51498 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51500 - "GET /static/locales/ko/common.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:55124 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:55124 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:52395 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:55124 - "GET /static/locales/ko/common.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:52395 - "GET /static/locales/ko/pages.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:50896 - "GET /static/locales/en/common.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:60772 - "GET /static/locales/en/pages.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:50896 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:60772 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:57787 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:55691 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:57787 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51064 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:57726 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/locales/ko/pages.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/locales/en/pages.json HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:62676 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51077 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51077 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51077 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51077 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/crawl/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:51077 - "GET /static/assets/index-uHGS9kYH.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /static/assets/index-DmWQTzXA.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:53782 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:51077 - "GET /projects HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /projects/PerfumeSubscribe_new HTTP/1.1" 200 OK
INFO: 127.0.0.1:62676 - "GET /domains HTTP/1.1" 200 OK

Binary file not shown.

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import csv
import io
import json
import re
from dataclasses import asdict
from typing import Any
@@ -28,7 +29,8 @@ from crawler_platform.app.core.database.repository import (
from crawler_platform.app.core.database.session import session_scope
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
from crawler_platform.app.core.extractor.factory import extractor_for_domain
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, ontology_for_domain
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, Ontology, ontology_for_domain
from crawler_platform.app.core.ontology.domain_discovery import DomainDiscoveryService
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
from crawler_platform.app.core.ontology.registry import OntologyRegistry
@@ -38,6 +40,9 @@ from crawler_platform.app.core.research.graph_research_loop import GraphResearch
from crawler_platform.app.core.research.memory_store import ResearchMemoryStore, research_session_payload
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,79}$")
class CrawlRequest(BaseModel):
config_path: str
source_name: str
@@ -149,6 +154,41 @@ class CreateProjectInlineRequest(BaseModel):
)
class DomainDefinitionRequest(BaseModel):
domain: str
description: str | None = None
entity_types: list[str] = Field(default_factory=list)
predicates: list[str] = Field(default_factory=list)
attributes: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
class DomainDefinitionUpdateRequest(BaseModel):
description: str | None = None
entity_types: list[str] = Field(default_factory=list)
predicates: list[str] = Field(default_factory=list)
attributes: list[str] = Field(default_factory=list)
aliases: dict[str, str] = Field(default_factory=dict)
class DomainDiscoveryRequest(BaseModel):
seed_urls: list[str] = Field(default_factory=list)
max_pages: int = 20
max_depth: int = 1
same_domain_only: bool = True
fetcher: str = "requests"
respect_robots_txt: bool = False
force_recrawl: bool = False
class DomainCandidateApplyRequest(BaseModel):
candidate_ids: list[int] = Field(default_factory=list)
class DomainCandidateStatusRequest(BaseModel):
status: str
class ResetProjectRequest(BaseModel):
config_path: str
project_name: str | None = None
@@ -165,6 +205,250 @@ def source_model_to_config(source: models.Source) -> SourceConfig:
)
def normalize_domain_name(domain: str) -> str:
normalized = domain.strip().lower()
if not DOMAIN_NAME_PATTERN.match(normalized):
raise HTTPException(
status_code=400,
detail="Domain must be 2-80 chars using lowercase letters, numbers, underscore, or hyphen.",
)
return normalized
def clean_string_list(values: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for value in values:
item = value.strip()
if not item or item in seen:
continue
cleaned.append(item)
seen.add(item)
return cleaned
def clean_aliases(values: dict[str, str]) -> dict[str, str]:
return {
key.strip(): value.strip()
for key, value in values.items()
if key.strip() and value.strip()
}
def domain_row_to_ontology(row: models.DomainDefinition):
return Ontology(
domain=row.domain,
entity_types=clean_string_list(list(row.entity_types or [])),
predicates=clean_string_list(list(row.predicates or [])),
attributes=clean_string_list(list(row.attributes or [])),
aliases=clean_aliases(dict(row.aliases or {})),
)
def domain_summary_payload(ont, *, is_builtin: bool, is_custom: bool, description: str | None = None):
return {
"domain": ont.domain,
"description": description,
"entity_types": list(ont.entity_types),
"predicates": list(ont.predicates),
"attribute_count": len(ont.attributes),
"attributes": list(ont.attributes),
"aliases": dict(ont.aliases),
"is_builtin": is_builtin,
"is_custom": is_custom,
}
def upsert_domain_definition(
session,
domain: str,
payload: DomainDefinitionUpdateRequest,
) -> models.DomainDefinition:
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is None:
row = models.DomainDefinition(domain=normalized_domain)
session.add(row)
session.flush()
row.description = payload.description.strip() if payload.description else None
row.entity_types = clean_string_list(payload.entity_types)
row.predicates = clean_string_list(payload.predicates)
row.attributes = clean_string_list(payload.attributes)
row.aliases = clean_aliases(payload.aliases)
row.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
row.updated_at = models.utcnow()
return row
def ensure_domain_definition(session, domain: str) -> models.DomainDefinition:
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is not None:
return row
base = DOMAIN_ONTOLOGIES.get(normalized_domain)
row = models.DomainDefinition(
domain=normalized_domain,
entity_types=list(base.entity_types) if base else [],
predicates=list(base.predicates) if base else [],
attributes=list(base.attributes) if base else [],
aliases=dict(base.aliases) if base else {},
is_builtin_override=normalized_domain in DOMAIN_ONTOLOGIES,
)
session.add(row)
session.flush()
return row
def ontology_for_domain_from_db(session, domain: str):
normalized_domain = normalize_domain_name(domain)
row = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
)
if row is not None:
return domain_row_to_ontology(row)
return ontology_for_domain(normalized_domain)
def clean_seed_urls(values: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for value in values:
url = value.strip()
if not url or url in seen:
continue
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("file://")):
raise HTTPException(status_code=400, detail=f"Unsupported URL: {url}")
cleaned.append(url)
seen.add(url)
if not cleaned:
raise HTTPException(status_code=400, detail="At least one reference URL is required.")
return cleaned
def domain_discovery_job_payload(job: models.DomainDiscoveryJob) -> dict[str, Any]:
return {
"job_id": job.id,
"domain": job.domain,
"status": job.status,
"seed_urls": list(job.seed_urls or []),
"max_pages": job.max_pages,
"max_depth": job.max_depth,
"same_domain_only": job.same_domain_only,
"fetcher": job.fetcher,
"respect_robots_txt": job.respect_robots_txt,
"error": job.error,
"progress": job.progress or {},
"result_summary": job.result_summary or {},
"created_at": job.created_at.isoformat() if job.created_at else None,
"started_at": job.started_at.isoformat() if job.started_at else None,
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
}
def domain_reference_source_payload(row: models.DomainReferenceSource) -> dict[str, Any]:
return {
"id": row.id,
"domain": row.domain,
"url": row.url,
"label": row.label,
"status": row.status,
"last_crawled_at": row.last_crawled_at.isoformat() if row.last_crawled_at else None,
"metadata": row.metadata_json or {},
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def already_crawled_reference_urls(session, domain: str, seed_urls: list[str]) -> set[str]:
if not seed_urls:
return set()
rows = session.scalars(
select(models.DomainReferenceSource).where(
models.DomainReferenceSource.domain == domain,
models.DomainReferenceSource.url.in_(seed_urls),
models.DomainReferenceSource.status == "active",
models.DomainReferenceSource.last_crawled_at.is_not(None),
)
).all()
return {row.url for row in rows}
def domain_candidate_payload(row: models.DomainSchemaCandidate, evidence_rows: list[models.DomainCandidateEvidence]) -> dict[str, Any]:
return {
"id": row.id,
"domain": row.domain,
"job_id": row.job_id,
"candidate_type": row.candidate_type,
"name": row.name,
"description": row.description,
"confidence": row.confidence,
"occurrence_count": row.occurrence_count,
"status": row.status,
"metadata": row.metadata_json or {},
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"evidence": [
{
"id": evidence.id,
"job_id": evidence.job_id,
"url": evidence.url,
"title": evidence.title,
"snippet": evidence.snippet,
"metadata": evidence.metadata_json or {},
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
}
for evidence in evidence_rows
],
}
def run_domain_discovery_job(database_url: str, job_id: int) -> None:
try:
with session_scope(database_url) as session:
DomainDiscoveryService(session).run_job(job_id)
except Exception as exc:
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is not None:
job.status = "failed"
job.error = str(exc)
job.finished_at = models.utcnow()
progress = dict(job.progress or {})
progress["errors"] = [*progress.get("errors", []), str(exc)]
job.progress = progress
def recover_interrupted_domain_discovery_jobs(database_url: str) -> None:
"""Mark domain discovery jobs that cannot survive a server restart."""
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainDiscoveryJob).where(
models.DomainDiscoveryJob.status.in_(
["pending", "running", "cancel_requested"]
)
)
).all()
for job in rows:
progress = dict(job.progress or {})
errors = list(progress.get("errors") or [])
if job.status == "cancel_requested":
job.status = "canceled"
progress["completion_reason"] = "canceled"
else:
job.status = "failed"
job.error = "Server restarted before this background analysis finished."
progress["completion_reason"] = "interrupted"
errors.append(job.error)
progress["errors"] = errors[-10:]
job.progress = progress
job.finished_at = models.utcnow()
def project_config_from_project_row(session, project: models.Project) -> ProjectConfig:
config_dict = dict(project.config or {})
if not config_dict:
@@ -195,6 +479,32 @@ def project_config_to_dict(config: ProjectConfig) -> dict[str, Any]:
}
def delete_project_data(session, project_id: int, include_project: bool = False) -> dict[str, int]:
repo = KnowledgeRepository(session)
deleted = repo.reset_project_runtime_data(project_id)
for table_model in [
models.OntologyProposal,
models.OntologyTriple,
models.OntologyRelationType,
models.OntologyEntityType,
models.Source,
]:
count = (
session.query(table_model)
.filter(table_model.project_id == project_id)
.delete(synchronize_session=False)
)
deleted[table_model.__tablename__] = deleted.get(table_model.__tablename__, 0) + int(count or 0)
if include_project:
project_count = (
session.query(models.Project)
.filter(models.Project.id == project_id)
.delete(synchronize_session=False)
)
deleted[models.Project.__tablename__] = int(project_count or 0)
return deleted
class CreateEntityRequest(BaseModel):
entity_type: str
name: str
@@ -485,6 +795,8 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
def register_routes(app, database_url: str) -> None:
recover_interrupted_domain_discovery_jobs(database_url)
@app.get("/health")
def health():
return {"ok": True}
@@ -520,16 +832,298 @@ def register_routes(app, database_url: str) -> None:
@app.get("/domains")
def list_domains():
"""Available pre-defined ontology domains for project creation."""
return [
{
"domain": ont.domain,
"entity_types": list(ont.entity_types),
"predicates": list(ont.predicates),
"attribute_count": len(ont.attributes),
"""Available ontology domains for project creation."""
with session_scope(database_url) as session:
rows = session.scalars(select(models.DomainDefinition)).all()
custom_by_domain = {row.domain: row for row in rows}
payloads = []
for domain, built_in in DOMAIN_ONTOLOGIES.items():
row = custom_by_domain.pop(domain, None)
if row is not None:
payloads.append(
domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=True,
is_custom=True,
description=row.description,
)
)
else:
payloads.append(
domain_summary_payload(
built_in,
is_builtin=True,
is_custom=False,
)
)
for row in sorted(custom_by_domain.values(), key=lambda item: item.domain):
payloads.append(
domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=False,
is_custom=True,
description=row.description,
)
)
return payloads
@app.post("/domains")
def create_domain(request: DomainDefinitionRequest):
domain = normalize_domain_name(request.domain)
with session_scope(database_url) as session:
existing = session.scalar(
select(models.DomainDefinition).where(models.DomainDefinition.domain == domain)
)
if existing is not None or domain in DOMAIN_ONTOLOGIES:
raise HTTPException(status_code=409, detail=f"Domain '{domain}' already exists.")
row = upsert_domain_definition(
session,
domain,
DomainDefinitionUpdateRequest(
description=request.description,
entity_types=request.entity_types,
predicates=request.predicates,
attributes=request.attributes,
aliases=request.aliases,
),
)
return domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=row.description,
)
@app.put("/domains/{domain}")
def update_domain(domain: str, request: DomainDefinitionUpdateRequest):
with session_scope(database_url) as session:
row = upsert_domain_definition(session, domain, request)
return domain_summary_payload(
domain_row_to_ontology(row),
is_builtin=row.domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=row.description,
)
@app.get("/domains/{domain}/discovery/jobs")
def list_domain_discovery_jobs(domain: str, limit: int = 20):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainDiscoveryJob)
.where(models.DomainDiscoveryJob.domain == normalized_domain)
.order_by(models.DomainDiscoveryJob.created_at.desc())
.limit(max(min(limit, 100), 1))
).all()
return [domain_discovery_job_payload(row) for row in rows]
@app.get("/domains/{domain}/reference-sources")
def list_domain_reference_sources(domain: str):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
rows = session.scalars(
select(models.DomainReferenceSource)
.where(models.DomainReferenceSource.domain == normalized_domain)
.order_by(models.DomainReferenceSource.updated_at.desc())
).all()
return [domain_reference_source_payload(row) for row in rows]
@app.post("/domains/{domain}/discovery/jobs")
def create_domain_discovery_job(
domain: str,
request: DomainDiscoveryRequest,
background_tasks: BackgroundTasks,
):
normalized_domain = normalize_domain_name(domain)
seed_urls = clean_seed_urls(request.seed_urls)
with session_scope(database_url) as session:
ensure_domain_definition(session, normalized_domain)
skipped_seed_urls: list[str] = []
if not request.force_recrawl:
skipped = already_crawled_reference_urls(session, normalized_domain, seed_urls)
skipped_seed_urls = [url for url in seed_urls if url in skipped]
seed_urls = [url for url in seed_urls if url not in skipped]
if not seed_urls:
job = models.DomainDiscoveryJob(
domain=normalized_domain,
status="completed",
seed_urls=[],
max_pages=max(min(request.max_pages, 200), 1),
max_depth=max(min(request.max_depth, 5), 0),
same_domain_only=request.same_domain_only,
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
respect_robots_txt=request.respect_robots_txt,
progress={
"visited_count": 0,
"queued_count": 0,
"candidate_count": 0,
"observation_count": 0,
"max_pages": max(min(request.max_pages, 200), 1),
"skipped_seed_urls": skipped_seed_urls,
"errors": [],
"pages": [],
},
result_summary={
"visited_count": 0,
"candidate_count": 0,
"skipped_seed_urls": skipped_seed_urls,
},
finished_at=models.utcnow(),
)
session.add(job)
session.flush()
return domain_discovery_job_payload(job)
job = models.DomainDiscoveryJob(
domain=normalized_domain,
status="pending",
seed_urls=seed_urls,
max_pages=max(min(request.max_pages, 200), 1),
max_depth=max(min(request.max_depth, 5), 0),
same_domain_only=request.same_domain_only,
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
respect_robots_txt=request.respect_robots_txt,
progress={
"visited_count": 0,
"queued_count": len(seed_urls),
"candidate_count": 0,
"observation_count": 0,
"max_pages": max(min(request.max_pages, 200), 1),
"skipped_seed_urls": skipped_seed_urls,
"errors": [],
"pages": [],
},
)
session.add(job)
session.flush()
response = domain_discovery_job_payload(job)
background_tasks.add_task(run_domain_discovery_job, database_url, response["job_id"])
return response
@app.get("/domains/{domain}/discovery/jobs/{job_id}")
def domain_discovery_job(domain: str, job_id: int):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is None or job.domain != normalized_domain:
raise HTTPException(status_code=404, detail="domain discovery job not found")
return domain_discovery_job_payload(job)
@app.post("/domains/{domain}/discovery/jobs/{job_id}/cancel")
def cancel_domain_discovery_job(domain: str, job_id: int):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
job = session.get(models.DomainDiscoveryJob, job_id)
if job is None or job.domain != normalized_domain:
raise HTTPException(status_code=404, detail="domain discovery job not found")
if job.status in {"completed", "failed", "canceled"}:
return domain_discovery_job_payload(job)
job.status = "cancel_requested"
job.error = "cancel requested by user"
return domain_discovery_job_payload(job)
@app.get("/domains/{domain}/candidates")
def list_domain_candidates(domain: str, status: str | None = None, limit: int = 200):
normalized_domain = normalize_domain_name(domain)
with session_scope(database_url) as session:
query = select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == normalized_domain
)
if status:
query = query.where(models.DomainSchemaCandidate.status == status)
rows = session.scalars(
query.order_by(
models.DomainSchemaCandidate.status,
models.DomainSchemaCandidate.candidate_type,
models.DomainSchemaCandidate.confidence.desc(),
models.DomainSchemaCandidate.name,
).limit(max(min(limit, 500), 1))
).all()
payload = []
for row in rows:
evidence = session.scalars(
select(models.DomainCandidateEvidence)
.where(models.DomainCandidateEvidence.candidate_id == row.id)
.order_by(models.DomainCandidateEvidence.created_at.desc())
.limit(3)
).all()
payload.append(domain_candidate_payload(row, evidence))
return payload
@app.post("/domains/{domain}/candidates/apply")
def apply_domain_candidates(domain: str, request: DomainCandidateApplyRequest):
normalized_domain = normalize_domain_name(domain)
if not request.candidate_ids:
raise HTTPException(status_code=400, detail="No candidates selected.")
with session_scope(database_url) as session:
definition = ensure_domain_definition(session, normalized_domain)
rows = session.scalars(
select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == normalized_domain,
models.DomainSchemaCandidate.id.in_(request.candidate_ids),
)
).all()
entity_types = set(definition.entity_types or [])
predicates = set(definition.predicates or [])
attributes = set(definition.attributes or [])
aliases = dict(definition.aliases or {})
applied = {"entity_type": 0, "predicate": 0, "attribute": 0, "alias": 0}
for row in rows:
if row.candidate_type == "entity_type":
entity_types.add(row.name)
applied["entity_type"] += 1
elif row.candidate_type == "predicate":
predicates.add(row.name)
applied["predicate"] += 1
elif row.candidate_type == "attribute":
attributes.add(row.name)
applied["attribute"] += 1
elif row.candidate_type == "alias":
metadata = dict(row.metadata_json or {})
targets = metadata.get("targets") or []
target = metadata.get("target") or (targets[0] if targets else None)
if target:
aliases[row.name] = str(target)
applied["alias"] += 1
row.status = "approved"
row.updated_at = models.utcnow()
definition.entity_types = sorted(entity_types)
definition.predicates = sorted(predicates)
definition.attributes = sorted(attributes)
definition.aliases = aliases
definition.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
definition.updated_at = models.utcnow()
return {
"ok": True,
"domain": normalized_domain,
"applied": applied,
"definition": domain_summary_payload(
domain_row_to_ontology(definition),
is_builtin=normalized_domain in DOMAIN_ONTOLOGIES,
is_custom=True,
description=definition.description,
),
}
for ont in DOMAIN_ONTOLOGIES.values()
]
@app.post("/domains/{domain}/candidates/{candidate_id}/status")
def update_domain_candidate_status(domain: str, candidate_id: int, request: DomainCandidateStatusRequest):
normalized_domain = normalize_domain_name(domain)
if request.status not in {"pending_review", "approved", "rejected"}:
raise HTTPException(status_code=400, detail="Unsupported candidate status.")
with session_scope(database_url) as session:
row = session.get(models.DomainSchemaCandidate, candidate_id)
if row is None or row.domain != normalized_domain:
raise HTTPException(status_code=404, detail="candidate not found")
row.status = request.status
row.updated_at = models.utcnow()
evidence = session.scalars(
select(models.DomainCandidateEvidence)
.where(models.DomainCandidateEvidence.candidate_id == row.id)
.order_by(models.DomainCandidateEvidence.created_at.desc())
.limit(3)
).all()
return domain_candidate_payload(row, evidence)
@app.post("/projects/reset")
def reset_project(request: ResetProjectRequest):
@@ -568,6 +1162,35 @@ def register_routes(app, database_url: str) -> None:
"deleted": deleted,
}
@app.post("/projects/{project_name}/reset")
def reset_project_by_name(project_name: str):
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
config = project_config_from_project_row(session, project)
deleted = repo.reset_project_runtime_data(project.id)
project = repo.upsert_project(config)
return {
"ok": True,
"name": project.name,
"domain": project.domain,
"reset": True,
"deleted": deleted,
}
@app.delete("/projects/{project_name}")
def delete_project(project_name: str):
with session_scope(database_url) as session:
project = KnowledgeRepository(session).get_project(project_name)
domain = project.domain
deleted = delete_project_data(session, project.id, include_project=True)
return {
"ok": True,
"name": project_name,
"domain": domain,
"deleted": deleted,
}
@app.get("/projects/{project_name}")
def project_detail(project_name: str):
with session_scope(database_url) as session:
@@ -630,7 +1253,8 @@ def register_routes(app, database_url: str) -> None:
@app.get("/ontology/{domain}")
def ontology(domain: str):
return ontology_to_dict(ontology_for_domain(domain))
with session_scope(database_url) as session:
return ontology_to_dict(ontology_for_domain_from_db(session, domain))
@app.get("/projects/{project_name}/ontology/registry")
def ontology_registry(project_name: str):

View File

@@ -56,6 +56,91 @@ class Source(Base):
project = relationship("Project", back_populates="sources")
class DomainDefinition(Base):
__tablename__ = "domain_definitions"
id = Column(Integer, primary_key=True)
domain = Column(String(80), unique=True, nullable=False, index=True)
description = Column(Text)
entity_types = Column(JSON, nullable=False, default=list)
predicates = Column(JSON, nullable=False, default=list)
attributes = Column(JSON, nullable=False, default=list)
aliases = Column(JSON, nullable=False, default=dict)
is_builtin_override = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainReferenceSource(Base):
__tablename__ = "domain_reference_sources"
__table_args__ = (
UniqueConstraint("domain", "url", name="uq_domain_reference_source"),
)
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
url = Column(Text, nullable=False)
label = Column(String(160))
status = Column(String(40), nullable=False, default="active", index=True)
last_crawled_at = Column(DateTime(timezone=True), nullable=True)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainDiscoveryJob(Base):
__tablename__ = "domain_discovery_jobs"
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
status = Column(String(40), nullable=False, default="pending", index=True)
seed_urls = Column(JSON, nullable=False, default=list)
max_pages = Column(Integer, nullable=False, default=20)
max_depth = Column(Integer, nullable=False, default=1)
same_domain_only = Column(Boolean, nullable=False, default=True)
fetcher = Column(String(80), nullable=False, default="requests")
respect_robots_txt = Column(Boolean, nullable=False, default=False)
error = Column(Text)
progress = Column(JSON, nullable=False, default=dict)
result_summary = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
class DomainSchemaCandidate(Base):
__tablename__ = "domain_schema_candidates"
__table_args__ = (
UniqueConstraint("domain", "candidate_type", "name", name="uq_domain_schema_candidate"),
)
id = Column(Integer, primary_key=True)
domain = Column(String(80), nullable=False, index=True)
job_id = Column(Integer, ForeignKey("domain_discovery_jobs.id"), nullable=True, index=True)
candidate_type = Column(String(40), nullable=False, index=True)
name = Column(String(160), nullable=False, index=True)
description = Column(Text)
confidence = Column(Float, nullable=False, default=0.5)
occurrence_count = Column(Integer, nullable=False, default=1)
status = Column(String(40), nullable=False, default="pending_review", index=True)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class DomainCandidateEvidence(Base):
__tablename__ = "domain_candidate_evidence"
id = Column(Integer, primary_key=True)
candidate_id = Column(Integer, ForeignKey("domain_schema_candidates.id"), nullable=False, index=True)
job_id = Column(Integer, ForeignKey("domain_discovery_jobs.id"), nullable=True, index=True)
url = Column(Text, nullable=False)
title = Column(Text)
snippet = Column(Text, nullable=False)
metadata_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False)
class Page(Base):
__tablename__ = "pages"
__table_args__ = (UniqueConstraint("project_id", "url", name="uq_page_project_url"),)

View File

@@ -0,0 +1,643 @@
from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
import json
import re
from typing import Any
from urllib.parse import urlparse
from sqlalchemy import select
from sqlalchemy.orm import Session
from crawler_platform.app.core.crawler.discovery import discover_links
from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetcher
from crawler_platform.app.core.crawler.page_classifier import classify_page
from crawler_platform.app.core.crawler.plugins import default_parser_registry
from crawler_platform.app.core.database import models
SCHEMA_TYPES = {"entity_type", "predicate", "attribute", "alias"}
RELATION_HINTS = {
"brand": ("Brand", "hasBrand"),
"브랜드": ("Brand", "hasBrand"),
"category": ("Category", "hasCategory"),
"카테고리": ("Category", "hasCategory"),
"ingredient": ("Ingredient", "hasIngredient"),
"ingredients": ("Ingredient", "hasIngredient"),
"성분": ("Ingredient", "hasIngredient"),
"effect": ("Effect", "hasEffect"),
"benefit": ("Effect", "hasEffect"),
"효과": ("Effect", "hasEffect"),
"효능": ("Effect", "hasEffect"),
"warning": ("Warning", "hasWarning"),
"주의": ("Warning", "hasWarning"),
"review": ("Review", "hasReview"),
"rating": ("Rating", "hasRating"),
"image": ("Image", "hasImage"),
"price": ("Price", "hasPrice"),
"가격": ("Price", "hasPrice"),
"offer": ("Offer", "hasOffer"),
}
@dataclass(slots=True)
class CandidateEvidence:
url: str
title: str | None
snippet: str
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class CandidateObservation:
candidate_type: str
name: str
description: str
confidence: float
evidence: CandidateEvidence
metadata: dict[str, Any] = field(default_factory=dict)
class DomainDiscoveryService:
def __init__(self, session: Session):
self.session = session
self.parser = default_parser_registry().get("generic")
def run_job(self, job_id: int) -> None:
job = self.session.get(models.DomainDiscoveryJob, job_id)
if job is None:
return
job.status = "running"
job.started_at = models.utcnow()
job.progress = {
"visited_count": 0,
"queued_count": len(job.seed_urls or []),
"candidate_count": 0,
"errors": [],
"pages": [],
}
self.session.commit()
visited: set[str] = set()
queued: set[str] = set()
queue: deque[tuple[str, int, str]] = deque()
for seed_url in job.seed_urls or []:
normalized = normalize_url(str(seed_url))
if normalized:
queue.append((normalized, 0, seed_host(normalized)))
queued.add(normalized)
self._upsert_reference_source(job.domain, normalized)
fetcher = make_fetcher(job.fetcher, rate_limit_per_minute=30)
robots = RobotsPolicy()
observation_count = 0
errors: list[str] = []
page_payloads: list[dict[str, Any]] = []
while queue and len(visited) < max(min(job.max_pages, 200), 1):
if self._cancel_requested(job):
job.status = "canceled"
job.finished_at = models.utcnow()
self.session.commit()
return
url, depth, root_host = queue.popleft()
if url in visited:
continue
visited.add(url)
if depth > max(job.max_depth, 0):
continue
if job.same_domain_only and seed_host(url) != root_host:
continue
decision = robots.check(url, job.respect_robots_txt)
if not decision.allowed:
errors.append(f"{decision.reason}: {url}")
self._save_progress(job, visited, queue, observation_count, errors, page_payloads)
continue
try:
fetched = fetcher.fetch(url)
parsed = self.parser.parse(fetched.analysis_html, fetched.final_url or url)
page_type = classify_page(
fetched.final_url or url,
parsed.title or fetched.title,
parsed.text or parsed.raw_text,
html=fetched.analysis_html,
source_zones=parsed.source_zones or [],
)
page_observations = mine_schema_candidates(
url=fetched.final_url or url,
title=parsed.title or fetched.title,
text=parsed.text or parsed.raw_text,
html=fetched.analysis_html,
page_type=page_type,
)
observation_count += len(page_observations)
if page_observations:
self._save_observations(job, page_observations)
page_payloads.append(
{
"url": fetched.final_url or url,
"title": parsed.title or fetched.title,
"page_type": page_type,
"candidate_count": len(page_observations),
"text_length": len(parsed.text or ""),
"warnings": [*fetched.warnings, *(parsed.extraction_warnings or [])],
}
)
self._enqueue_links(
html=fetched.analysis_html,
base_url=fetched.final_url or url,
root_host=root_host,
depth=depth,
max_depth=max(job.max_depth, 0),
same_domain_only=job.same_domain_only,
queue=queue,
queued=queued,
visited=visited,
)
except Exception as exc:
errors.append(f"{url}: {exc}")
self._save_progress(job, visited, queue, observation_count, errors, page_payloads)
reached_page_limit = len(visited) >= max(min(job.max_pages, 200), 1)
completion_reason = "max_pages_reached" if reached_page_limit else "no_more_links"
saved = self._candidate_count(job.domain)
job.status = "completed"
job.finished_at = models.utcnow()
job.result_summary = {
"visited_count": len(visited),
"candidate_count": saved,
"completion_reason": completion_reason,
"errors": errors[-20:],
"pages": page_payloads[-50:],
}
self._save_progress(
job,
visited,
queue,
observation_count,
errors,
page_payloads,
completion_reason=completion_reason,
)
self.session.commit()
def _save_progress(
self,
job: models.DomainDiscoveryJob,
visited: set[str],
queue: deque[tuple[str, int, str]],
observation_count: int,
errors: list[str],
page_payloads: list[dict[str, Any]],
completion_reason: str | None = None,
) -> None:
job.progress = {
"visited_count": len(visited),
"queued_count": len(queue),
"candidate_count": self._candidate_count(job.domain),
"observation_count": observation_count,
"max_pages": job.max_pages,
"errors": errors[-10:],
"pages": page_payloads[-10:],
}
if completion_reason:
job.progress["completion_reason"] = completion_reason
self.session.commit()
def _cancel_requested(self, job: models.DomainDiscoveryJob) -> bool:
self.session.expire(job)
return job.status == "cancel_requested"
def _candidate_count(self, domain: str) -> int:
return int(
self.session.query(models.DomainSchemaCandidate)
.filter(models.DomainSchemaCandidate.domain == domain)
.count()
)
def _save_observations(self, job: models.DomainDiscoveryJob, observations: list[CandidateObservation]) -> int:
if not observations:
return 0
by_key: dict[tuple[str, str], list[CandidateObservation]] = {}
for observation in observations:
if observation.candidate_type not in SCHEMA_TYPES:
continue
key = (observation.candidate_type, observation.name)
by_key.setdefault(key, []).append(observation)
saved_count = 0
for (candidate_type, name), rows in sorted(by_key.items()):
row = self.session.scalar(
select(models.DomainSchemaCandidate).where(
models.DomainSchemaCandidate.domain == job.domain,
models.DomainSchemaCandidate.candidate_type == candidate_type,
models.DomainSchemaCandidate.name == name,
)
)
confidence = min(max(max(item.confidence for item in rows) + min(len(rows), 10) * 0.02, 0.0), 0.98)
metadata = merge_metadata(item.metadata for item in rows)
if row is None:
row = models.DomainSchemaCandidate(
domain=job.domain,
job_id=job.id,
candidate_type=candidate_type,
name=name,
description=rows[0].description,
confidence=round(confidence, 4),
occurrence_count=len(rows),
metadata_json=metadata,
)
self.session.add(row)
self.session.flush()
else:
row.job_id = job.id
row.description = row.description or rows[0].description
row.confidence = max(row.confidence, round(confidence, 4))
row.occurrence_count += len(rows)
row.metadata_json = {**(row.metadata_json or {}), **metadata}
row.updated_at = models.utcnow()
for evidence in rows[:8]:
self.session.add(
models.DomainCandidateEvidence(
candidate_id=row.id,
job_id=job.id,
url=evidence.evidence.url,
title=evidence.evidence.title,
snippet=evidence.evidence.snippet[:1000],
metadata_json=evidence.evidence.metadata,
)
)
saved_count += 1
return saved_count
def _upsert_reference_source(self, domain: str, url: str) -> models.DomainReferenceSource:
row = self.session.scalar(
select(models.DomainReferenceSource).where(
models.DomainReferenceSource.domain == domain,
models.DomainReferenceSource.url == url,
)
)
if row is None:
row = models.DomainReferenceSource(domain=domain, url=url)
self.session.add(row)
self.session.flush()
row.last_crawled_at = models.utcnow()
row.updated_at = models.utcnow()
return row
def _enqueue_links(
self,
*,
html: str,
base_url: str,
root_host: str,
depth: int,
max_depth: int,
same_domain_only: bool,
queue: deque[tuple[str, int, str]],
queued: set[str],
visited: set[str],
) -> None:
if depth >= max_depth:
return
for link in discover_links(html, base_url, limit=100):
next_url = normalize_url(link.url)
if not next_url or next_url in queued or next_url in visited:
continue
if same_domain_only and seed_host(next_url) != root_host:
continue
queue.append((next_url, depth + 1, root_host))
queued.add(next_url)
def mine_schema_candidates(
*,
url: str,
title: str | None,
text: str,
html: str,
page_type: str,
) -> list[CandidateObservation]:
observations: list[CandidateObservation] = []
evidence_base = {"page_type": page_type}
page_type_entity = entity_type_from_page_type(page_type)
if page_type_entity:
observations.append(
observation(
"entity_type",
page_type_entity,
f"Detected from page type {page_type}.",
0.72,
url,
title,
title or first_sentence(text),
evidence_base,
)
)
soup = soup_from_html(html)
labels = collect_structural_labels(soup, text)
json_ld_types, json_ld_keys = collect_json_ld_schema(soup)
for schema_type, count in json_ld_types.items():
observations.append(
observation(
"entity_type",
pascal_case(schema_type),
"Detected from JSON-LD schema type.",
min(0.86 + count * 0.02, 0.95),
url,
title,
f"JSON-LD @type: {schema_type}",
{**evidence_base, "source": "json_ld"},
)
)
for key, count in json_ld_keys.items():
attr = attribute_name(key)
if attr:
observations.append(
observation(
"attribute",
attr,
"Detected from structured data property.",
min(0.78 + count * 0.02, 0.92),
url,
title,
f"Structured data property: {key}",
{**evidence_base, "source": "json_ld", "raw_label": key},
)
)
for label, count in labels.items():
attr = attribute_name(label)
if not attr:
continue
observations.append(
observation(
"attribute",
attr,
"Detected from page labels, tables, metadata, or key-value text.",
min(0.62 + count * 0.04, 0.88),
url,
title,
label,
{**evidence_base, "source": "page_label", "raw_label": label},
)
)
hint = relation_hint(label)
if hint:
entity_type, predicate = hint
observations.append(
observation(
"entity_type",
entity_type,
f"Detected from repeated label '{label}'.",
min(0.68 + count * 0.03, 0.9),
url,
title,
label,
{**evidence_base, "source": "relation_hint", "raw_label": label},
)
)
observations.append(
observation(
"predicate",
predicate,
f"Suggested relation for label '{label}'.",
min(0.7 + count * 0.03, 0.92),
url,
title,
label,
{
**evidence_base,
"source": "relation_hint",
"raw_label": label,
"object_type": entity_type,
},
)
)
observations.append(
observation(
"alias",
attr,
f"Map source field '{attr}' to relation '{predicate}'.",
0.66,
url,
title,
label,
{**evidence_base, "source": "relation_hint", "target": predicate},
)
)
return observations
def observation(
candidate_type: str,
name: str,
description: str,
confidence: float,
url: str,
title: str | None,
snippet: str,
metadata: dict[str, Any],
) -> CandidateObservation:
return CandidateObservation(
candidate_type=candidate_type,
name=name[:160],
description=description,
confidence=confidence,
evidence=CandidateEvidence(url=url, title=title, snippet=clean_snippet(snippet), metadata=metadata),
metadata=metadata,
)
def soup_from_html(html: str):
from bs4 import BeautifulSoup
return BeautifulSoup(html or "", "html.parser")
def collect_json_ld_schema(soup) -> tuple[Counter[str], Counter[str]]:
types: Counter[str] = Counter()
keys: Counter[str] = Counter()
for tag in soup.find_all("script"):
script_type = " ".join(str(item) for item in tag.get("type", "").split()).lower()
if "ld+json" not in script_type:
continue
raw = tag.string or tag.get_text(" ", strip=True)
try:
payload = json.loads(raw)
except Exception:
continue
visit_json_ld(payload, types, keys)
return types, keys
def visit_json_ld(value: Any, types: Counter[str], keys: Counter[str]) -> None:
if isinstance(value, list):
for item in value:
visit_json_ld(item, types, keys)
return
if not isinstance(value, dict):
return
for key, nested in value.items():
if key == "@type":
for item in ensure_list(nested):
if isinstance(item, str) and is_schema_name(item):
types[item] += 1
elif not key.startswith("@"):
keys[key] += 1
visit_json_ld(nested, types, keys)
def collect_structural_labels(soup, text: str) -> Counter[str]:
labels: Counter[str] = Counter()
for selector in ["th", "dt", "label", "[itemprop]", "[property]", "[name]"]:
for node in soup.select(selector):
label = ""
if selector == "[itemprop]":
label = node.get("itemprop", "")
elif selector == "[property]":
label = str(node.get("property", "")).split(":")[-1]
elif selector == "[name]":
label = str(node.get("name", "")).split(":")[-1]
else:
label = node.get_text(" ", strip=True)
add_label(labels, label)
for line in text.splitlines()[:500]:
match = re.match(r"^\s*([^:|]{2,40})\s*[:|]\s*(.{1,200})$", line)
if match:
add_label(labels, match.group(1))
return labels
def add_label(labels: Counter[str], label: str) -> None:
cleaned = clean_label(label)
if cleaned and not is_noisy_label(cleaned):
labels[cleaned] += 1
def relation_hint(label: str) -> tuple[str, str] | None:
lowered = label.lower()
for token, hint in RELATION_HINTS.items():
if token in lowered:
return hint
return None
def entity_type_from_page_type(page_type: str) -> str | None:
mapping = {
"ProductPage": "Product",
"CategoryPage": "Category",
"BrandStoryPage": "Brand",
"NoticePage": "Notice",
"PromotionPage": "Promotion",
"ReviewPage": "Review",
"BoardPage": "Article",
}
return mapping.get(page_type)
def attribute_name(label: str) -> str:
cleaned = clean_label(label)
if not cleaned or is_noisy_label(cleaned):
return ""
if re.search(r"[가-힣]", cleaned):
return re.sub(r"[\s/()-]+", "_", cleaned).strip("_")[:80]
words = re.findall(r"[A-Za-z0-9]+", cleaned)
if not words:
return ""
return "_".join(word.lower() for word in words)[:80]
def pascal_case(value: str) -> str:
cleaned = clean_label(value)
parts = re.findall(r"[A-Za-z0-9가-힣]+", cleaned)
if not parts:
return ""
return "".join(part[:1].upper() + part[1:] for part in parts)[:80]
def clean_label(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "").replace("\xa0", " ")).strip(" -_*:/|")
def is_schema_name(value: str) -> bool:
return 1 <= len(value) <= 80 and not value.startswith("http")
def is_noisy_label(value: str) -> bool:
lowered = value.lower().strip()
if len(lowered) < 2 or len(lowered) > 80:
return True
noisy = {
"class",
"style",
"viewport",
"charset",
"csrf-token",
"description",
"keywords",
"robots",
"generator",
"theme-color",
}
return lowered in noisy or lowered.startswith("twitter:")
def clean_snippet(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()[:1000]
def first_sentence(text: str) -> str:
cleaned = clean_snippet(text)
for delimiter in [". ", "\n", "", "다."]:
if delimiter in cleaned:
return cleaned.split(delimiter, 1)[0][:240]
return cleaned[:240]
def normalize_url(url: str) -> str:
return str(url or "").strip().rstrip("/")
def seed_host(url: str) -> str:
return urlparse(url).netloc.lower()
def ensure_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
return [value]
def merge_metadata(items) -> dict[str, Any]:
merged: dict[str, Any] = {}
sources: set[str] = set()
targets: set[str] = set()
object_types: set[str] = set()
for item in items:
merged.update(item or {})
if item.get("source"):
sources.add(str(item["source"]))
if item.get("target"):
targets.add(str(item["target"]))
if item.get("object_type"):
object_types.add(str(item["object_type"]))
if sources:
merged["sources"] = sorted(sources)
if targets:
merged["targets"] = sorted(targets)
if object_types:
merged["object_types"] = sorted(object_types)
return merged

View File

@@ -4,7 +4,40 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Ontology Construction Platform with Phase 5 GraphRAG and Phase 7 LLM" />
<title>Ontology Builder - AI-Powered Ontology Construction</title>
<meta name="theme-color" content="#0b1020" />
<title>Ontology Builder — AI-Powered Ontology Construction</title>
<!-- Avoid FOUC on dark mode: apply theme before stylesheets -->
<script>
(function () {
try {
var stored = localStorage.getItem("op.theme");
var prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme = stored === "light" || stored === "dark"
? stored
: prefersDark ? "dark" : "light";
if (theme === "dark") document.documentElement.classList.add("dark");
document.documentElement.dataset.theme = theme;
} catch (e) {}
})();
</script>
<!-- Fonts: Pretendard (KR/EN), Inter (display), JetBrains Mono (code) -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
as="style"
crossorigin
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View File

@@ -9,8 +9,14 @@
"version": "0.2.0",
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@reduxjs/toolkit": "^1.9.7",
"@tanstack/react-query": "^5.28.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cytoscape": "^3.33.3",
@@ -786,6 +792,44 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@@ -883,6 +927,669 @@
"node": ">= 8"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
@@ -1440,6 +2147,66 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1857,6 +2624,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -2240,6 +3019,12 @@
}
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2443,6 +3228,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3180,6 +3974,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
@@ -3212,6 +4053,28 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
@@ -3581,6 +4444,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3626,6 +4495,49 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@@ -21,8 +21,14 @@
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@reduxjs/toolkit": "^1.9.7",
"@tanstack/react-query": "^5.28.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cytoscape": "^3.33.3",

View File

@@ -6,12 +6,36 @@
"nav": {
"dashboard": "Dashboard",
"onboard": "New Project",
"sources": "Sources",
"crawl": "Crawl",
"research": "Research",
"editor": "Editor",
"sources": "Source Explorer",
"crawl": "Seed Crawl",
"pipeline": "Build Pipeline",
"analysis": "Page Analysis",
"schema": "Schema Designer",
"research": "Graph Research",
"editor": "Entity Manager",
"review": "Claim Review",
"quality": "Quality Inspector",
"graph": "Graph View",
"export": "Export / API",
"toggleSidebar": "Toggle sidebar",
"openCommandPalette": "Open command palette",
"searchPlaceholder": "Search pages, projects…",
"help": "Help",
"account": "Account",
"projects": "Projects",
"noProjects": "No projects yet",
"noProjectSelected": "Select project",
"selectProjectFirst": "Select a project from the dashboard first"
},
"navGroup": {
"workspace": "Workspace",
"build": "Build",
"review": "Review",
"toggleSidebar": "Toggle sidebar"
"publish": "Publish"
},
"theme": {
"label": "Theme",
"toggle": "Switch theme"
},
"dashboard": {
"title": "Ontology Builder",

View File

@@ -8,10 +8,34 @@
"onboard": "프로젝트 생성",
"sources": "참고 소스",
"crawl": "크롤 진행",
"pipeline": "빌드 파이프라인",
"analysis": "페이지 분석",
"schema": "스키마 디자이너",
"research": "자율 연구",
"editor": "온톨로지 편집",
"review": "결과 검토",
"toggleSidebar": "사이드바 토글"
"quality": "품질 검사",
"graph": "그래프 보기",
"export": "내보내기 / API",
"toggleSidebar": "사이드바 토글",
"openCommandPalette": "커맨드 팔레트 열기",
"searchPlaceholder": "페이지·프로젝트 검색…",
"help": "도움말",
"account": "계정",
"projects": "프로젝트",
"noProjects": "등록된 프로젝트가 없습니다",
"noProjectSelected": "프로젝트 선택",
"selectProjectFirst": "먼저 대시보드에서 프로젝트를 선택하세요"
},
"navGroup": {
"workspace": "워크스페이스",
"build": "구축",
"review": "검토",
"publish": "배포"
},
"theme": {
"label": "테마",
"toggle": "테마 전환"
},
"dashboard": {
"title": "온톨로지 빌더",

View File

@@ -1,6 +1,7 @@
import { Routes, Route } from "react-router-dom";
import AppShell from "@/components/layout/AppShell";
import OnboardingPage from "@/pages/OnboardingPage";
import DomainBuilderPage from "@/pages/DomainBuilderPage";
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
import CrawlPage from "@/pages/CrawlPage";
import ResearchPage from "@/pages/ResearchPage";
@@ -20,6 +21,7 @@ function App() {
<Route element={<AppShell />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/onboard" element={<OnboardingPage />} />
<Route path="/domains" element={<DomainBuilderPage />} />
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
<Route path="/crawl/:projectId" element={<CrawlPage />} />
<Route path="/pipeline/:projectId" element={<BuildPipelinePage />} />

View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { ErrorState } from "@/components/ui/error-state";
interface ErrorBoundaryState {
error: Error | null;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
/** Render prop for full custom fallback */
fallback?: (error: Error, reset: () => void) => React.ReactNode;
/** Reset boundary when any value in this array changes (like `useEffect` deps) */
resetKeys?: unknown[];
/** Called once when a render error is caught */
onError?: (error: Error, info: React.ErrorInfo) => void;
}
/**
* Catches render-time errors anywhere below it and shows a fallback UI.
* Wrap the whole app at the root (catches everything) and optionally
* wrap individual routes/sections for finer-grained recovery.
*
* Note: does NOT catch errors inside event handlers, async code, or
* server-side rendering. For those, surface errors via React Query
* `onError` or `toast.error()`.
*/
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
// eslint-disable-next-line no-console
console.error("[ErrorBoundary]", error, info);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (!this.state.error) return;
const prev = prevProps.resetKeys ?? [];
const next = this.props.resetKeys ?? [];
if (
prev.length !== next.length ||
prev.some((v, i) => !Object.is(v, next[i]))
) {
this.reset();
}
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div className="mx-auto max-w-3xl px-6 py-16">
<ErrorState
severity="error"
title="Something went wrong"
description="An unexpected error broke this view. You can try reloading just this section, or refresh the page."
detail={error.stack ?? error.message}
onRetry={this.reset}
/>
</div>
);
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import {
matchPath,
NavLink,
@@ -7,69 +7,39 @@ import {
useNavigate,
} from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useSelector, useDispatch } from "react-redux";
import {
LayoutDashboard,
UploadCloud,
Settings2,
Activity,
Brain,
Code2,
GitBranch,
Network,
ListChecks,
Menu,
SearchCheck,
ShieldCheck,
ClipboardCheck,
Layers3,
ChevronDown,
ChevronsLeft,
ChevronsRight,
Command,
HelpCircle,
Search,
Sparkles,
} from "lucide-react";
import { RootState } from "@/stores";
import { toggleSidebar } from "@/stores/slices/uiSlice";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useProjects } from "@/hooks/useProjects";
interface NavItem {
to?: string;
projectPath?: string;
labelKey: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
{ projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 },
{ projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck },
{ projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck },
{ projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network },
{ projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks },
{ projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck },
{ projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch },
{ projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 },
{ projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain },
];
const projectRoutePatterns = [
"/sources/:projectId",
"/crawl/:projectId",
"/pipeline/:projectId",
"/analysis/:projectId",
"/schema/:projectId",
"/research/:projectId",
"/editor/:projectId",
"/review/:projectId",
"/quality/:projectId",
"/graph/:projectId",
"/export/:projectId",
];
import { useThemeSync } from "@/hooks/useTheme";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { ThemeToggle } from "@/components/layout/ThemeToggle";
import {
ShortcutsOverlay,
type ShortcutGroup,
} from "@/components/ui/shortcuts-overlay";
import { Tooltip } from "@/components/ui/tooltip";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { NAV_GROUPS, PROJECT_ROUTE_PATTERNS, type NavItem } from "./navigation";
import {
CommandPalette,
useCommandPaletteShortcut,
} from "./CommandPalette";
function projectIdFromPathname(pathname: string): string | undefined {
for (const pattern of projectRoutePatterns) {
for (const pattern of PROJECT_ROUTE_PATTERNS) {
const match = matchPath({ path: pattern, end: false }, pathname);
if (match?.params.projectId) return match.params.projectId;
}
@@ -77,7 +47,7 @@ function projectIdFromPathname(pathname: string): string | undefined {
}
function workspaceSectionFromPathname(pathname: string): string | undefined {
for (const pattern of projectRoutePatterns) {
for (const pattern of PROJECT_ROUTE_PATTERNS) {
const match = matchPath({ path: pattern, end: false }, pathname);
if (match?.params.projectId) return pattern.split("/")[1];
}
@@ -85,11 +55,17 @@ function workspaceSectionFromPathname(pathname: string): string | undefined {
}
export default function AppShell() {
useThemeSync();
const { t } = useTranslation();
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const [paletteOpen, setPaletteOpen] = useState(false);
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [chordLeader, setChordLeader] = useState<string | null>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const routeProjectId = projectIdFromPathname(location.pathname);
const workspaceSection = workspaceSectionFromPathname(location.pathname);
const { data: projects } = useProjects();
@@ -99,9 +75,58 @@ export default function AppShell() {
!projects ||
projects.some((project) => project.name === routeProjectId);
const currentProjectId = routeProjectExists
? routeProjectId ?? fallbackProjectId
? (routeProjectId ?? fallbackProjectId)
: fallbackProjectId;
useCommandPaletteShortcut(() => setPaletteOpen((v) => !v));
// Build chord bindings from NAV_GROUPS (e.g. "g d" -> /, "g s" -> /sources/...).
const shortcutBindings = useMemo(() => {
const bindings: Record<string, () => void> = {};
const collectItem = (item: NavItem) => {
if (!item.shortcut) return;
const href =
item.to ??
(item.projectPath && currentProjectId
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
if (!href) return;
bindings[item.shortcut] = () => navigate(href);
};
NAV_GROUPS.forEach((g) => g.items.forEach(collectItem));
bindings["/"] = () => setPaletteOpen(true);
bindings["Shift+?"] = () => setShortcutsOpen(true);
return bindings;
}, [currentProjectId, navigate]);
useKeyboardShortcuts(shortcutBindings, {
disabled: paletteOpen || shortcutsOpen,
onChordStart: (leader) => setChordLeader(leader),
onChordEnd: () => setChordLeader(null),
});
// Build groups for the ShortcutsOverlay (memoized)
const shortcutGroups = useMemo<ShortcutGroup[]>(() => {
const navGroups: ShortcutGroup[] = NAV_GROUPS.map((g) => ({
title: g.label,
items: g.items
.filter((i) => i.shortcut)
.map((i) => ({ keys: i.shortcut!, label: i.label })),
})).filter((g) => g.items.length > 0);
return [
...navGroups,
{
title: "Global",
items: [
{ keys: "Mod+K", label: "Open command palette" },
{ keys: "/", label: "Open command palette" },
{ keys: "Shift+?", label: "Show this shortcuts panel" },
{ keys: "Escape", label: "Close any open dialog" },
],
},
];
}, []);
useEffect(() => {
if (routeProjectId && projects && !routeProjectExists) {
const nextPath =
@@ -119,98 +144,362 @@ export default function AppShell() {
workspaceSection,
]);
const switchProject = (projectName: string) => {
setProjectMenuOpen(false);
const section = workspaceSection ?? "sources";
navigate(`/${section}/${encodeURIComponent(projectName)}`);
};
// Detect macOS to show ⌘ vs Ctrl
const isMac =
typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const modKey = isMac ? "⌘" : "Ctrl";
return (
<div className="flex min-h-screen bg-background text-foreground">
<aside
className={cn(
"border-r bg-card transition-all duration-200 ease-out",
sidebarOpen ? "w-60" : "w-16",
)}
>
<div className="flex h-14 items-center justify-between px-4 border-b">
{sidebarOpen && (
<span className="text-sm font-semibold">
{t("app.title", "Ontology Builder")}
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={() => dispatch(toggleSidebar())}
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
<Sidebar
sidebarOpen={sidebarOpen}
currentProjectId={currentProjectId ?? null}
onToggle={() => dispatch(toggleSidebar())}
t={t}
/>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border bg-surface/80 px-4 backdrop-blur-md">
<ProjectSwitcher
projects={(projects ?? []).map((p) => p.name)}
currentProjectId={currentProjectId ?? null}
open={projectMenuOpen}
onOpenChange={setProjectMenuOpen}
onSelect={switchProject}
t={t}
/>
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="group flex h-9 max-w-md flex-1 items-center gap-2 rounded-md border border-border bg-background-subtle px-3 text-sm text-muted-foreground transition-colors hover:border-border-strong hover:text-foreground"
aria-label={t("nav.openCommandPalette", "Open command palette")}
>
<Menu className="h-4 w-4" />
</Button>
</div>
<Search className="h-4 w-4" />
<span className="flex-1 text-left">
{t("nav.searchPlaceholder", "Search pages, projects…")}
</span>
<kbd className="hidden gap-0.5 rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:inline-flex">
{modKey} K
</kbd>
</button>
{sidebarOpen && (
<div className="border-b px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("nav.currentProject", "Current project")}
</p>
<p className="mt-1 truncate text-sm font-medium">
{currentProjectId ??
t("nav.noProjectSelected", "Select a project")}
</p>
</div>
)}
<nav className="flex flex-col gap-1 p-2">
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
const href =
to ??
(currentProjectId
? `/${projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
if (!href) {
return (
<div
key={labelKey}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
title={t(
"nav.selectProjectFirst",
"Select a project from the dashboard first",
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</div>
);
}
return (
<NavLink
key={labelKey}
to={href}
end={href === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)
}
<div className="ml-auto flex items-center gap-1">
<Tooltip content={t("nav.help", "Keyboard shortcuts")} shortcut="?">
<Button
variant="ghost"
size="icon"
aria-label={t("nav.help", "Keyboard shortcuts")}
onClick={() => setShortcutsOpen(true)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && <span>{t(labelKey, label)}</span>}
</NavLink>
);
})}
</nav>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
<h1 className="text-sm font-medium text-muted-foreground">
{t("app.subtitle", "AI-powered ontology construction")}
</h1>
<HelpCircle className="h-4 w-4" />
</Button>
</Tooltip>
<ThemeToggle />
<div className="ml-2 h-7 w-px bg-border" aria-hidden />
<span
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-brand-900 dark:text-brand-200"
title={t("nav.account", "Account")}
>
ON
</span>
</div>
</header>
<main className="flex-1 overflow-auto">
<Outlet />
<main className="min-w-0 flex-1 overflow-auto">
<ErrorBoundary resetKeys={[location.pathname]}>
<Outlet />
</ErrorBoundary>
</main>
</div>
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
currentProjectId={currentProjectId ?? null}
/>
<ShortcutsOverlay
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
groups={shortcutGroups}
/>
<ChordHint leader={chordLeader} />
</div>
);
}
function ChordHint({ leader }: { leader: string | null }) {
if (!leader) return null;
return (
<div
className="pointer-events-none fixed bottom-6 left-1/2 z-40 -translate-x-1/2 animate-fade-in-up"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg">
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs">
{leader}
</kbd>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">awaiting follower key</span>
</div>
</div>
);
}
/* ============================================================
* Sidebar
* ========================================================== */
function Sidebar({
sidebarOpen,
currentProjectId,
onToggle,
t,
}: {
sidebarOpen: boolean;
currentProjectId: string | null;
onToggle: () => void;
t: TFunction;
}) {
return (
<aside
className={cn(
"sticky top-0 flex h-screen flex-col border-r border-border bg-surface transition-[width] duration-fast ease-out-expo",
sidebarOpen ? "w-64" : "w-[60px]",
)}
>
<div className="flex h-14 items-center gap-2 border-b border-border px-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-brand-600 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{sidebarOpen && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{t("app.title", "Ontology Builder")}
</p>
<p className="truncate text-2xs text-muted-foreground">
{t("app.subtitle", "AI-powered ontology construction")}
</p>
</div>
)}
<Button
variant="ghost"
size="icon"
onClick={onToggle}
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
className="h-7 w-7"
>
{sidebarOpen ? (
<ChevronsLeft className="h-4 w-4" />
) : (
<ChevronsRight className="h-4 w-4" />
)}
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_GROUPS.map((group) => (
<div key={group.id} className="px-2 pb-2">
{sidebarOpen && (
<p className="px-3 pb-1 pt-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(group.labelKey, group.label)}
</p>
)}
<ul className="flex flex-col gap-0.5">
{group.items.map((item) => {
const href =
item.to ??
(item.projectPath && currentProjectId
? `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`
: undefined);
const Icon = item.icon;
if (!href) {
return (
<li key={item.id}>
<div
className={cn(
"flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-muted-foreground/40",
!sidebarOpen && "justify-center px-2",
)}
title={t(
"nav.selectProjectFirst",
"Select a project from the dashboard first",
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && (
<span className="truncate">
{t(item.labelKey, item.label)}
</span>
)}
</div>
</li>
);
}
return (
<li key={item.id}>
<NavLink
to={href}
end={href === "/"}
title={
!sidebarOpen ? t(item.labelKey, item.label) : undefined
}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-2.5 rounded-md px-3 py-2 text-sm transition-colors",
!sidebarOpen && "justify-center px-2",
isActive
? "bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-200"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden
className="absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-brand-600 dark:bg-brand-400"
/>
)}
<Icon className="h-4 w-4 flex-shrink-0" />
{sidebarOpen && (
<>
<span className="flex-1 truncate">
{t(item.labelKey, item.label)}
</span>
{item.shortcut && (
<kbd className="ml-1 hidden flex-shrink-0 rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-2xs text-muted-foreground group-hover:inline-flex">
{item.shortcut}
</kbd>
)}
</>
)}
</>
)}
</NavLink>
</li>
);
})}
</ul>
</div>
))}
</nav>
{sidebarOpen && (
<div className="border-t border-border p-3 text-2xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>v0.2.0</span>
<span className="pill-info">beta</span>
</div>
</div>
)}
</aside>
);
}
/* ============================================================
* Project Switcher (header dropdown)
* ========================================================== */
function ProjectSwitcher({
projects,
currentProjectId,
open,
onOpenChange,
onSelect,
t,
}: {
projects: string[];
currentProjectId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (name: string) => void;
t: TFunction;
}) {
const ref = useMemo(() => ({ current: null as HTMLDivElement | null }), []);
useEffect(() => {
if (!open) return;
const onClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onOpenChange(false);
}
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open, onOpenChange, ref]);
return (
<div className="relative" ref={(node) => (ref.current = node)}>
<button
type="button"
onClick={() => onOpenChange(!open)}
className="flex h-9 items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent"
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
{(currentProjectId ?? "—").slice(0, 1).toUpperCase()}
</span>
<span className="max-w-[160px] truncate">
{currentProjectId ?? t("nav.noProjectSelected", "Select project")}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
{open && (
<div
role="listbox"
className="absolute left-0 top-full z-40 mt-1.5 w-72 overflow-hidden rounded-lg border border-border bg-popover shadow-lg animate-fade-in-up"
>
<div className="border-b border-border px-3 py-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{t("nav.projects", "Projects")}
</div>
<ul className="max-h-72 overflow-y-auto py-1">
{projects.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
{t("nav.noProjects", "No projects yet.")}
</li>
)}
{projects.map((name) => (
<li key={name}>
<button
type="button"
onClick={() => onSelect(name)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
name === currentProjectId
? "bg-accent text-foreground"
: "text-foreground hover:bg-accent/60",
)}
role="option"
aria-selected={name === currentProjectId}
>
<span className="flex h-5 w-5 items-center justify-center rounded bg-brand-100 text-2xs font-bold text-brand-700 dark:bg-brand-900 dark:text-brand-200">
{name.slice(0, 1).toUpperCase()}
</span>
<span className="truncate">{name}</span>
</button>
</li>
))}
</ul>
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-3 py-2 text-2xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Command className="h-3 w-3" />
Press <kbd className="rounded border border-border bg-surface px-1 py-0.5">K</kbd> for palette
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,291 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { Search, FolderKanban, Sun, Moon, Monitor, CornerDownLeft } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import { Dialog, DialogPanel } from "@/components/ui/dialog";
import { useProjects } from "@/hooks/useProjects";
import { NAV_GROUPS, type NavItem } from "./navigation";
import { cn } from "@/lib/utils";
import { cycleTheme, setTheme } from "@/stores/slices/uiSlice";
import type { RootState } from "@/stores";
type CommandItem = {
id: string;
group: string;
label: string;
hint?: string;
keywords?: string[];
icon: React.ComponentType<{ className?: string }>;
onSelect: () => void;
};
function score(query: string, item: CommandItem): number {
if (!query) return 1;
const q = query.toLowerCase();
const haystack = [item.label, item.group, ...(item.keywords ?? [])]
.join(" ")
.toLowerCase();
if (haystack.includes(q)) return 2;
// fuzzy: every char in q appears in order
let pos = 0;
for (const ch of q) {
pos = haystack.indexOf(ch, pos);
if (pos === -1) return 0;
pos++;
}
return 1;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current project to use for project-scoped nav items */
currentProjectId?: string | null;
}
export function CommandPalette({
open,
onOpenChange,
currentProjectId,
}: CommandPaletteProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const theme = useSelector((s: RootState) => s.ui.theme);
const { data: projects } = useProjects();
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
const navHref = (item: NavItem): string | undefined => {
if (item.to) return item.to;
if (item.projectPath && currentProjectId) {
return `/${item.projectPath}/${encodeURIComponent(currentProjectId)}`;
}
return undefined;
};
const close = React.useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const commands = React.useMemo<CommandItem[]>(() => {
const list: CommandItem[] = [];
NAV_GROUPS.forEach((group) => {
group.items.forEach((item) => {
const href = navHref(item);
list.push({
id: `nav:${item.id}`,
group: group.label,
label: item.label,
hint: item.shortcut,
keywords: item.keywords,
icon: item.icon,
onSelect: () => {
if (href) navigate(href);
close();
},
});
});
});
(projects ?? []).slice(0, 12).forEach((project) => {
list.push({
id: `project:${project.name}`,
group: "Projects",
label: project.name,
keywords: ["switch", "open", "project"],
icon: FolderKanban,
onSelect: () => {
navigate(`/sources/${encodeURIComponent(project.name)}`);
close();
},
});
});
const themeOpts: Array<{
mode: "light" | "dark" | "system";
label: string;
icon: React.ComponentType<{ className?: string }>;
}> = [
{ mode: "light", label: "Switch to Light theme", icon: Sun },
{ mode: "dark", label: "Switch to Dark theme", icon: Moon },
{ mode: "system", label: "Use System theme", icon: Monitor },
];
themeOpts.forEach((opt) => {
list.push({
id: `theme:${opt.mode}`,
group: "Settings",
label: opt.label,
keywords: ["theme", "dark", "light", "mode", opt.mode],
icon: opt.icon,
onSelect: () => {
dispatch(setTheme(opt.mode));
close();
},
});
});
list.push({
id: "theme:cycle",
group: "Settings",
label: `Cycle theme (current: ${theme})`,
keywords: ["theme", "toggle"],
icon: Sun,
onSelect: () => {
dispatch(cycleTheme());
close();
},
});
return list;
}, [projects, navigate, currentProjectId, dispatch, close, theme]);
const filtered = React.useMemo(() => {
const scored = commands
.map((cmd) => ({ cmd, s: score(query, cmd) }))
.filter((x) => x.s > 0);
scored.sort((a, b) => b.s - a.s);
return scored.map((x) => x.cmd);
}, [commands, query]);
React.useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
}
}, [open]);
React.useEffect(() => {
setActiveIndex(0);
}, [query]);
React.useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(
`[data-cmd-index="${activeIndex}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter") {
event.preventDefault();
filtered[activeIndex]?.onSelect();
}
};
// Group by section preserving filtered order
const grouped: Array<{ group: string; items: CommandItem[] }> = [];
filtered.forEach((cmd) => {
const last = grouped[grouped.length - 1];
if (last && last.group === cmd.group) last.items.push(cmd);
else grouped.push({ group: cmd.group, items: [cmd] });
});
let runningIndex = -1;
return (
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel="Command palette">
<DialogPanel className="mx-auto mt-20 max-w-xl">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search className="h-4 w-4 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search pages, projects, settings…"
aria-label="Command palette input"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
ESC
</kbd>
</div>
<div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1.5">
{filtered.length === 0 && (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</p>
)}
{grouped.map((section) => (
<div key={section.group} className="px-1.5">
<div className="px-3 pb-1 pt-2 text-2xs font-medium uppercase tracking-wider text-muted-foreground">
{section.group}
</div>
{section.items.map((cmd) => {
runningIndex += 1;
const isActive = runningIndex === activeIndex;
const Icon = cmd.icon;
return (
<button
key={cmd.id}
data-cmd-index={runningIndex}
type="button"
onMouseEnter={() => setActiveIndex(runningIndex)}
onClick={cmd.onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm",
isActive
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50",
)}
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{cmd.label}</span>
{cmd.hint && (
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 text-2xs text-muted-foreground">
{cmd.hint}
</kbd>
)}
{isActive && (
<CornerDownLeft className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
);
})}
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-border bg-background-subtle px-4 py-2 text-2xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-border bg-surface px-1.5 py-0.5"></kbd>
select
</span>
</div>
<span>{filtered.length} results</span>
</div>
</DialogPanel>
</Dialog>
);
}
/**
* Global hook — binds ⌘K / Ctrl+K to toggle the palette.
*/
export function useCommandPaletteShortcut(onToggle: () => void) {
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
const isMod = event.metaKey || event.ctrlKey;
if (isMod && event.key.toLowerCase() === "k") {
event.preventDefault();
onToggle();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onToggle]);
}

View File

@@ -0,0 +1,129 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface BreadcrumbItem {
label: React.ReactNode;
to?: string;
}
interface PageHeaderProps {
/** Optional eyebrow / breadcrumb trail. The last item is rendered as plain text. */
breadcrumbs?: BreadcrumbItem[];
title: React.ReactNode;
/** Optional icon shown left of the title */
icon?: React.ComponentType<{ className?: string }>;
description?: React.ReactNode;
/** Pills/badges rendered next to the title (e.g. status, environment) */
meta?: React.ReactNode;
/** Buttons / dropdowns on the right */
actions?: React.ReactNode;
/** Secondary nav (tabs) row */
tabs?: React.ReactNode;
className?: string;
}
export function PageHeader({
breadcrumbs,
title,
icon: Icon,
description,
meta,
actions,
tabs,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"border-b border-border bg-surface",
className,
)}
>
<div className="mx-auto max-w-7xl px-6 pb-4 pt-5">
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs items={breadcrumbs} className="mb-2" />
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5">
{Icon && (
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
<Icon className="h-5 w-5" />
</span>
)}
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
{meta && <div className="flex items-center gap-2">{meta}</div>}
</div>
{description && (
<p className="mt-1.5 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{actions && (
<div className="flex flex-shrink-0 items-center gap-2">
{actions}
</div>
)}
</div>
</div>
{tabs && (
<div className="mx-auto max-w-7xl px-6">
<div className="flex items-center gap-1 overflow-x-auto">{tabs}</div>
</div>
)}
</div>
);
}
export function Breadcrumbs({
items,
className,
}: {
items: BreadcrumbItem[];
className?: string;
}) {
return (
<nav
aria-label="Breadcrumb"
className={cn("flex items-center text-xs text-muted-foreground", className)}
>
<ol className="flex flex-wrap items-center gap-1">
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<li key={idx} className="flex items-center gap-1">
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded px-1 hover:text-foreground hover:underline underline-offset-2"
>
{item.label}
</Link>
) : (
<span
className={cn(
"px-1",
isLast && "font-medium text-foreground",
)}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
)}
{!isLast && (
<ChevronRight className="h-3 w-3 flex-shrink-0 opacity-60" />
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,37 @@
import { useDispatch, useSelector } from "react-redux";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cycleTheme, type ThemeMode } from "@/stores/slices/uiSlice";
import type { RootState } from "@/stores";
const ICONS: Record<ThemeMode, React.ComponentType<{ className?: string }>> = {
light: Sun,
dark: Moon,
system: Monitor,
};
const LABELS: Record<ThemeMode, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
export function ThemeToggle() {
const { t } = useTranslation();
const dispatch = useDispatch();
const mode = useSelector((s: RootState) => s.ui.theme);
const Icon = ICONS[mode];
return (
<Button
variant="ghost"
size="icon"
onClick={() => dispatch(cycleTheme())}
aria-label={t("theme.toggle", "Switch theme") + ` (${LABELS[mode]})`}
title={`${t("theme.label", "Theme")}: ${LABELS[mode]}`}
>
<Icon className="h-4 w-4" />
</Button>
);
}

View File

@@ -0,0 +1,205 @@
import type { ComponentType } from "react";
import {
Activity,
Brain,
ClipboardCheck,
Code2,
GitBranch,
Globe2,
Layers3,
LayoutDashboard,
ListChecks,
Network,
SearchCheck,
Settings2,
ShieldCheck,
UploadCloud,
} from "lucide-react";
export type NavGroupId = "workspace" | "build" | "review" | "publish";
export interface NavItem {
/** Stable identifier — used as React key and palette command id */
id: string;
/** i18n key */
labelKey: string;
/** Default label (English) */
label: string;
/** Static route — mutually exclusive with projectPath */
to?: string;
/** Path segment under /:projectId — joined as `/${projectPath}/${projectId}` */
projectPath?: string;
icon: ComponentType<{ className?: string }>;
/** Search keywords for the command palette (in addition to label) */
keywords?: string[];
/** Optional shortcut hint shown next to the item */
shortcut?: string;
}
export interface NavGroup {
id: NavGroupId;
labelKey: string;
label: string;
items: NavItem[];
}
export const NAV_GROUPS: NavGroup[] = [
{
id: "workspace",
labelKey: "navGroup.workspace",
label: "Workspace",
items: [
{
id: "dashboard",
to: "/",
labelKey: "nav.dashboard",
label: "Dashboard",
icon: LayoutDashboard,
keywords: ["home", "overview"],
shortcut: "g d",
},
{
id: "onboard",
to: "/onboard",
labelKey: "nav.onboard",
label: "New Project",
icon: UploadCloud,
keywords: ["create", "start", "wizard"],
shortcut: "g n",
},
{
id: "domains",
to: "/domains",
labelKey: "nav.domains",
label: "Domain Builder",
icon: Globe2,
keywords: ["domain", "schema", "reference", "crawler"],
shortcut: "g m",
},
],
},
{
id: "build",
labelKey: "navGroup.build",
label: "Build",
items: [
{
id: "sources",
projectPath: "sources",
labelKey: "nav.sources",
label: "Source Explorer",
icon: Settings2,
keywords: ["urls", "domains", "seed"],
shortcut: "g s",
},
{
id: "crawl",
projectPath: "crawl",
labelKey: "nav.crawl",
label: "Seed Crawl",
icon: Activity,
keywords: ["scrape", "fetch"],
shortcut: "g c",
},
{
id: "pipeline",
projectPath: "pipeline",
labelKey: "nav.pipeline",
label: "Build Pipeline",
icon: Layers3,
keywords: ["jobs", "run", "extract"],
shortcut: "g p",
},
{
id: "analysis",
projectPath: "analysis",
labelKey: "nav.analysis",
label: "Page Analysis",
icon: SearchCheck,
keywords: ["html", "structure"],
shortcut: "g a",
},
{
id: "schema",
projectPath: "schema",
labelKey: "nav.schema",
label: "Schema Designer",
icon: ShieldCheck,
keywords: ["types", "predicate", "model"],
shortcut: "g t",
},
{
id: "research",
projectPath: "research",
labelKey: "nav.research",
label: "Graph Research",
icon: Brain,
keywords: ["graphrag", "query", "ai", "autonomous"],
shortcut: "g r",
},
],
},
{
id: "review",
labelKey: "navGroup.review",
label: "Review",
items: [
{
id: "editor",
projectPath: "editor",
labelKey: "nav.editor",
label: "Entity Manager",
icon: Network,
keywords: ["entities", "nodes", "merge"],
shortcut: "g e",
},
{
id: "review",
projectPath: "review",
labelKey: "nav.review",
label: "Claim Review",
icon: ListChecks,
keywords: ["validate", "approve", "queue"],
shortcut: "g v",
},
{
id: "quality",
projectPath: "quality",
labelKey: "nav.quality",
label: "Quality Inspector",
icon: ClipboardCheck,
keywords: ["metrics", "qa", "audit"],
shortcut: "g q",
},
{
id: "graph",
projectPath: "graph",
labelKey: "nav.graph",
label: "Graph View",
icon: GitBranch,
keywords: ["visualization", "network"],
shortcut: "g g",
},
],
},
{
id: "publish",
labelKey: "navGroup.publish",
label: "Publish",
items: [
{
id: "export",
projectPath: "export",
labelKey: "nav.export",
label: "Export / API",
icon: Code2,
keywords: ["download", "rest", "json"],
shortcut: "g x",
},
],
},
];
export const PROJECT_ROUTE_PATTERNS = NAV_GROUPS.flatMap((g) =>
g.items.filter((i) => i.projectPath).map((i) => `/${i.projectPath}/:projectId`),
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
import * as React from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Dialog primitive — thin styled wrapper over @radix-ui/react-dialog.
* Radix handles focus trap, scroll lock, Escape, focus restoration, and ARIA.
*
* The exported API mirrors the previous custom implementation so consumers
* (Drawer, CommandPalette, ShortcutsOverlay) didn't need restructuring:
*
* <Dialog open onOpenChange>
* <DialogPanel ariaLabel="…">
* <DialogHeader onClose>...</DialogHeader>
* <DialogBody>...</DialogBody>
* <DialogFooter>...</DialogFooter>
* </DialogPanel>
* </Dialog>
*/
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
/** Compatibility shim — ariaLabel is now supplied on DialogPanel directly. */
ariaLabel?: string;
/** Block close from overlay click */
closeOnOverlayClick?: boolean;
/** Block close from Escape */
closeOnEscape?: boolean;
}
const DialogCtx = React.createContext<{
ariaLabel?: string;
closeOnOverlayClick: boolean;
closeOnEscape: boolean;
}>({ closeOnOverlayClick: true, closeOnEscape: true });
export function Dialog({
open,
onOpenChange,
children,
ariaLabel,
closeOnOverlayClick = true,
closeOnEscape = true,
}: DialogProps) {
return (
<DialogCtx.Provider
value={{ ariaLabel, closeOnOverlayClick, closeOnEscape }}
>
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
{children}
</RadixDialog.Root>
</DialogCtx.Provider>
);
}
export const DialogTrigger = RadixDialog.Trigger;
export const DialogClose = RadixDialog.Close;
interface DialogPanelProps extends React.HTMLAttributes<HTMLDivElement> {
/** Accessible label when the panel has no DialogTitle */
ariaLabel?: string;
/** Accessible description id reference */
ariaDescribedBy?: string;
}
export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => {
const ctx = React.useContext(DialogCtx);
const label = ariaLabel ?? ctx.ariaLabel;
return (
<RadixDialog.Portal>
<RadixDialog.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-fade-in",
)}
/>
<RadixDialog.Content
ref={ref}
aria-label={label}
aria-describedby={ariaDescribedBy}
onInteractOutside={(e) => {
if (!ctx.closeOnOverlayClick) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (!ctx.closeOnEscape) e.preventDefault();
}}
className={cn(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden",
"rounded-xl border border-border bg-surface-raised shadow-xl outline-none",
"data-[state=open]:animate-fade-in",
className,
)}
{...props}
>
{/* Radix warns if no Title is present. If consumer didn't provide one,
expose ariaLabel via VisuallyHidden Title as a fallback. */}
{label && !hasDialogTitle(children) && (
<RadixDialog.Title className="sr-only">{label}</RadixDialog.Title>
)}
{children}
</RadixDialog.Content>
</RadixDialog.Portal>
);
},
);
DialogPanel.displayName = "DialogPanel";
/** Best-effort detection: does the children tree include a <DialogTitle>? */
function hasDialogTitle(node: React.ReactNode): boolean {
let found = false;
React.Children.forEach(node, (child) => {
if (found) return;
if (!React.isValidElement(child)) return;
if (child.type === DialogTitle) {
found = true;
return;
}
const subtree = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (subtree && hasDialogTitle(subtree)) found = true;
});
return found;
}
export function DialogHeader({
className,
children,
onClose,
...props
}: React.HTMLAttributes<HTMLDivElement> & { onClose?: () => void }) {
return (
<div
className={cn(
"flex items-start justify-between gap-4 border-b border-border px-5 py-4",
className,
)}
{...props}
>
<div className="min-w-0 flex-1">{children}</div>
{onClose && (
<RadixDialog.Close asChild>
<button
type="button"
onClick={onClose}
className="-mr-2 -mt-1 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</RadixDialog.Close>
)}
</div>
);
}
export const DialogTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<RadixDialog.Title asChild>
<h2
ref={ref}
className={cn("text-base font-semibold text-foreground", className)}
{...props}
/>
</RadixDialog.Title>
));
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<RadixDialog.Description asChild>
<p
ref={ref}
className={cn("mt-1 text-sm text-muted-foreground", className)}
{...props}
/>
</RadixDialog.Description>
));
DialogDescription.displayName = "DialogDescription";
export function DialogBody({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex-1 overflow-y-auto px-5 py-4", className)}
{...props}
/>
);
}
export function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-row-reverse items-center gap-2 border-t border-border px-5 py-3",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import * as RadixDialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
type Side = "right" | "left";
type Size = "sm" | "md" | "lg" | "xl";
const SIZE_CLASS: Record<Size, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
xl: "max-w-4xl",
};
interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: Side;
size?: Size;
title?: React.ReactNode;
description?: React.ReactNode;
footer?: React.ReactNode;
children?: React.ReactNode;
/** Slot rendered on the header right (e.g. tabs, badges, secondary actions) */
headerExtra?: React.ReactNode;
className?: string;
/** Block close when clicking the overlay */
closeOnOverlayClick?: boolean;
}
/**
* Slide-in drawer using Radix Dialog. Comes with focus trap, scroll lock,
* Escape close, and ARIA wiring. Use for entity editors, claim reviews,
* detail inspectors — anything that benefits from preserving page context.
*/
export function Drawer({
open,
onOpenChange,
side = "right",
size = "lg",
title,
description,
footer,
children,
headerExtra,
className,
closeOnOverlayClick = true,
}: DrawerProps) {
const slideOpen =
side === "right"
? "data-[state=open]:animate-slide-in-right"
: "data-[state=open]:animate-fade-in-up";
const sidePos = side === "right" ? "right-0" : "left-0";
const borderSide = side === "right" ? "border-l" : "border-r";
const titleId = React.useId();
const descId = React.useId();
return (
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
<RadixDialog.Portal>
<RadixDialog.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-fade-in",
)}
/>
<RadixDialog.Content
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
onInteractOutside={(e) => {
if (!closeOnOverlayClick) e.preventDefault();
}}
className={cn(
"fixed top-0 bottom-0 z-50 flex w-full flex-col",
"bg-surface-raised border-border shadow-2xl outline-none",
sidePos,
borderSide,
SIZE_CLASS[size],
slideOpen,
className,
)}
>
{(title || description || headerExtra) && (
<header className="flex items-start justify-between gap-4 border-b border-border px-6 py-4">
<div className="min-w-0 flex-1">
{title && (
<RadixDialog.Title asChild>
<h2
id={titleId}
className="truncate text-base font-semibold text-foreground"
>
{title}
</h2>
</RadixDialog.Title>
)}
{!title && (
// Radix requires a Title for accessibility; provide a hidden one
<RadixDialog.Title className="sr-only">
Panel
</RadixDialog.Title>
)}
{description && (
<RadixDialog.Description asChild>
<p
id={descId}
className="mt-1 text-sm text-muted-foreground"
>
{description}
</p>
</RadixDialog.Description>
)}
</div>
<div className="flex items-center gap-2">
{headerExtra}
<RadixDialog.Close asChild>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close drawer"
>
<X className="h-4 w-4" />
</button>
</RadixDialog.Close>
</div>
</header>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{children}
</div>
{footer && (
<footer className="flex items-center justify-end gap-2 border-t border-border bg-background-subtle px-6 py-3">
{footer}
</footer>
)}
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}

View File

@@ -0,0 +1,71 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Variant = "default" | "muted" | "danger" | "info";
interface EmptyStateProps {
icon?: React.ComponentType<{ className?: string }>;
title: React.ReactNode;
description?: React.ReactNode;
/** Primary call-to-action (usually a <Button>) */
primaryAction?: React.ReactNode;
/** Optional secondary action */
secondaryAction?: React.ReactNode;
/** Style variant — danger for errors, info for empty-by-design */
variant?: Variant;
/** Compact vertical padding for inline / panel usage */
compact?: boolean;
className?: string;
}
const ICON_BG: Record<Variant, string> = {
default: "bg-muted text-muted-foreground",
muted: "bg-muted text-muted-foreground",
danger: "bg-danger-subtle text-danger",
info: "bg-info-subtle text-info",
};
export function EmptyState({
icon: Icon,
title,
description,
primaryAction,
secondaryAction,
variant = "default",
compact = false,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-4 text-center",
compact ? "py-8" : "py-16",
className,
)}
role="status"
>
{Icon && (
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full",
ICON_BG[variant],
)}
>
<Icon className="h-6 w-6" />
</div>
)}
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{primaryAction}
{secondaryAction}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import * as React from "react";
import { AlertCircle, AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
type Severity = "error" | "warning" | "offline";
interface ErrorStateProps {
/** Visual severity */
severity?: Severity;
/** Short headline, e.g. "Could not load entities" */
title: React.ReactNode;
/** Longer explanation. Pass an `Error` and the message will be extracted. */
description?: React.ReactNode | Error;
/** Retry handler — adds a Retry button when provided */
onRetry?: () => void;
/** Disable the retry button (e.g. during a refetch) */
retrying?: boolean;
/** Inline vs full block layout */
inline?: boolean;
/** Compact vertical padding */
compact?: boolean;
/** Show technical details (stack) in a collapsible block */
detail?: string;
className?: string;
}
const ICON: Record<Severity, React.ComponentType<{ className?: string }>> = {
error: AlertCircle,
warning: AlertTriangle,
offline: WifiOff,
};
const TONE: Record<
Severity,
{ icon: string; ring: string; bg: string; text: string }
> = {
error: {
icon: "text-danger",
ring: "border-danger-border",
bg: "bg-danger-subtle",
text: "text-danger",
},
warning: {
icon: "text-warning",
ring: "border-warning-border",
bg: "bg-warning-subtle",
text: "text-warning",
},
offline: {
icon: "text-muted-foreground",
ring: "border-border",
bg: "bg-muted",
text: "text-muted-foreground",
},
};
export function ErrorState({
severity = "error",
title,
description,
onRetry,
retrying,
inline = false,
compact = false,
detail,
className,
}: ErrorStateProps) {
const Icon = ICON[severity];
const tone = TONE[severity];
const desc =
description instanceof Error ? description.message : description;
if (inline) {
return (
<div
role="alert"
className={cn(
"flex items-start gap-3 rounded-md border px-4 py-3",
tone.ring,
tone.bg,
className,
)}
>
<Icon className={cn("mt-0.5 h-4 w-4 flex-shrink-0", tone.icon)} />
<div className="min-w-0 flex-1 text-sm">
<div className={cn("font-medium", tone.text)}>{title}</div>
{desc && (
<div className="mt-0.5 text-muted-foreground">{desc}</div>
)}
</div>
{onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
disabled={retrying}
>
<RefreshCw
className={cn("h-3.5 w-3.5", retrying && "animate-spin")}
/>
Retry
</Button>
)}
</div>
);
}
return (
<div
role="alert"
className={cn(
"flex flex-col items-center justify-center gap-4 text-center",
compact ? "py-8" : "py-16",
className,
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-full",
tone.bg,
tone.icon,
)}
>
<Icon className="h-6 w-6" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{desc && <p className="text-sm text-muted-foreground">{desc}</p>}
</div>
{onRetry && (
<Button onClick={onRetry} disabled={retrying} variant="outline">
<RefreshCw className={cn("h-4 w-4", retrying && "animate-spin")} />
Retry
</Button>
)}
{detail && (
<details className="mt-2 w-full max-w-2xl text-left">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
Show technical details
</summary>
<pre className="mt-2 max-h-64 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,370 @@
import * as React from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronDown, Filter, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { Input } from "./input";
/**
* Generic FilterPanel — composes a search box, several MultiSelect/Single popovers,
* range inputs, and active-filter chips into a unified toolbar.
*
* Build by composing the named sub-components. Each filter is self-contained so the
* parent only stores values and supplies onChange callbacks. Use `FilterChips` to
* render the active filter summary below the toolbar.
*/
/* ============================================================
* Layout primitives
* ========================================================== */
export function FilterPanel({
children,
className,
toolbarClassName,
chips,
onClearAll,
}: {
children: React.ReactNode;
/** Active filter chips, e.g. from <FilterChips /> */
chips?: React.ReactNode;
onClearAll?: () => void;
className?: string;
toolbarClassName?: string;
}) {
return (
<div
className={cn(
"rounded-lg border border-border bg-surface",
className,
)}
>
<div
className={cn(
"flex flex-wrap items-center gap-2 px-3 py-2",
toolbarClassName,
)}
>
<Filter className="h-4 w-4 text-muted-foreground" aria-hidden />
{children}
</div>
{chips && (
<div className="flex flex-wrap items-center gap-1.5 border-t border-border bg-background-subtle px-3 py-2">
{chips}
{onClearAll && (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}
/* ============================================================
* Search box
* ========================================================== */
export function FilterSearch({
value,
onChange,
placeholder = "Search…",
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<div className={cn("relative min-w-[200px] flex-1", className)}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-9 pl-8"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
}
/* ============================================================
* Select filter (single value) — popover with options + counts
* ========================================================== */
export interface FilterOption {
value: string;
label: string;
/** Optional badge / count */
hint?: React.ReactNode;
/** Optional swatch color for visual cue */
swatch?: string;
}
export function FilterSelect({
label,
value,
onChange,
options,
placeholder = "All",
allowEmpty = true,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: FilterOption[];
placeholder?: string;
allowEmpty?: boolean;
}) {
const selectedLabel =
options.find((o) => o.value === value)?.label ?? placeholder;
const hasValue = !!value;
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
hasValue && "border-brand-500 text-foreground",
)}
>
<span className="text-muted-foreground">{label}:</span>
<span className={cn(hasValue ? "font-medium" : "text-muted-foreground")}>
{selectedLabel}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-40 w-56 overflow-hidden rounded-md border border-border bg-popover shadow-md animate-fade-in-up"
>
<ul className="max-h-72 overflow-y-auto py-1">
{allowEmpty && (
<li>
<button
type="button"
onClick={() => onChange("")}
className={cn(
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
!hasValue && "bg-accent/40 font-medium",
)}
>
<span>{placeholder}</span>
{!hasValue && <Check className="h-3.5 w-3.5" />}
</button>
</li>
)}
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<li key={opt.value}>
<button
type="button"
onClick={() => onChange(opt.value)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent/60",
isSelected && "bg-accent/40 font-medium",
)}
>
{opt.swatch && (
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: opt.swatch }}
/>
)}
<span className="flex-1 truncate">{opt.label}</span>
{opt.hint != null && (
<span className="text-2xs text-muted-foreground tabular-nums">
{opt.hint}
</span>
)}
{isSelected && (
<Check className="h-3.5 w-3.5 text-foreground" />
)}
</button>
</li>
);
})}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
/* ============================================================
* Range filter (slider) — for numeric thresholds like confidence
* ========================================================== */
export function FilterRange({
label,
value,
onChange,
min = 0,
max = 1,
step = 0.05,
format = (v) => `${Math.round(v * 100)}%`,
}: {
label: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
format?: (v: number) => string;
}) {
const active = value > min;
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
active && "border-brand-500 text-foreground",
)}
>
<span className="text-muted-foreground">{label}:</span>
<span className={cn(active ? "font-medium" : "text-muted-foreground")}>
{format(value)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-40 w-64 rounded-md border border-border bg-popover p-3 shadow-md animate-fade-in-up"
>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{format(value)}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full"
/>
<div className="mt-1 flex justify-between text-2xs text-muted-foreground">
<span>{format(min)}</span>
<span>{format(max)}</span>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
/* ============================================================
* Toggle (checkbox-style chip)
* ========================================================== */
export function FilterToggle({
label,
checked,
onChange,
disabled,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<label
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-3 text-sm transition-colors hover:bg-accent",
checked && "border-brand-500",
disabled && "cursor-not-allowed opacity-50 hover:bg-surface",
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="text-foreground">{label}</span>
</label>
);
}
/* ============================================================
* Active filter chips
* ========================================================== */
export interface ActiveChip {
id: string;
label: string;
value: string;
onClear: () => void;
}
export function FilterChips({ chips }: { chips: ActiveChip[] }) {
if (chips.length === 0) {
return (
<span className="text-2xs text-muted-foreground">No active filters</span>
);
}
return (
<>
{chips.map((chip) => (
<span
key={chip.id}
className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:border-brand-800 dark:bg-brand-950 dark:text-brand-200"
>
<span className="text-brand-600/70 dark:text-brand-300/70">
{chip.label}:
</span>
<span className="font-medium">{chip.value}</span>
<button
type="button"
onClick={chip.onClear}
className="ml-0.5 -mr-0.5 rounded-full p-0.5 hover:bg-brand-200/60 dark:hover:bg-brand-800/60"
aria-label={`Remove ${chip.label} filter`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</>
);
}
/* ============================================================
* Refresh / view actions slot (placed on the right of toolbar)
* ========================================================== */
export function FilterActions({ children }: { children: React.ReactNode }) {
return <div className="ml-auto flex items-center gap-1">{children}</div>;
}
// Re-export Button for convenience when composing inline
export { Button };

View File

@@ -0,0 +1,90 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "./skeleton";
type Variant = "spinner" | "skeleton" | "list" | "table";
interface LoadingStateProps {
variant?: Variant;
/** Optional message under the indicator */
message?: React.ReactNode;
/** For skeleton variants: row count */
rows?: number;
/** Compact vertical padding */
compact?: boolean;
className?: string;
}
/**
* Standardized loading indicator. Pick a variant that matches what's coming:
* - `spinner`: indeterminate work (mutations, single resources)
* - `skeleton`: rectangular placeholder (single block)
* - `list`: vertical list of rows
* - `table`: dense grid of rows × 4 cols
*/
export function LoadingState({
variant = "spinner",
message,
rows = 4,
compact = false,
className,
}: LoadingStateProps) {
if (variant === "spinner") {
return (
<div
role="status"
aria-live="polite"
className={cn(
"flex flex-col items-center justify-center gap-3 text-center text-muted-foreground",
compact ? "py-6" : "py-16",
className,
)}
>
<Loader2 className="h-5 w-5 animate-spin text-brand-500" />
{message && <span className="text-sm">{message}</span>}
<span className="sr-only">Loading</span>
</div>
);
}
if (variant === "skeleton") {
return <Skeleton className={cn("h-24 w-full", className)} />;
}
if (variant === "list") {
return (
<div className={cn("space-y-2", className)} aria-busy="true">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
// table
return (
<div
className={cn(
"overflow-hidden rounded-lg border border-border",
className,
)}
aria-busy="true"
>
<div className="border-b border-border bg-background-subtle px-3 py-2">
<Skeleton className="h-3.5 w-32" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="grid grid-cols-4 gap-3 border-b border-border px-3 py-3 last:border-0"
>
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-1/2" />
<Skeleton className="h-3.5 w-3/5" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import * as React from "react";
import { Keyboard } from "lucide-react";
import {
Dialog,
DialogPanel,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
} from "./dialog";
export interface ShortcutEntry {
keys: string;
label: string;
}
export interface ShortcutGroup {
title: string;
items: ShortcutEntry[];
}
interface ShortcutsOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
groups: ShortcutGroup[];
title?: string;
description?: string;
}
/**
* Modal listing keyboard shortcuts. Pair with the `?` binding to open it.
*
* Each `keys` entry uses tokens separated by spaces for chord (e.g. "g d")
* or `+` for modifier combos (e.g. "Mod+K", "Shift+/"). Tokens are rendered
* as individual <kbd> chips.
*/
export function ShortcutsOverlay({
open,
onOpenChange,
groups,
title = "Keyboard shortcuts",
description = "Press the key combination to trigger the action.",
}: ShortcutsOverlayProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange} ariaLabel={title}>
<DialogPanel className="mx-auto mt-20 max-w-2xl">
<DialogHeader onClose={() => onOpenChange(false)}>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
<Keyboard className="h-4 w-4" />
</span>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogBody>
<div className="grid gap-6 sm:grid-cols-2">
{groups.map((group) => (
<div key={group.title}>
<h3 className="mb-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h3>
<ul className="space-y-1.5">
{group.items.map((item) => (
<li
key={item.keys + item.label}
className="flex items-center justify-between gap-3 rounded-md border border-border bg-background-subtle px-3 py-1.5"
>
<span className="text-sm text-foreground">
{item.label}
</span>
<ShortcutChips keys={item.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</DialogBody>
</DialogPanel>
</Dialog>
);
}
function ShortcutChips({ keys }: { keys: string }) {
// chord (space) → sequence; modifier (+) → group
const trimmed = keys.trim();
if (trimmed.includes(" ")) {
const parts = trimmed.split(/\s+/);
return (
<span className="inline-flex items-center gap-1">
{parts.map((p, i) => (
<React.Fragment key={i}>
<Kbd>{p}</Kbd>
{i < parts.length - 1 && (
<span className="text-2xs text-muted-foreground">then</span>
)}
</React.Fragment>
))}
</span>
);
}
if (trimmed.includes("+")) {
const parts = trimmed.split("+").map((p) => p.trim());
return (
<span className="inline-flex items-center gap-1">
{parts.map((p, i) => (
<React.Fragment key={i}>
<Kbd>{prettifyKey(p)}</Kbd>
{i < parts.length - 1 && (
<span className="text-2xs text-muted-foreground">+</span>
)}
</React.Fragment>
))}
</span>
);
}
return <Kbd>{prettifyKey(trimmed)}</Kbd>;
}
function prettifyKey(k: string): string {
const lower = k.toLowerCase();
if (typeof navigator === "undefined") return k;
const isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
if (lower === "mod") return isMac ? "⌘" : "Ctrl";
if (lower === "cmd" || lower === "meta") return isMac ? "⌘" : "Win";
if (lower === "ctrl") return "Ctrl";
if (lower === "shift") return "⇧";
if (lower === "alt" || lower === "option") return isMac ? "⌥" : "Alt";
if (lower === "enter") return "↵";
if (lower === "escape" || lower === "esc") return "Esc";
if (lower === "arrowup") return "↑";
if (lower === "arrowdown") return "↓";
if (lower === "arrowleft") return "←";
if (lower === "arrowright") return "→";
return k.length === 1 ? k.toUpperCase() : k;
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex min-w-[1.5rem] items-center justify-center rounded border border-border bg-surface px-1.5 py-0.5 font-mono text-2xs font-medium text-foreground shadow-xs">
{children}
</kbd>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
/**
* Thin wrapper over Radix Tooltip with project styling.
* Wrap your tree in <TooltipProvider> once (in main.tsx).
*/
export const TooltipProvider = TooltipPrimitive.Provider;
export interface TooltipProps {
children: React.ReactNode;
content: React.ReactNode;
/** Side relative to the trigger */
side?: "top" | "right" | "bottom" | "left";
/** Alignment along the side axis */
align?: "start" | "center" | "end";
/** Open delay in ms. Default 200. */
delayMs?: number;
/** Show keyboard shortcut chip after content */
shortcut?: string;
}
export function Tooltip({
children,
content,
side = "top",
align = "center",
delayMs = 200,
shortcut,
}: TooltipProps) {
return (
<TooltipPrimitive.Root delayDuration={delayMs}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
align={align}
sideOffset={6}
className={cn(
"z-50 inline-flex items-center gap-2 rounded-md border border-border bg-popover px-2.5 py-1.5",
"text-2xs text-popover-foreground shadow-md",
"animate-fade-in",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
)}
>
<span>{content}</span>
{shortcut && (
<kbd className="rounded border border-border bg-background-subtle px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{shortcut}
</kbd>
)}
<TooltipPrimitive.Arrow className="fill-popover stroke-border" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}

View File

@@ -1,5 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { domainsApi, DomainSummary } from "@/lib/api/domains";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
domainsApi,
DomainDefinitionRequest,
DomainDefinitionUpdateRequest,
DomainDiscoveryJob,
DomainDiscoveryRequest,
DomainReferenceSource,
DomainCandidate,
DomainSummary,
} from "@/lib/api/domains";
import { ontologyApi, OntologyDetail } from "@/lib/api/ontology";
import { queryKeys } from "./queryKeys";
@@ -11,6 +20,151 @@ export function useDomains() {
});
}
export function useCreateDomain() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DomainDefinitionRequest) => domainsApi.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({ queryKey: queryKeys.ontology.all });
},
});
}
export function useUpdateDomain() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
body,
}: {
domain: string;
body: DomainDefinitionUpdateRequest;
}) => domainsApi.update(domain, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({
queryKey: queryKeys.ontology.byDomain(variables.domain),
});
},
});
}
export function useDomainDiscoveryJobs(domain: string | undefined) {
return useQuery<DomainDiscoveryJob[]>({
queryKey: [...queryKeys.domains.all, "discoveryJobs", domain ?? ""],
queryFn: () => domainsApi.discoveryJobs(domain!),
enabled: Boolean(domain),
refetchInterval: (query) => {
const jobs = query.state.data;
return jobs?.some((job) => ["pending", "running", "cancel_requested"].includes(job.status))
? 2000
: false;
},
});
}
export function useDomainReferenceSources(domain: string | undefined) {
return useQuery<DomainReferenceSource[]>({
queryKey: [...queryKeys.domains.all, "referenceSources", domain ?? ""],
queryFn: () => domainsApi.referenceSources(domain!),
enabled: Boolean(domain),
});
}
export function useDomainCandidates(domain: string | undefined) {
return useQuery<DomainCandidate[]>({
queryKey: [...queryKeys.domains.all, "candidates", domain ?? ""],
queryFn: () => domainsApi.candidates(domain!),
enabled: Boolean(domain),
refetchInterval: 2000,
});
}
export function useStartDomainDiscovery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
body,
}: {
domain: string;
body: DomainDiscoveryRequest;
}) => domainsApi.startDiscovery(domain, body),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "discoveryJobs", variables.domain],
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "referenceSources", variables.domain],
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useCancelDomainDiscovery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
jobId,
}: {
domain: string;
jobId: string;
}) => domainsApi.cancelDiscovery(domain, jobId),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "discoveryJobs", variables.domain],
});
},
});
}
export function useApplyDomainCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
candidateIds,
}: {
domain: string;
candidateIds: string[];
}) => domainsApi.applyCandidates(domain, candidateIds),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.domains.all });
queryClient.invalidateQueries({
queryKey: queryKeys.ontology.byDomain(variables.domain),
});
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useUpdateDomainCandidateStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
domain,
candidateId,
status,
}: {
domain: string;
candidateId: string;
status: "pending_review" | "approved" | "rejected";
}) => domainsApi.updateCandidateStatus(domain, candidateId, status),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [...queryKeys.domains.all, "candidates", variables.domain],
});
},
});
}
export function useOntology(domain: string | undefined) {
return useQuery<OntologyDetail>({
queryKey: queryKeys.ontology.byDomain(domain ?? ""),

View File

@@ -0,0 +1,173 @@
import { useEffect, useRef } from "react";
/**
* Generic keyboard shortcut hook supporting two forms:
* - Single keys / modifier combos: "?", "/", "Mod+K", "Shift+/"
* - Chord (leader-follower) sequences: "g d", "g n" — leader pressed then
* follower within `chordWindowMs`. Inspired by Linear / GitHub.
*
* Bindings is a record of `pattern -> handler`. Patterns are case-insensitive.
* Use "Mod" for Ctrl on Windows/Linux and Cmd on macOS.
*
* The hook ignores key events whose target is an editable element (input,
* textarea, contenteditable) UNLESS the pattern uses a modifier (Mod/Shift/Alt).
*/
export interface UseKeyboardShortcutsOptions {
/** Disable all bindings (e.g. while a modal is open) */
disabled?: boolean;
/** Time window in ms for a chord follower after leader. Default 1500. */
chordWindowMs?: number;
/** Fired when a leader key starts a chord, useful for UI hints. */
onChordStart?: (leader: string) => void;
/** Fired when a chord times out or is cancelled. */
onChordEnd?: () => void;
}
type Handler = (event: KeyboardEvent) => void;
function isEditable(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
if (target.isContentEditable) return true;
return false;
}
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
}
interface ParsedSingle {
kind: "single";
key: string; // lowercase
mod: boolean;
shift: boolean;
alt: boolean;
}
interface ParsedChord {
kind: "chord";
leader: string; // lowercase
follower: string; // lowercase
}
function parsePattern(pattern: string): ParsedSingle | ParsedChord {
const trimmed = pattern.trim();
if (trimmed.includes(" ")) {
const [leader, follower] = trimmed.toLowerCase().split(/\s+/);
return { kind: "chord", leader, follower };
}
const parts = trimmed.split("+").map((p) => p.trim().toLowerCase());
const key = parts.pop() ?? "";
const set = new Set(parts);
return {
kind: "single",
key,
mod: set.has("mod") || set.has("ctrl") || set.has("cmd") || set.has("meta"),
shift: set.has("shift"),
alt: set.has("alt") || set.has("option"),
};
}
function keyMatches(event: KeyboardEvent, parsed: ParsedSingle): boolean {
const evKey = event.key.toLowerCase();
if (evKey !== parsed.key) return false;
const mod = isMac() ? event.metaKey : event.ctrlKey;
if (parsed.mod !== mod) return false;
if (parsed.shift !== event.shiftKey) return false;
if (parsed.alt !== event.altKey) return false;
return true;
}
export function useKeyboardShortcuts(
bindings: Record<string, Handler>,
opts: UseKeyboardShortcutsOptions = {},
) {
const bindingsRef = useRef(bindings);
bindingsRef.current = bindings;
const optsRef = useRef(opts);
optsRef.current = opts;
useEffect(() => {
const pending = { leader: null as string | null, timer: 0 };
const clearChord = () => {
if (pending.timer) {
window.clearTimeout(pending.timer);
pending.timer = 0;
}
if (pending.leader) {
pending.leader = null;
optsRef.current.onChordEnd?.();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (optsRef.current.disabled) return;
const editable = isEditable(event.target);
const evKey = event.key.toLowerCase();
// Parse all bindings on each press (cheap; ~15 entries)
const entries = Object.entries(bindingsRef.current).map(
([pattern, handler]) => ({ pattern, parsed: parsePattern(pattern), handler }),
);
// 1) Follower phase — if a leader is pending, look for chord match.
if (pending.leader) {
const match = entries.find(
(e) =>
e.parsed.kind === "chord" &&
e.parsed.leader === pending.leader &&
e.parsed.follower === evKey,
);
if (match) {
event.preventDefault();
clearChord();
match.handler(event);
return;
}
// Any other key cancels the chord
clearChord();
// ... and falls through to normal handling below.
}
// 2) Single key / modifier combos
for (const e of entries) {
if (e.parsed.kind !== "single") continue;
const hasMod = e.parsed.mod || e.parsed.shift || e.parsed.alt;
if (editable && !hasMod) continue;
if (keyMatches(event, e.parsed)) {
event.preventDefault();
e.handler(event);
return;
}
}
// 3) Leader phase — start a chord if this key is a leader and no
// modifier was pressed (chords are bare keys).
if (editable) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
const leaders = new Set(
entries
.filter((e) => e.parsed.kind === "chord")
.map((e) => (e.parsed as ParsedChord).leader),
);
if (leaders.has(evKey)) {
pending.leader = evKey;
optsRef.current.onChordStart?.(evKey);
pending.timer = window.setTimeout(
clearChord,
optsRef.current.chordWindowMs ?? 1500,
);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
clearChord();
};
}, []);
}

View File

@@ -43,3 +43,31 @@ export function useCreateProjectInline() {
},
});
}
export function useResetProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectName: string) => projectsApi.reset(projectName),
onSuccess: (_data, projectName) => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
queryClient.invalidateQueries({
queryKey: queryKeys.projects.detail(projectName),
});
queryClient.invalidateQueries({ queryKey: queryKeys.platform.all });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectName: string) => projectsApi.delete(projectName),
onSuccess: (_data, projectName) => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
queryClient.removeQueries({
queryKey: queryKeys.projects.detail(projectName),
});
queryClient.invalidateQueries({ queryKey: queryKeys.platform.all });
},
});
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useSelector } from "react-redux";
import type { RootState } from "@/stores";
/**
* Syncs the Redux theme state to the `<html>` element's `dark` class.
* Honors system preference when mode === "system" and reacts to OS changes.
*/
export function useThemeSync() {
const mode = useSelector((s: RootState) => s.ui.theme);
useEffect(() => {
const root = document.documentElement;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const resolved =
mode === "system" ? (media.matches ? "dark" : "light") : mode;
root.classList.toggle("dark", resolved === "dark");
root.dataset.theme = resolved;
};
apply();
if (mode === "system") {
media.addEventListener("change", apply);
return () => media.removeEventListener("change", apply);
}
}, [mode]);
}

View File

@@ -3,15 +3,177 @@ import { apiClient } from "./client";
export const domainSummarySchema = z.object({
domain: z.string(),
description: z.string().nullable().optional(),
entity_types: z.array(z.string()),
predicates: z.array(z.string()),
attribute_count: z.number().int(),
attributes: z.array(z.string()).default([]),
aliases: z.record(z.string(), z.string()).default({}),
is_builtin: z.boolean().default(false),
is_custom: z.boolean().default(false),
});
export const domainListSchema = z.array(domainSummarySchema);
export type DomainSummary = z.infer<typeof domainSummarySchema>;
export const domainDiscoveryJobSchema = z.object({
job_id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
status: z.string(),
seed_urls: z.array(z.string()).default([]),
max_pages: z.number(),
max_depth: z.number(),
same_domain_only: z.boolean(),
fetcher: z.string(),
respect_robots_txt: z.boolean(),
error: z.string().nullable().optional(),
progress: z.record(z.unknown()).default({}),
result_summary: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
started_at: z.string().nullable().optional(),
finished_at: z.string().nullable().optional(),
});
export const domainReferenceSourceSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
url: z.string(),
label: z.string().nullable().optional(),
status: z.string(),
last_crawled_at: z.string().nullable().optional(),
metadata: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
updated_at: z.string().nullable().optional(),
});
export const domainCandidateEvidenceSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
job_id: z.union([z.string(), z.number()]).nullable().optional().transform((v) =>
v == null ? null : String(v),
),
url: z.string(),
title: z.string().nullable().optional(),
snippet: z.string(),
metadata: z.record(z.unknown()).default({}),
created_at: z.string().nullable().optional(),
});
export const domainCandidateSchema = z.object({
id: z.union([z.string(), z.number()]).transform(String),
domain: z.string(),
job_id: z.union([z.string(), z.number()]).nullable().optional().transform((v) =>
v == null ? null : String(v),
),
candidate_type: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
confidence: z.number(),
occurrence_count: z.number(),
status: z.string(),
metadata: z.record(z.unknown()).default({}),
evidence: z.array(domainCandidateEvidenceSchema).default([]),
created_at: z.string().nullable().optional(),
updated_at: z.string().nullable().optional(),
});
export const domainDiscoveryJobListSchema = z.array(domainDiscoveryJobSchema);
export const domainReferenceSourceListSchema = z.array(domainReferenceSourceSchema);
export const domainCandidateListSchema = z.array(domainCandidateSchema);
export const domainCandidateApplyResponseSchema = z.object({
ok: z.boolean(),
domain: z.string(),
applied: z.record(z.number()),
definition: domainSummarySchema,
});
export type DomainDiscoveryJob = z.infer<typeof domainDiscoveryJobSchema>;
export type DomainReferenceSource = z.infer<typeof domainReferenceSourceSchema>;
export type DomainCandidate = z.infer<typeof domainCandidateSchema>;
export type DomainCandidateApplyResponse = z.infer<
typeof domainCandidateApplyResponseSchema
>;
export interface DomainDefinitionRequest {
domain: string;
description?: string | null;
entity_types: string[];
predicates: string[];
attributes: string[];
aliases?: Record<string, string>;
}
export type DomainDefinitionUpdateRequest = Omit<
DomainDefinitionRequest,
"domain"
>;
export interface DomainDiscoveryRequest {
seed_urls: string[];
max_pages?: number;
max_depth?: number;
same_domain_only?: boolean;
fetcher?: string;
respect_robots_txt?: boolean;
force_recrawl?: boolean;
}
export const domainsApi = {
list: () => apiClient.get("/domains", domainListSchema),
create: (body: DomainDefinitionRequest) =>
apiClient.post("/domains", domainSummarySchema, body),
update: (domain: string, body: DomainDefinitionUpdateRequest) =>
apiClient.put(
`/domains/${encodeURIComponent(domain)}`,
domainSummarySchema,
body,
),
discoveryJobs: (domain: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/discovery/jobs`,
domainDiscoveryJobListSchema,
),
referenceSources: (domain: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/reference-sources`,
domainReferenceSourceListSchema,
),
startDiscovery: (domain: string, body: DomainDiscoveryRequest) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/discovery/jobs`,
domainDiscoveryJobSchema,
body,
),
discoveryJob: (domain: string, jobId: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/discovery/jobs/${encodeURIComponent(jobId)}`,
domainDiscoveryJobSchema,
),
cancelDiscovery: (domain: string, jobId: string) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/discovery/jobs/${encodeURIComponent(jobId)}/cancel`,
domainDiscoveryJobSchema,
),
candidates: (domain: string, status?: string) =>
apiClient.get(
`/domains/${encodeURIComponent(domain)}/candidates`,
domainCandidateListSchema,
{ query: { status } },
),
applyCandidates: (domain: string, candidateIds: string[]) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/candidates/apply`,
domainCandidateApplyResponseSchema,
{ candidate_ids: candidateIds.map(Number) },
),
updateCandidateStatus: (
domain: string,
candidateId: string,
status: "pending_review" | "approved" | "rejected",
) =>
apiClient.post(
`/domains/${encodeURIComponent(domain)}/candidates/${encodeURIComponent(candidateId)}/status`,
domainCandidateSchema,
{ status },
),
};

View File

@@ -35,9 +35,17 @@ export const createProjectResponseSchema = z.object({
domain: z.string(),
});
export const projectActionResponseSchema = z.object({
ok: z.boolean(),
name: z.string(),
domain: z.string().optional(),
deleted: z.record(z.number()).optional(),
});
export type ProjectSummary = z.infer<typeof projectSummarySchema>;
export type ProjectDetail = z.infer<typeof projectDetailSchema>;
export type ProjectSource = z.infer<typeof projectSourceSchema>;
export type ProjectActionResponse = z.infer<typeof projectActionResponseSchema>;
export interface CreateProjectRequest {
config_path: string;
@@ -77,4 +85,14 @@ export const projectsApi = {
apiClient.post("/projects", createProjectResponseSchema, body),
createInline: (body: CreateProjectInlineRequest) =>
apiClient.post("/projects/inline", createProjectResponseSchema, body),
reset: (projectName: string) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/reset`,
projectActionResponseSchema,
),
delete: (projectName: string) =>
apiClient.delete(
`/projects/${encodeURIComponent(projectName)}`,
projectActionResponseSchema,
),
};

View File

@@ -8,17 +8,23 @@ import "./i18n";
import App from "./App";
import store from "./stores";
import { queryClient } from "./lib/queryClient";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/static/">
<App />
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</Provider>
<ErrorBoundary>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
<BrowserRouter basename="/static/">
<App />
<Toaster position="top-right" />
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</Provider>
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
@@ -5,28 +6,38 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
ExternalLink,
Globe2,
Loader2,
Plus,
Settings2,
Trash2,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { DataTable, type ColumnDef } from "@/components/ui/data-table";
import { Drawer } from "@/components/ui/drawer";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/layout/PageHeader";
import { useProject } from "@/hooks/useProjects";
import { useCreateSource, useDeleteSource } from "@/hooks/useSources";
type SourceRow = {
id: string;
name: string;
type: string;
base_url?: string | null;
trust_level?: string | number | null;
respect_robots_txt?: boolean | null;
rate_limit_per_minute?: number | null;
};
const toNumber = (v: string | number | null | undefined): number | null =>
v == null ? null : typeof v === "number" ? v : Number(v);
const SOURCE_TYPES = [
"official",
"review",
@@ -62,6 +73,15 @@ const sourceSchema = z.object({
type SourceFormValues = z.infer<typeof sourceSchema>;
const TYPE_TONE: Record<string, string> = {
official: "pill-info",
review: "pill-warning",
blog: "pill-muted",
news: "pill-info",
community: "pill-muted",
unknown: "pill-muted",
};
export default function ConfigureSourcesPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -79,6 +99,8 @@ export default function ConfigureSourcesPage() {
const createSource = useCreateSource(projectName);
const deleteSource = useDeleteSource(projectName);
const [addOpen, setAddOpen] = useState(false);
const {
register,
handleSubmit,
@@ -96,6 +118,11 @@ export default function ConfigureSourcesPage() {
},
});
const openAdd = () => {
reset();
setAddOpen(true);
};
const onAddSource = async (values: SourceFormValues) => {
try {
await createSource.mutateAsync({
@@ -108,6 +135,7 @@ export default function ConfigureSourcesPage() {
}),
);
reset();
setAddOpen(false);
} catch (e) {
toast.error(
t("sources.addFailed", "추가 실패: {{msg}}", {
@@ -143,269 +171,327 @@ export default function ConfigureSourcesPage() {
}
};
return (
<div className="mx-auto max-w-5xl px-6 py-10">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/")}
aria-label={t("common.back", "이전")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold tracking-tight">
{t("sources.title", "참고 소스 설정")}
</h1>
{project && (
<p className="text-sm text-muted-foreground">
{project.name}{" "}
<span className="capitalize text-muted-foreground/70">
· {project.domain}
</span>
</p>
)}
const sources = project?.sources ?? [];
const columns: ColumnDef<SourceRow, any>[] = [
{
id: "name",
header: t("sources.name", "이름"),
accessorFn: (row) => row.name,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-brand-50 text-brand-600 dark:bg-brand-950 dark:text-brand-300">
<Globe2 className="h-3.5 w-3.5" />
</span>
<span className="truncate font-medium">{row.original.name}</span>
</div>
<Button
onClick={() => navigate(`/crawl/${projectName}`)}
disabled={!project || (project.sources?.length ?? 0) === 0}
>
{t("sources.next", "크롤 진행")}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
),
size: 240,
enablePinning: true,
},
{
id: "type",
header: t("sources.type", "타입"),
accessorFn: (row) => row.type,
cell: ({ row }) => (
<span className={TYPE_TONE[row.original.type] ?? "pill-muted"}>
{row.original.type}
</span>
),
size: 120,
},
{
id: "base_url",
header: "Base URL",
accessorFn: (row) => row.base_url ?? "",
enableSorting: false,
cell: ({ row }) =>
row.original.base_url ? (
<a
href={row.original.base_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
<ExternalLink className="h-3 w-3" />
<span className="max-w-[260px] truncate">
{row.original.base_url}
</span>
</a>
) : (
<span className="text-xs text-muted-foreground/60"></span>
),
size: 280,
},
{
id: "trust",
header: t("sources.trust", "신뢰도"),
accessorFn: (row) => toNumber(row.trust_level) ?? 0,
cell: ({ row }) => {
const n = toNumber(row.original.trust_level);
return n == null ? (
<span className="text-xs text-muted-foreground/60"></span>
) : (
<TrustBar value={n} />
);
},
size: 140,
meta: { align: "right" },
},
{
id: "rate",
header: t("sources.rateLimit", "rate/분"),
accessorFn: (row) => row.rate_limit_per_minute ?? 0,
cell: ({ row }) => (
<span className="text-xs">
{row.original.rate_limit_per_minute ?? "—"}
</span>
),
size: 110,
meta: { align: "right" },
},
{
id: "actions",
header: "",
enableSorting: false,
enableHiding: false,
enableResizing: false,
cell: ({ row }) => (
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
onDelete(row.original.name);
}}
disabled={deleteSource.isPending}
aria-label={t("sources.delete", "삭제")}
>
<Trash2 className="h-4 w-4 text-danger" />
</Button>
</div>
),
size: 60,
meta: { align: "right" },
},
];
{isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center justify-between gap-3 py-4">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => refetch()}
>
{t("common.retry", "다시 시도")}
return (
<>
<PageHeader
icon={Settings2}
breadcrumbs={[
{ label: t("nav.dashboard", "Dashboard"), to: "/" },
{ label: projectName, to: `/sources/${projectName}` },
{ label: t("sources.title", "Source Explorer") },
]}
title={t("sources.title", "참고 소스 설정")}
description={
project &&
`${project.name} · ${project.domain} · ${sources.length} ${t("sources.count", "sources")}`
}
actions={
<>
<Button variant="outline" onClick={openAdd}>
<Plus className="h-4 w-4" />
{t("sources.add", "소스 추가")}
</Button>
</CardContent>
</Card>
)}
<Button
onClick={() => navigate(`/crawl/${projectName}`)}
disabled={!project || sources.length === 0}
>
{t("sources.next", "크롤 진행")}
<ArrowRight className="h-4 w-4" />
</Button>
</>
}
/>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<section>
<Card>
<CardHeader>
<CardTitle>{t("sources.listTitle", "등록된 소스")}</CardTitle>
<CardDescription>
{t(
"sources.listDesc",
"프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16" />
))}
</div>
<div className="mx-auto max-w-7xl px-6 py-6">
{isError && (
<ErrorState
className="mb-4"
inline
severity="error"
title={t("sources.loadFailedTitle", "소스를 불러오지 못했습니다")}
description={error}
onRetry={() => refetch()}
/>
)}
<DataTable<SourceRow>
tableId="sources"
columns={columns}
data={sources}
getRowId={(row) => row.id}
loading={isLoading}
loadingRows={4}
height={560}
empty={
<EmptyState
icon={Globe2}
title={t("sources.emptyTitle", "등록된 소스가 없습니다")}
description={t(
"sources.empty",
"첫 참고 사이트를 추가해 크롤을 시작하세요.",
)}
{project && project.sources.length === 0 && (
<p className="py-8 text-center text-sm text-muted-foreground">
{t(
"sources.empty",
"아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
)}
</p>
)}
{project && project.sources.length > 0 && (
<ul className="divide-y">
{project.sources.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{s.name}</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
{s.type}
</span>
{typeof s.trust_level === "number" && (
<span className="text-xs text-muted-foreground">
{t("sources.trust", "신뢰도")}{" "}
{s.trust_level.toFixed(2)}
</span>
)}
</div>
{s.base_url && (
<a
href={s.base_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
{s.base_url}
</a>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(s.name)}
disabled={deleteSource.isPending}
aria-label={t("sources.delete", "삭제")}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</section>
<aside>
<Card>
<CardHeader>
<CardTitle>{t("sources.addTitle", "소스 추가")}</CardTitle>
<CardDescription>
{t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit(onAddSource)}
className="space-y-4"
noValidate
>
<div className="space-y-1.5">
<Label htmlFor="src_name">
{t("sources.name", "이름")}
</Label>
<Input
id="src_name"
placeholder="official_brand_site"
{...register("name")}
/>
{errors.name && (
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="src_type">
{t("sources.type", "타입")}
</Label>
<select
id="src_type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
{...register("type")}
>
{SOURCE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="src_url">
{t("sources.baseUrl", "Base URL")}
</Label>
<Input
id="src_url"
placeholder="https://example.com"
type="url"
{...register("base_url")}
/>
{errors.base_url && (
<p className="text-xs text-destructive">
{errors.base_url.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="src_trust">
{t("sources.trust", "신뢰도")}
</Label>
<Input
id="src_trust"
type="number"
step="0.05"
min={0}
max={1}
{...register("trust_level", { valueAsNumber: true })}
/>
{errors.trust_level && (
<p className="text-xs text-destructive">
{errors.trust_level.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="src_rate">
{t("sources.rateLimit", "rate/분")}
</Label>
<Input
id="src_rate"
type="number"
min={1}
max={600}
{...register("rate_limit_per_minute", {
valueAsNumber: true,
})}
/>
{errors.rate_limit_per_minute && (
<p className="text-xs text-destructive">
{errors.rate_limit_per_minute.message}
</p>
)}
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register("respect_robots_txt")}
/>
<span>
{t("sources.respectRobots", "robots.txt 준수")}
</span>
</label>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || createSource.isPending}
>
{createSource.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
primaryAction={
<Button onClick={openAdd}>
<Plus className="h-4 w-4" />
{t("sources.add", "소스 추가")}
</Button>
</form>
</CardContent>
</Card>
</aside>
}
/>
}
/>
</div>
<Drawer
open={addOpen}
onOpenChange={setAddOpen}
size="md"
title={t("sources.addTitle", "소스 추가")}
description={t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
footer={
<>
<Button
variant="ghost"
type="button"
onClick={() => setAddOpen(false)}
>
{t("common.cancel", "취소")}
</Button>
<Button
type="submit"
form="add-source-form"
disabled={isSubmitting || createSource.isPending}
>
{createSource.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("sources.add", "소스 추가")}
</Button>
</>
}
>
<form
id="add-source-form"
onSubmit={handleSubmit(onAddSource)}
className="space-y-4"
noValidate
>
<div className="space-y-1.5">
<Label htmlFor="src_name">{t("sources.name", "이름")}</Label>
<Input
id="src_name"
placeholder="official_brand_site"
{...register("name")}
/>
{errors.name && (
<p className="text-xs text-danger">{errors.name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="src_type">{t("sources.type", "타입")}</Label>
<select
id="src_type"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
{...register("type")}
>
{SOURCE_TYPES.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="src_url">
{t("sources.baseUrl", "Base URL")}
</Label>
<Input
id="src_url"
placeholder="https://example.com"
type="url"
{...register("base_url")}
/>
{errors.base_url && (
<p className="text-xs text-danger">{errors.base_url.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="src_trust">
{t("sources.trust", "신뢰도")}
</Label>
<Input
id="src_trust"
type="number"
step="0.05"
min={0}
max={1}
{...register("trust_level", { valueAsNumber: true })}
/>
{errors.trust_level && (
<p className="text-xs text-danger">
{errors.trust_level.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="src_rate">
{t("sources.rateLimit", "rate/분")}
</Label>
<Input
id="src_rate"
type="number"
min={1}
max={600}
{...register("rate_limit_per_minute", {
valueAsNumber: true,
})}
/>
{errors.rate_limit_per_minute && (
<p className="text-xs text-danger">
{errors.rate_limit_per_minute.message}
</p>
)}
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register("respect_robots_txt")}
/>
<span>{t("sources.respectRobots", "robots.txt 준수")}</span>
</label>
</form>
</Drawer>
</>
);
}
function TrustBar({ value }: { value: number }) {
const pct = Math.round(value * 100);
const tone =
value >= 0.75 ? "bg-success" : value >= 0.4 ? "bg-warning" : "bg-danger";
return (
<div className="flex items-center gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div className={`h-full ${tone}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{value.toFixed(2)}
</span>
</div>
);
}

View File

@@ -1,6 +1,15 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Plus, FolderOpen, AlertCircle, Clock } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
Plus,
FolderOpen,
AlertCircle,
Clock,
DatabaseZap,
Trash2,
} from "lucide-react";
import {
Card,
CardContent,
@@ -9,8 +18,21 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useProjects } from "@/hooks/useProjects";
import {
useDeleteProject,
useProjects,
useResetProject,
} from "@/hooks/useProjects";
import { usePipeline } from "@/hooks/usePlatform";
import { formatPercent } from "@/lib/display";
import { ProjectSummary } from "@/lib/api/projects";
@@ -128,6 +150,11 @@ function ProjectCard({
onOpen: (path: string) => void;
}) {
const pipeline = usePipeline(project.name);
const resetProject = useResetProject();
const deleteProject = useDeleteProject();
const [confirmAction, setConfirmAction] = useState<"reset" | "delete" | null>(
null,
);
const claimStage = pipeline.data?.stages.find((stage) => stage.key === "claims");
const approvedStage = pipeline.data?.stages.find(
(stage) => stage.key === "validated",
@@ -136,35 +163,136 @@ function ProjectCard({
const claimCount = claimStage?.count ?? 0;
const approvedCount = approvedStage?.count ?? 0;
const approvalRate = claimCount > 0 ? approvedCount / claimCount : 0;
const isDeleting = deleteProject.isPending;
const isResetting = resetProject.isPending;
const handleProjectAction = async () => {
if (!confirmAction) return;
try {
if (confirmAction === "reset") {
await resetProject.mutateAsync(project.name);
toast.success(`${project.name} 데이터가 초기화되었습니다`);
} else {
await deleteProject.mutateAsync(project.name);
toast.success(`${project.name} 프로젝트가 삭제되었습니다`);
}
setConfirmAction(null);
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<Card
role="button"
tabIndex={0}
onClick={() => onOpen(`/sources/${project.name}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onOpen(`/sources/${project.name}`);
}
}}
className="cursor-pointer transition-colors hover:bg-accent/40"
>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
<CardDescription>{project.domain}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2 text-xs">
<ProjectStat label="Pages" value={pageStage?.count ?? 0} />
<ProjectStat label="Claims" value={claimCount} />
<ProjectStat label="Approved" value={formatPercent(approvalRate)} />
<>
<Card
role="button"
tabIndex={0}
onClick={() => onOpen(`/sources/${project.name}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onOpen(`/sources/${project.name}`);
}
}}
className="relative cursor-pointer transition-colors hover:bg-accent/40"
>
<div className="absolute right-3 top-3 z-10 flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={`${project.name} 데이터 초기화`}
title="데이터 초기화"
disabled={isDeleting || isResetting}
onClick={(e) => {
e.stopPropagation();
setConfirmAction("reset");
}}
>
<DatabaseZap className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`${project.name} 프로젝트 삭제`}
title="프로젝트 삭제"
disabled={isDeleting || isResetting}
onClick={(e) => {
e.stopPropagation();
setConfirmAction("delete");
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatRelative(project.updated_at)}
</div>
</CardContent>
</Card>
<CardHeader className="pr-24">
<CardTitle>{project.name}</CardTitle>
<CardDescription>{project.domain}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2 text-xs">
<ProjectStat label="Pages" value={pageStage?.count ?? 0} />
<ProjectStat label="Claims" value={claimCount} />
<ProjectStat label="Approved" value={formatPercent(approvalRate)} />
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatRelative(project.updated_at)}
</div>
</CardContent>
</Card>
<Dialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open && !isDeleting && !isResetting) setConfirmAction(null);
}}
ariaLabel="프로젝트 작업 확인"
>
<DialogPanel>
<DialogHeader onClose={() => setConfirmAction(null)}>
<DialogTitle>
{confirmAction === "delete" ? "프로젝트 삭제" : "데이터 초기화"}
</DialogTitle>
<DialogDescription>
{confirmAction === "delete"
? "프로젝트와 연결된 모든 데이터가 삭제됩니다."
: "프로젝트는 유지하고 크롤링/엔티티/클레임 데이터를 초기화합니다."}
</DialogDescription>
</DialogHeader>
<DialogBody>
<p className="text-sm">
<span className="font-semibold">{project.name}</span>
{confirmAction === "delete"
? " 프로젝트를 삭제할까요?"
: " 데이터를 초기화할까요?"}
</p>
</DialogBody>
<DialogFooter>
<Button
variant={confirmAction === "delete" ? "destructive" : "default"}
onClick={handleProjectAction}
disabled={isDeleting || isResetting}
>
{isDeleting || isResetting
? "처리 중..."
: confirmAction === "delete"
? "삭제"
: "초기화"}
</Button>
<Button
variant="outline"
onClick={() => setConfirmAction(null)}
disabled={isDeleting || isResetting}
>
</Button>
</DialogFooter>
</DialogPanel>
</Dialog>
</>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
import { AlertCircle, ArrowLeft, Loader2, Pencil, Plus } from "lucide-react";
import {
Card,
CardContent,
@@ -12,30 +13,235 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogBody,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { useDomains } from "@/hooks/useDomains";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateDomain,
useDomains,
useUpdateDomain,
} from "@/hooks/useDomains";
import { useCreateProjectInline } from "@/hooks/useProjects";
import { DomainSummary } from "@/lib/api/domains";
import { cn } from "@/lib/utils";
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
const domainNameRegex = /^[a-z0-9][a-z0-9_-]{1,79}$/;
const schema = z.object({
project_name: z
.string()
.min(2, "최소 2자 이상")
.max(64, "최대 64자")
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
domain: z.string().min(1, "도메인을 선택하세요"),
.min(2, "At least 2 characters")
.max(64, "Up to 64 characters")
.regex(projectNameRegex, "Use letters, numbers, underscore, or hyphen"),
domain: z.string().min(1, "Select a domain"),
});
type FormValues = z.infer<typeof schema>;
interface DomainEditorState {
mode: "create" | "edit";
domain?: DomainSummary;
}
function parseList(value: string): string[] {
const seen = new Set<string>();
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter((item) => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
function formatList(values: string[] | undefined): string {
return (values ?? []).join("\n");
}
function parseAliases(value: string): Record<string, string> {
const aliases: Record<string, string> = {};
for (const line of value.split("\n")) {
const [rawKey, ...rawValue] = line.split("=");
const key = rawKey?.trim();
const aliasValue = rawValue.join("=").trim();
if (key && aliasValue) aliases[key] = aliasValue;
}
return aliases;
}
function formatAliases(values: Record<string, string> | undefined): string {
return Object.entries(values ?? {})
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
function DomainEditorDialog({
state,
onClose,
onSelect,
}: {
state: DomainEditorState | null;
onClose: () => void;
onSelect: (domain: string) => void;
}) {
const createDomain = useCreateDomain();
const updateDomain = useUpdateDomain();
const editing = state?.mode === "edit";
const domain = state?.domain;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [entityTypes, setEntityTypes] = useState("");
const [predicates, setPredicates] = useState("");
const [attributes, setAttributes] = useState("");
const [aliases, setAliases] = useState("");
useEffect(() => {
if (!state) return;
setName(domain?.domain ?? "");
setDescription(domain?.description ?? "");
setEntityTypes(formatList(domain?.entity_types));
setPredicates(formatList(domain?.predicates));
setAttributes(formatList(domain?.attributes));
setAliases(formatAliases(domain?.aliases));
}, [domain, state]);
const saving = createDomain.isPending || updateDomain.isPending;
const save = async () => {
const normalized = name.trim().toLowerCase();
if (!domainNameRegex.test(normalized)) {
toast.error("도메인은 소문자, 숫자, _, - 조합의 2~80자로 입력하세요.");
return;
}
const body = {
description: description.trim() || null,
entity_types: parseList(entityTypes),
predicates: parseList(predicates),
attributes: parseList(attributes),
aliases: parseAliases(aliases),
};
if (body.entity_types.length === 0) {
toast.error("엔티티 타입을 최소 1개 이상 입력하세요.");
return;
}
try {
const saved = editing
? await updateDomain.mutateAsync({ domain: normalized, body })
: await createDomain.mutateAsync({ domain: normalized, ...body });
toast.success(editing ? "도메인을 수정했습니다." : "도메인을 생성했습니다.");
onSelect(saved.domain);
onClose();
} catch (e) {
toast.error((e as Error).message);
}
};
return (
<Dialog open={Boolean(state)} onOpenChange={(open) => !open && onClose()}>
<DialogPanel ariaLabel={editing ? "도메인 편집" : "도메인 생성"} className="max-w-2xl">
<DialogHeader onClose={onClose}>
<DialogTitle>{editing ? "도메인 편집" : "새 도메인 생성"}</DialogTitle>
<DialogDescription>
, , 릿 .
</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-name"> ID</Label>
<Input
id="domain-name"
value={name}
disabled={editing}
placeholder="cosmetic_ingredient"
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-description"></Label>
<Input
id="domain-description"
value={description}
placeholder="화장품 성분 지식 그래프"
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-entities"> </Label>
<Textarea
id="domain-entities"
value={entityTypes}
className="min-h-[160px]"
placeholder={"Ingredient\nBrand\nProduct\nSkinConcern"}
onChange={(event) => setEntityTypes(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-predicates"></Label>
<Textarea
id="domain-predicates"
value={predicates}
className="min-h-[160px]"
placeholder={"hasIngredient\nsuitableForSkinType\nhasWarning"}
onChange={(event) => setPredicates(event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain-attributes"></Label>
<Textarea
id="domain-attributes"
value={attributes}
placeholder={"name\nsource_url\nupdated_at\nconfidence"}
onChange={(event) => setAttributes(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain-aliases"> </Label>
<Textarea
id="domain-aliases"
value={aliases}
placeholder={"ingredients=hasIngredient\nwarnings=hasWarning"}
onChange={(event) => setAliases(event.target.value)}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
</Button>
</DialogFooter>
</DialogPanel>
</Dialog>
);
}
export default function OnboardingPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [editorState, setEditorState] = useState<DomainEditorState | null>(null);
const {
data: domains,
isLoading: domainsLoading,
@@ -65,6 +271,15 @@ export default function OnboardingPage() {
project_name: values.project_name,
domain: values.domain,
target_entities: selected?.entity_types ?? [],
fields: selected?.attributes ?? [],
ontology: selected
? {
entity_types: selected.entity_types,
predicates: selected.predicates,
attributes: selected.attributes,
aliases: selected.aliases,
}
: {},
});
toast.success(
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
@@ -99,29 +314,33 @@ export default function OnboardingPage() {
<Card>
<CardHeader>
<CardTitle>
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
</CardTitle>
<CardDescription>
{t(
"onboarding.formDesc",
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
)}
</CardDescription>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>{t("onboarding.formTitle", "온톨로지 도메인 선택")}</CardTitle>
<CardDescription>
.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => navigate("/domains")}>
</Button>
<Button type="button" variant="outline" onClick={() => setEditorState({ mode: "create" })}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6"
noValidate
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
<div className="space-y-2">
<Label htmlFor="project_name">
{t("onboarding.projectName", "프로젝트 이름")}
</Label>
<Input
id="project_name"
placeholder="my_perfume_project"
placeholder="my_ontology_project"
aria-invalid={Boolean(errors.project_name)}
{...register("project_name")}
/>
@@ -131,37 +350,27 @@ export default function OnboardingPage() {
</p>
)}
<p className="text-xs text-muted-foreground">
{t(
"onboarding.projectNameHint",
"영문, 숫자, _ , - 만 사용 (2~64자)",
)}
, , _, - (2~64)
</p>
</div>
<div className="space-y-2">
<Label>{t("onboarding.domain", "도메인")}</Label>
{isError && (
<Card className="border-destructive">
<CardContent className="flex items-center justify-between gap-3 py-4">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => refetch()}
>
{t("common.retry", "다시 시도")}
</Button>
</CardContent>
</Card>
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive px-4 py-3">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button variant="outline" size="sm" type="button" onClick={() => refetch()}>
{t("common.retry", "다시 시도")}
</Button>
</div>
)}
{domainsLoading && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
<Skeleton key={i} className="h-28" />
))}
</div>
)}
@@ -170,43 +379,56 @@ export default function OnboardingPage() {
{domains.map((d) => {
const active = selectedDomain === d.domain;
return (
<button
<div
key={d.domain}
type="button"
onClick={() =>
setValue("domain", d.domain, {
shouldValidate: true,
})
}
className={cn(
"rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"rounded-lg border bg-card transition-colors",
active && "border-primary bg-accent/60",
)}
>
<div className="mb-1 font-semibold capitalize">
{d.domain}
<div className="flex items-start gap-2 p-4">
<button
type="button"
onClick={() =>
setValue("domain", d.domain, {
shouldValidate: true,
})
}
className="min-w-0 flex-1 text-left focus-visible:outline-none"
>
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold capitalize">{d.domain}</span>
{d.is_custom && (
<span className="rounded border px-1.5 py-0.5 text-[10px] text-muted-foreground">
custom
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{d.entity_types.length} · {d.predicates.length} · {d.attribute_count}
</div>
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
{d.entity_types.slice(0, 6).join(", ")}
{d.entity_types.length > 6 && " ..."}
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${d.domain} 편집`}
onClick={() => setEditorState({ mode: "edit", domain: d })}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
{t("onboarding.domainSummary", {
entities: d.entity_types.length,
predicates: d.predicates.length,
defaultValue:
"엔티티 {{entities}}종 · 관계 {{predicates}}개",
})}
</div>
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
{d.entity_types.slice(0, 5).join(", ")}
{d.entity_types.length > 5 && " …"}
</div>
</button>
</div>
);
})}
</div>
)}
{errors.domain && (
<p className="text-sm text-destructive">
{errors.domain.message}
</p>
<p className="text-sm text-destructive">{errors.domain.message}</p>
)}
</div>
@@ -219,19 +441,20 @@ export default function OnboardingPage() {
>
{t("common.cancel", "취소")}
</Button>
<Button
type="submit"
disabled={isSubmitting || createProject.isPending}
>
{createProject.isPending && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<Button type="submit" disabled={isSubmitting || createProject.isPending}>
{createProject.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t("onboarding.submit", "프로젝트 만들기")}
</Button>
</div>
</form>
</CardContent>
</Card>
<DomainEditorDialog
state={editorState}
onClose={() => setEditorState(null)}
onSelect={(domain) => setValue("domain", domain, { shouldValidate: true })}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,31 @@
import * as React from "react";
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Check,
Eye,
Filter,
ExternalLink,
ListChecks,
Search,
ShieldCheck,
X,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge, BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { DataTable, type ColumnDef } from "@/components/ui/data-table";
import { Drawer } from "@/components/ui/drawer";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Tooltip } from "@/components/ui/tooltip";
import {
FilterPanel,
FilterSearch,
FilterSelect,
FilterChips,
type ActiveChip,
} from "@/components/ui/filter-panel";
import { PageHeader } from "@/components/layout/PageHeader";
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
import { useProject } from "@/hooks/useProjects";
import {
@@ -48,15 +51,21 @@ function statusVariant(status: string | undefined): BadgeProps["variant"] {
function confidenceBucket(claim: Claim): string {
if (typeof claim.confidence !== "number") return "unknown";
if (claim.confidence >= 0.9) return "auto-approve candidate";
if (claim.confidence >= 0.7) return "normal review";
return "priority review";
if (claim.confidence >= 0.9) return "auto-approve";
if (claim.confidence >= 0.7) return "normal";
return "priority";
}
function claimObject(claim: Claim): string {
function claimObjectText(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
const STATUS_LABELS: Record<string, string> = {
candidate: "Candidate",
approved: "Approved",
rejected: "Rejected",
};
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -64,24 +73,28 @@ export default function ReviewPage() {
const { data: project } = useProject(projectName);
const claims = useClaims(projectName, {
includeCandidates: true,
limit: 300,
limit: 500,
});
const updateStatus = useUpdateClaimStatus(projectName);
const [statusFilter, setStatusFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<string>("");
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
/* --------------------- Derived data --------------------- */
const filteredClaims = useMemo(() => {
const query = search.trim().toLowerCase();
return (claims.data ?? []).filter((claim) => {
if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
if (statusFilter && reviewLabel(claim.status) !== statusFilter) {
return false;
}
if (!query) return true;
return [
claim.subject,
claim.predicate,
claimObject(claim),
claimObjectText(claim),
claim.evidence_text,
claim.page_url,
claim.source,
@@ -92,11 +105,9 @@ export default function ReviewPage() {
}, [claims.data, search, statusFilter]);
const selectedClaim = useMemo(() => {
return (
filteredClaims.find((claim) => claim.id === selectedId) ??
filteredClaims[0]
);
}, [filteredClaims, selectedId]);
if (!selectedId) return null;
return claims.data?.find((claim) => claim.id === selectedId) ?? null;
}, [claims.data, selectedId]);
const counts = useMemo(() => {
const all = claims.data ?? [];
@@ -111,6 +122,8 @@ export default function ReviewPage() {
};
}, [claims.data]);
/* --------------------- Actions --------------------- */
const applyStatus = async (claim: Claim, status: string) => {
try {
await updateStatus.mutateAsync({
@@ -121,304 +134,491 @@ export default function ReviewPage() {
? "Rejected from Claim Review"
: "Updated from Claim Review",
});
toast.success(`Claim ${status}`);
toast.success(
status === "validated_claim"
? `Approved · ${humanizeValue(claim.subject)}`
: `Rejected · ${humanizeValue(claim.subject)}`,
);
} catch (error) {
toast.error((error as Error).message);
}
};
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/schema/${projectName}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ListChecks className="h-6 w-6 text-primary" />
Claim Review
</h1>
<p className="text-sm text-muted-foreground">
{project?.name ?? projectName} · , ,
</p>
const openDetail = (claim: Claim) => {
setSelectedId(claim.id);
setDrawerOpen(true);
};
/* --------------------- Filter chips --------------------- */
const chips: ActiveChip[] = [];
if (search)
chips.push({
id: "search",
label: "Search",
value: `"${search}"`,
onClear: () => setSearch(""),
});
if (statusFilter)
chips.push({
id: "status",
label: "Status",
value: STATUS_LABELS[statusFilter] ?? statusFilter,
onClear: () => setStatusFilter(""),
});
const clearAll = () => {
setSearch("");
setStatusFilter("");
};
/* --------------------- Table columns --------------------- */
const columns: ColumnDef<Claim, any>[] = [
{
id: "status",
header: "Status",
accessorFn: (row) => reviewLabel(row.status),
cell: ({ row }) => (
<Badge variant={statusVariant(row.original.status)}>
{reviewLabel(row.original.status)}
</Badge>
),
size: 110,
enablePinning: true,
},
{
id: "subject",
header: "Subject",
accessorFn: (row) => humanizeValue(row.subject),
cell: ({ row }) => (
<span className="line-clamp-2 break-words font-medium text-foreground">
{humanizeValue(row.original.subject)}
</span>
),
size: 220,
},
{
id: "predicate",
header: "Predicate",
accessorFn: (row) => row.predicate,
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono">
{row.original.predicate}
</Badge>
),
size: 160,
},
{
id: "object",
header: "Object",
accessorFn: (row) => claimObjectText(row),
cell: ({ row }) => (
<span className="line-clamp-2 break-words text-foreground">
{claimObjectText(row.original)}
</span>
),
size: 280,
enableSorting: false,
},
{
id: "confidence",
header: "Confidence",
accessorFn: (row) => row.confidence ?? 0,
cell: ({ row }) => <ConfidenceBar value={row.original.confidence} />,
size: 140,
meta: { align: "right" },
},
{
id: "bucket",
header: "Bucket",
accessorFn: (row) => confidenceBucket(row),
cell: ({ row }) => {
const b = confidenceBucket(row.original);
const tone =
b === "auto-approve"
? "pill-success"
: b === "priority"
? "pill-warning"
: "pill-info";
return <span className={tone}>{b}</span>;
},
size: 130,
},
{
id: "source",
header: "Source",
accessorFn: (row) => row.source ?? "",
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{humanizeValue(row.original.source)}
</span>
),
size: 120,
},
{
id: "evidence",
header: "Evidence",
accessorFn: (row) => (row.evidence_text ? 1 : 0),
cell: ({ row }) =>
row.original.evidence_text ? (
<span className="text-xs font-medium text-success">yes</span>
) : (
<span className="text-xs font-medium text-warning">missing</span>
),
size: 90,
meta: { align: "center" },
},
{
id: "actions",
header: "",
enableSorting: false,
enableHiding: false,
enableResizing: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Tooltip content="Approve" shortcut="A">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
applyStatus(row.original, "validated_claim");
}}
disabled={updateStatus.isPending}
className="h-7 w-7"
>
<Check className="h-3.5 w-3.5 text-success" />
</Button>
</Tooltip>
<Tooltip content="Reject" shortcut="X">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
applyStatus(row.original, "rejected");
}}
disabled={updateStatus.isPending}
className="h-7 w-7"
>
<X className="h-3.5 w-3.5 text-danger" />
</Button>
</Tooltip>
</div>
<Button onClick={() => navigate(`/quality/${projectName}`)}>
Quality Inspector
</Button>
),
size: 90,
meta: { align: "right" },
},
];
/* --------------------- Render --------------------- */
return (
<>
<PageHeader
icon={ListChecks}
breadcrumbs={[
{ label: "Dashboard", to: "/" },
{ label: projectName, to: `/sources/${projectName}` },
{ label: "Claim Review" },
]}
title="Claim Review"
description={
project &&
`${project.name} · 후보·승인·반려 상태 분리 검토 · ${counts.total.toLocaleString()} claims`
}
actions={
<Button onClick={() => navigate(`/quality/${projectName}`)}>
<ShieldCheck className="h-4 w-4" />
Quality Inspector
</Button>
}
/>
<div className="mx-auto max-w-7xl px-6 py-6">
{claims.isError && (
<ErrorState
className="mb-4"
inline
severity="error"
title="Failed to load claims"
description={claims.error as Error}
onRetry={() => claims.refetch()}
/>
)}
{/* Metrics */}
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Total" value={counts.total} tone="muted" />
<Metric label="Candidate" value={counts.candidate} tone="warning" />
<Metric label="Approved" value={counts.approved} tone="success" />
<Metric label="Rejected" value={counts.rejected} tone="danger" />
</div>
{/* Filter panel */}
<FilterPanel
className="mb-4"
chips={chips.length > 0 ? <FilterChips chips={chips} /> : undefined}
onClearAll={chips.length > 0 ? clearAll : undefined}
>
<FilterSearch
value={search}
onChange={setSearch}
placeholder="Search subject, predicate, object, evidence…"
/>
<FilterSelect
label="Status"
value={statusFilter}
onChange={setStatusFilter}
placeholder="All statuses"
options={[
{ value: "candidate", label: "Candidate", hint: counts.candidate, swatch: "hsl(var(--warning))" },
{ value: "approved", label: "Approved", hint: counts.approved, swatch: "hsl(var(--success))" },
{ value: "rejected", label: "Rejected", hint: counts.rejected, swatch: "hsl(var(--danger))" },
]}
/>
</FilterPanel>
<DataTable<Claim>
tableId="review"
columns={columns}
data={filteredClaims}
getRowId={(row) => row.id}
loading={claims.isLoading}
loadingRows={8}
height={640}
onRowClick={openDetail}
rowToneAccessor={(row) => {
const r = reviewLabel(row.status);
if (r === "approved") return "success";
if (r === "rejected") return "danger";
return undefined;
}}
empty={
<EmptyState
icon={ListChecks}
title="조건에 맞는 클레임이 없습니다"
description="필터를 조정하거나 클레임을 추가하세요."
primaryAction={
chips.length > 0 ? (
<Button variant="outline" onClick={clearAll}>
Clear filters
</Button>
) : undefined
}
/>
}
/>
</div>
{claims.isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(claims.error as Error).message}
</CardContent>
</Card>
)}
<div className="mb-6 grid gap-4 md:grid-cols-4">
<Metric label="Total" value={counts.total} />
<Metric label="Candidate" value={counts.candidate} />
<Metric label="Approved" value={counts.approved} />
<Metric label="Rejected" value={counts.rejected} />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Review Queue</CardTitle>
<CardDescription>
, , , .
</CardDescription>
{/* Detail Drawer */}
<Drawer
open={drawerOpen && !!selectedClaim}
onOpenChange={(open) => {
setDrawerOpen(open);
if (!open) setSelectedId(null);
}}
size="lg"
title={
selectedClaim
? `Claim #${selectedClaim.id} · ${selectedClaim.predicate}`
: ""
}
description="근거, 출처, 검증 결과, 히스토리"
headerExtra={
selectedClaim ? (
<Badge variant={statusVariant(selectedClaim.status)}>
{reviewLabel(selectedClaim.status)}
</Badge>
) : null
}
footer={
selectedClaim ? (
<>
<Button
variant="outline"
onClick={() => applyStatus(selectedClaim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
<Button
onClick={() => applyStatus(selectedClaim, "validated_claim")}
disabled={updateStatus.isPending}
>
<Check className="h-4 w-4" />
Approve
</Button>
</>
) : null
}
>
{selectedClaim && (
<div className="space-y-5">
{/* Triple summary */}
<div className="rounded-md border border-border bg-background-subtle p-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground">
{humanizeValue(selectedClaim.subject)}
</span>
{selectedClaim.subject_type && (
<span className="pill-muted">
{selectedClaim.subject_type}
</span>
)}
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="font-mono">
{selectedClaim.predicate}
</Badge>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{claimObjectText(selectedClaim)}
</span>
</div>
<div className="flex min-w-0 flex-wrap gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-56 pl-9"
placeholder="Search claims"
/>
</div>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="w-40 pl-9"
>
<option value="all">All</option>
<option value="candidate">Candidate</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</Select>
</div>
<div className="mt-2 flex flex-wrap gap-3 text-2xs text-muted-foreground">
<span>
Confidence{" "}
<span className="font-medium text-foreground tabular-nums">
{formatPercent(selectedClaim.confidence)}
</span>
</span>
<span>
Bucket{" "}
<span className="font-medium text-foreground">
{confidenceBucket(selectedClaim)}
</span>
</span>
{selectedClaim.review_required && (
<span className="pill-warning">review required</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
{claims.isLoading && (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton key={index} className="h-24" />
))}
</div>
)}
{!claims.isLoading && filteredClaims.length === 0 && (
<p className="py-10 text-center text-sm text-muted-foreground">
.
</p>
)}
<div className="space-y-3">
{filteredClaims.map((claim) => (
<article
key={claim.id}
className="rounded-md border bg-background p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<button
type="button"
onClick={() => setSelectedId(claim.id)}
className="min-w-0 flex-1 text-left"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(claim.status)}>
{reviewLabel(claim.status)}
</Badge>
<Badge variant="secondary">{claim.predicate}</Badge>
<span className="text-xs text-muted-foreground">
{confidenceBucket(claim)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">
{humanizeValue(claim.subject)}
</span>
<span className="text-muted-foreground">-&gt;</span>
<span className="font-medium">{claimObject(claim)}</span>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>Confidence {formatPercent(claim.confidence)}</span>
<span>Source {humanizeValue(claim.source)}</span>
<span>
Evidence {claim.evidence_text ? "yes" : "missing"}
</span>
<span>
Method {humanizeValue(claim.extraction_method)}
</span>
</div>
</button>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedId(claim.id)}
>
<Eye className="h-4 w-4" />
Detail
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "validated_claim")}
disabled={updateStatus.isPending}
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => applyStatus(claim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
</div>
</div>
</article>
))}
</div>
</CardContent>
</Card>
<aside className="lg:sticky lg:top-4 lg:self-start">
<Card>
<CardHeader>
<CardTitle>Claim Detail</CardTitle>
<CardDescription>
, , ,
</CardDescription>
</CardHeader>
<CardContent>
{!selectedClaim && (
<p className="py-8 text-center text-sm text-muted-foreground">
.
</p>
)}
{selectedClaim && (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={statusVariant(selectedClaim.status)}>
{reviewLabel(selectedClaim.status)}
</Badge>
<Badge variant="outline">
{formatPercent(selectedClaim.confidence)}
</Badge>
{selectedClaim.review_required && (
<Badge variant="warning">review required</Badge>
)}
</div>
<DetailRow label="Subject" value={selectedClaim.subject} />
<DetailRow label="Subject Type" value={selectedClaim.subject_type} />
<DetailRow label="Predicate" value={selectedClaim.predicate} />
<DetailRow
label="Object"
value={selectedClaim.object ?? selectedClaim.object_value}
/>
<DetailRow label="Source" value={selectedClaim.source} />
<DetailRow label="Source URL" value={selectedClaim.page_url} />
<DetailRow
label="Page Type"
value={selectedClaim.page_type}
/>
<DetailRow
label="Created By"
value={selectedClaim.extraction_method}
/>
<DetailRow
label="Validation"
value={
selectedClaim.validation_status ??
selectedClaim.graph_merge_status
}
/>
{selectedClaim.graph_merge_reason && (
<DetailRow
label="Graph Reason"
value={selectedClaim.graph_merge_reason}
/>
)}
{selectedClaim.evidence_text && (
<section>
<h3 className="mb-2 text-sm font-medium">Evidence Text</h3>
<div className="rounded-md bg-yellow-50 px-3 py-2 text-sm leading-relaxed text-yellow-950">
{selectedClaim.evidence_text}
</div>
</section>
)}
{selectedClaim.confidence_breakdown && (
<section>
<h3 className="mb-2 text-sm font-medium">
Confidence Breakdown
</h3>
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
{JSON.stringify(
selectedClaim.confidence_breakdown,
null,
2,
)}
</pre>
</section>
)}
{selectedClaim.source_history &&
selectedClaim.source_history.length > 0 && (
<section>
<h3 className="mb-2 text-sm font-medium">
Source History
</h3>
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
{JSON.stringify(selectedClaim.source_history, null, 2)}
</pre>
</section>
)}
<div className="grid grid-cols-2 gap-2 pt-2">
<Button
onClick={() =>
applyStatus(selectedClaim, "validated_claim")
}
disabled={updateStatus.isPending}
{/* Detail grid */}
<div className="grid gap-3 sm:grid-cols-2">
<DetailRow label="Source" value={selectedClaim.source} />
<DetailRow
label="Page Type"
value={selectedClaim.page_type}
/>
<DetailRow
label="Extraction Method"
value={selectedClaim.extraction_method}
/>
<DetailRow
label="Validation"
value={
selectedClaim.validation_status ??
selectedClaim.graph_merge_status
}
/>
{selectedClaim.page_url && (
<DetailRow
label="Source URL"
value={
<a
href={selectedClaim.page_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-brand-600 hover:underline dark:text-brand-300"
>
<Check className="h-4 w-4" />
Approve
</Button>
<Button
variant="outline"
onClick={() => applyStatus(selectedClaim, "rejected")}
disabled={updateStatus.isPending}
>
<X className="h-4 w-4" />
Reject
</Button>
</div>
<div className="text-xs text-muted-foreground">
Last seen {formatDateTime(selectedClaim.last_seen_at)}
</div>
</div>
<ExternalLink className="h-3 w-3" />
<span className="max-w-[240px] truncate">
{selectedClaim.page_url}
</span>
</a>
}
full
/>
)}
</CardContent>
</Card>
</aside>
</div>
</div>
{selectedClaim.graph_merge_reason && (
<DetailRow
label="Graph Reason"
value={selectedClaim.graph_merge_reason}
full
/>
)}
</div>
{/* Evidence */}
{selectedClaim.evidence_text && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Evidence Text
</h3>
<blockquote className="rounded-md border-l-4 border-warning bg-warning-subtle px-3 py-2 text-sm leading-relaxed text-foreground">
{selectedClaim.evidence_text}
</blockquote>
</section>
)}
{/* Confidence breakdown */}
{selectedClaim.confidence_breakdown && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Confidence Breakdown
</h3>
<pre className="max-h-44 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs">
{JSON.stringify(
selectedClaim.confidence_breakdown,
null,
2,
)}
</pre>
</section>
)}
{/* Source history */}
{selectedClaim.source_history &&
selectedClaim.source_history.length > 0 && (
<section>
<h3 className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Source History ({selectedClaim.source_history.length})
</h3>
<pre className="max-h-44 overflow-auto rounded-md border border-border bg-background-subtle p-3 text-2xs">
{JSON.stringify(selectedClaim.source_history, null, 2)}
</pre>
</section>
)}
<div className="border-t border-border pt-3 text-2xs text-muted-foreground">
Last seen {formatDateTime(selectedClaim.last_seen_at)}
</div>
</div>
)}
</Drawer>
</>
);
}
function Metric({ label, value }: { label: string; value: number }) {
/* ============================================================
* Sub-components
* ========================================================== */
const METRIC_TONE: Record<string, string> = {
muted: "border-border",
warning: "border-warning-border bg-warning-subtle/30",
success: "border-success-border bg-success-subtle/30",
danger: "border-danger-border bg-danger-subtle/30",
};
function Metric({
label,
value,
tone = "muted",
}: {
label: string;
value: number;
tone?: keyof typeof METRIC_TONE;
}) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">
<Card className={`border ${METRIC_TONE[tone]}`}>
<CardContent className="py-3">
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-foreground">
{value.toLocaleString()}
</div>
</CardContent>
@@ -426,11 +626,48 @@ function Metric({ label, value }: { label: string; value: number }) {
);
}
function DetailRow({ label, value }: { label: string; value: unknown }) {
function ConfidenceBar({ value }: { value: number | undefined }) {
if (typeof value !== "number") {
return <span className="text-xs text-muted-foreground/60"></span>;
}
const pct = Math.round(value * 100);
const tone =
value >= 0.9
? "bg-success"
: value >= 0.7
? "bg-info"
: value >= 0.5
? "bg-warning"
: "bg-danger";
return (
<div className="rounded-md border bg-background px-3 py-2 text-sm">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
<div className="flex items-center justify-end gap-2">
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-muted">
<div className={`h-full ${tone}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-foreground">{pct}%</span>
</div>
);
}
function DetailRow({
label,
value,
full = false,
}: {
label: string;
value: unknown;
full?: boolean;
}) {
return (
<div
className={`rounded-md border border-border bg-background-subtle px-3 py-2 ${full ? "sm:col-span-2" : ""}`}
>
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-1 break-words text-sm font-medium text-foreground">
{React.isValidElement(value) ? value : humanizeValue(value)}
</div>
</div>
);
}

View File

@@ -1,10 +1,35 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export type ThemeMode = "light" | "dark" | "system";
const THEME_STORAGE_KEY = "op.theme";
function readStoredTheme(): ThemeMode {
if (typeof window === "undefined") return "system";
try {
const v = window.localStorage.getItem(THEME_STORAGE_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
/* ignore */
}
return "system";
}
function persistTheme(mode: ThemeMode) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}
export interface UIState {
sidebarOpen: boolean;
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
selectedProjectId: string | null;
isLoading: boolean;
theme: ThemeMode;
notification: {
type: "success" | "error" | "info" | "warning" | null;
message: string;
@@ -16,6 +41,7 @@ const initialState: UIState = {
currentStep: 0,
selectedProjectId: null,
isLoading: false,
theme: readStoredTheme(),
notification: {
type: null,
message: "",
@@ -50,6 +76,20 @@ const uiSlice = createSlice({
clearNotification: (state) => {
state.notification = { type: null, message: "" };
},
setTheme: (state, action: PayloadAction<ThemeMode>) => {
state.theme = action.payload;
persistTheme(action.payload);
},
cycleTheme: (state) => {
const next: ThemeMode =
state.theme === "light"
? "dark"
: state.theme === "dark"
? "system"
: "light";
state.theme = next;
persistTheme(next);
},
},
});
@@ -60,6 +100,8 @@ export const {
setLoading,
showNotification,
clearNotification,
setTheme,
cycleTheme,
} = uiSlice.actions;
export default uiSlice.reducer;

View File

@@ -2,50 +2,180 @@
@tailwind components;
@tailwind utilities;
/* ============================================================================
* Design Tokens — Ontology Platform
* Two layers:
* 1. Primitive tokens (brand-*, chart-*) — raw palette
* 2. Semantic tokens (background, surface, primary, ring, success, ...)
* reference primitives; only semantic tokens should be used in components.
* Values are HSL triplets (no `hsl()` wrapper) for Tailwind opacity modifiers.
* ========================================================================== */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--muted: 221.2 63.6% 97%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 221.2 83.2% 53.3%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--primary: 222.2 47.6% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.6% 11.2%;
/* — Brand scale (deep indigo, tuned for data-heavy surfaces) — */
--brand-50: 220 100% 97%;
--brand-100: 220 95% 93%;
--brand-200: 221 92% 86%;
--brand-300: 222 88% 76%;
--brand-400: 224 84% 64%;
--brand-500: 226 78% 55%;
--brand-600: 228 74% 47%;
--brand-700: 229 70% 39%;
--brand-800: 230 64% 32%;
--brand-900: 231 58% 24%;
--brand-950: 232 60% 14%;
/* — Chart palette (qualitative, colorblind-safer ordering) — */
--chart-1: 226 78% 55%;
--chart-2: 175 70% 41%;
--chart-3: 32 95% 56%;
--chart-4: 280 68% 60%;
--chart-5: 340 80% 58%;
--chart-6: 142 64% 42%;
--chart-7: 200 90% 48%;
--chart-8: 12 78% 56%;
--chart-9: 55 88% 52%;
--chart-10: 260 70% 62%;
--chart-11: 165 60% 38%;
--chart-12: 300 60% 52%;
/* — Semantic surfaces (light) — */
--background: 210 25% 99%;
--background-subtle: 220 24% 96%;
--surface: 0 0% 100%;
--surface-raised: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--muted: 220 20% 96%;
--muted-foreground: 220 12% 44%;
--primary: 226 78% 55%;
--primary-foreground: 210 40% 98%;
--secondary: 220 20% 94%;
--secondary-foreground: 222 47% 16%;
--accent: 220 20% 94%;
--accent-foreground: 222 47% 16%;
--border: 220 16% 90%;
--border-strong: 220 14% 82%;
--input: 220 16% 90%;
--ring: 226 78% 55%;
/* — Semantic states (light) — */
--success: 142 62% 40%;
--success-foreground: 140 80% 98%;
--success-subtle: 142 60% 95%;
--success-border: 142 50% 78%;
--warning: 32 92% 48%;
--warning-foreground: 30 90% 98%;
--warning-subtle: 40 95% 94%;
--warning-border: 36 88% 76%;
--danger: 0 75% 52%;
--danger-foreground: 0 80% 98%;
--danger-subtle: 0 80% 96%;
--danger-border: 0 70% 82%;
--info: 210 85% 50%;
--info-foreground: 210 90% 98%;
--info-subtle: 210 90% 95%;
--info-border: 210 80% 80%;
/* — Geometry — */
--radius: 0.625rem;
/* — Elevation (light) — uses cool-tinted shadows for crispness — */
--shadow-xs: 0 1px 2px 0 hsl(220 30% 12% / 0.04);
--shadow-sm: 0 1px 2px 0 hsl(220 30% 12% / 0.05),
0 1px 3px 0 hsl(220 30% 12% / 0.06);
--shadow-md: 0 2px 4px -1px hsl(220 30% 12% / 0.06),
0 4px 8px -2px hsl(220 30% 12% / 0.08);
--shadow-lg: 0 4px 8px -2px hsl(220 30% 12% / 0.08),
0 12px 24px -6px hsl(220 30% 12% / 0.12);
--shadow-xl: 0 8px 16px -4px hsl(220 30% 12% / 0.10),
0 24px 48px -12px hsl(220 30% 12% / 0.18);
--shadow-2xl: 0 32px 64px -16px hsl(220 30% 12% / 0.24);
/* — Typography (font families) — */
--font-sans: "Pretendard Variable", "Pretendard", "Inter",
ui-sans-serif, system-ui, -apple-system, "Segoe UI",
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
--font-display: "Inter", "Pretendard Variable", "Pretendard",
ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace,
SFMono-Regular, Menlo, Consolas, monospace;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 222.2 47.6% 11.2%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.6% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
/* — Brand scale stays primitive; semantic tokens re-point — */
--background: 224 30% 7%;
--background-subtle: 224 28% 10%;
--surface: 224 28% 10%;
--surface-raised: 224 26% 13%;
--foreground: 210 30% 96%;
--card: 224 26% 11%;
--card-foreground: 210 30% 96%;
--popover: 224 26% 13%;
--popover-foreground: 210 30% 96%;
--muted: 224 22% 16%;
--muted-foreground: 220 14% 64%;
--primary: 224 84% 64%;
--primary-foreground: 226 80% 10%;
--secondary: 224 22% 18%;
--secondary-foreground: 210 30% 96%;
--accent: 224 22% 20%;
--accent-foreground: 210 30% 96%;
--border: 224 20% 20%;
--border-strong: 224 18% 28%;
--input: 224 20% 20%;
--ring: 224 84% 64%;
--success: 142 55% 50%;
--success-foreground: 142 80% 8%;
--success-subtle: 142 40% 16%;
--success-border: 142 40% 28%;
--warning: 36 92% 58%;
--warning-foreground: 30 90% 10%;
--warning-subtle: 36 50% 18%;
--warning-border: 36 50% 30%;
--danger: 0 72% 60%;
--danger-foreground: 0 80% 98%;
--danger-subtle: 0 50% 20%;
--danger-border: 0 50% 32%;
--info: 210 90% 62%;
--info-foreground: 210 90% 10%;
--info-subtle: 210 50% 18%;
--info-border: 210 50% 30%;
/* shadows on dark are softer and rely on borders + glow */
--shadow-xs: 0 1px 2px 0 hsl(0 0% 0% / 0.30);
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.32),
0 1px 3px 0 hsl(0 0% 0% / 0.30);
--shadow-md: 0 2px 4px -1px hsl(0 0% 0% / 0.36),
0 4px 8px -2px hsl(0 0% 0% / 0.40);
--shadow-lg: 0 4px 8px -2px hsl(0 0% 0% / 0.44),
0 12px 24px -6px hsl(0 0% 0% / 0.48);
--shadow-xl: 0 8px 16px -4px hsl(0 0% 0% / 0.50),
0 24px 48px -12px hsl(0 0% 0% / 0.55);
--shadow-2xl: 0 32px 64px -16px hsl(0 0% 0% / 0.65);
}
}
@@ -54,7 +184,92 @@
@apply border-border;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "cv11", "ss01", "ss03";
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground font-sans text-base;
font-synthesis-weight: none;
}
/* Focus ring — consistent across all interactive elements */
:where(button, a, [role="button"], input, select, textarea, [tabindex]):not(
[tabindex="-1"]
):focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Selection */
::selection {
background-color: hsl(var(--brand-500) / 0.25);
color: hsl(var(--foreground));
}
/* Scrollbars (Webkit) */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: hsl(var(--border-strong));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.6);
background-clip: padding-box;
}
/* Headings default to display font with tight tracking */
h1, h2, h3, h4 {
@apply font-heading tracking-tight text-foreground;
font-weight: 600;
}
/* Numeric tabular figures for tables/metrics */
.tabular-nums,
table,
[data-tabular] {
font-variant-numeric: tabular-nums;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
/* Surface helpers — use instead of bg-card/bg-background ad-hoc */
.surface-1 {
@apply bg-surface border border-border;
}
.surface-2 {
@apply bg-surface-raised border border-border shadow-sm;
}
/* Status pill — semantic colored chip */
.pill {
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs font-medium;
}
.pill-success { @apply pill bg-success-subtle text-success border border-success-border; }
.pill-warning { @apply pill bg-warning-subtle text-warning border border-warning-border; }
.pill-danger { @apply pill bg-danger-subtle text-danger border border-danger-border; }
.pill-info { @apply pill bg-info-subtle text-info border border-info-border; }
.pill-muted { @apply pill bg-muted text-muted-foreground border border-border; }
}

View File

@@ -1,16 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
],
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1rem",
screens: { "2xl": "1440px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
"border-strong": "hsl(var(--border-strong))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
"background-subtle": "hsl(var(--background-subtle))",
surface: "hsl(var(--surface))",
"surface-raised": "hsl(var(--surface-raised))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
@@ -20,10 +27,6 @@ export default {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
@@ -32,13 +35,153 @@ export default {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
// Brand scale (indigo-cyan blend tuned for analytics products)
brand: {
50: "hsl(var(--brand-50))",
100: "hsl(var(--brand-100))",
200: "hsl(var(--brand-200))",
300: "hsl(var(--brand-300))",
400: "hsl(var(--brand-400))",
500: "hsl(var(--brand-500))",
600: "hsl(var(--brand-600))",
700: "hsl(var(--brand-700))",
800: "hsl(var(--brand-800))",
900: "hsl(var(--brand-900))",
950: "hsl(var(--brand-950))",
},
// Semantic states (each has bg/border/foreground)
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
subtle: "hsl(var(--success-subtle))",
border: "hsl(var(--success-border))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
subtle: "hsl(var(--warning-subtle))",
border: "hsl(var(--warning-border))",
},
danger: {
DEFAULT: "hsl(var(--danger))",
foreground: "hsl(var(--danger-foreground))",
subtle: "hsl(var(--danger-subtle))",
border: "hsl(var(--danger-border))",
},
info: {
DEFAULT: "hsl(var(--info))",
foreground: "hsl(var(--info-foreground))",
subtle: "hsl(var(--info-subtle))",
border: "hsl(var(--info-border))",
},
// alias for legacy code paths
destructive: {
DEFAULT: "hsl(var(--danger))",
foreground: "hsl(var(--danger-foreground))",
},
// Chart palette — qualitative, 12 hues
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
6: "hsl(var(--chart-6))",
7: "hsl(var(--chart-7))",
8: "hsl(var(--chart-8))",
9: "hsl(var(--chart-9))",
10: "hsl(var(--chart-10))",
11: "hsl(var(--chart-11))",
12: "hsl(var(--chart-12))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
xs: "calc(var(--radius) - 6px)",
sm: "calc(var(--radius) - 4px)",
md: "calc(var(--radius) - 2px)",
lg: "var(--radius)",
xl: "calc(var(--radius) + 4px)",
"2xl": "calc(var(--radius) + 8px)",
},
fontFamily: {
sans: ["var(--font-sans)"],
mono: ["var(--font-mono)"],
heading: ["var(--font-display)"],
},
fontSize: {
// Type ramp tuned for dense enterprise UI (line-height as second value)
"2xs": ["0.6875rem", { lineHeight: "1rem", letterSpacing: "0.01em" }],
xs: ["0.75rem", { lineHeight: "1.125rem" }],
sm: ["0.8125rem", { lineHeight: "1.25rem" }],
base: ["0.875rem", { lineHeight: "1.375rem" }],
md: ["0.9375rem", { lineHeight: "1.5rem" }],
lg: ["1rem", { lineHeight: "1.5rem" }],
xl: ["1.125rem", { lineHeight: "1.75rem" }],
"2xl": ["1.375rem", { lineHeight: "1.875rem", letterSpacing: "-0.01em" }],
"3xl": ["1.75rem", { lineHeight: "2.125rem", letterSpacing: "-0.015em" }],
"4xl": ["2.25rem", { lineHeight: "2.625rem", letterSpacing: "-0.02em" }],
display: ["3rem", { lineHeight: "3.25rem", letterSpacing: "-0.025em", fontWeight: "600" }],
},
spacing: {
// density-aware row heights
"row-compact": "32px",
"row-cozy": "40px",
"row-comfortable": "48px",
header: "56px",
"header-sm": "48px",
},
boxShadow: {
xs: "var(--shadow-xs)",
sm: "var(--shadow-sm)",
md: "var(--shadow-md)",
lg: "var(--shadow-lg)",
xl: "var(--shadow-xl)",
"2xl": "var(--shadow-2xl)",
"focus-ring": "0 0 0 2px hsl(var(--background)), 0 0 0 4px hsl(var(--ring))",
"inner-border": "inset 0 0 0 1px hsl(var(--border))",
},
transitionTimingFunction: {
"out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
"in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)",
},
transitionDuration: {
instant: "80ms",
fast: "120ms",
DEFAULT: "200ms",
slow: "320ms",
},
keyframes: {
"fade-in": {
from: { opacity: "0" },
to: { opacity: "1" },
},
"fade-in-up": {
from: { opacity: "0", transform: "translateY(4px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"slide-in-right": {
from: { transform: "translateX(8px)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
animation: {
"fade-in": "fade-in 200ms cubic-bezier(0.16, 1, 0.3, 1)",
"fade-in-up": "fade-in-up 240ms cubic-bezier(0.16, 1, 0.3, 1)",
"slide-in-right": "slide-in-right 200ms cubic-bezier(0.16, 1, 0.3, 1)",
shimmer: "shimmer 1.6s linear infinite",
},
},
},
plugins: [],
}
};