Compare commits
3 Commits
33584dd631
...
de46b657c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de46b657c2 | ||
|
|
6a6befc485 | ||
|
|
651c47d698 |
@@ -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)
|
||||
@@ -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.
@@ -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):
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
912
ontology_platform/web/frontend/package-lock.json
generated
912
ontology_platform/web/frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "온톨로지 빌더",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 “{query}”
|
||||
</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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`),
|
||||
);
|
||||
1058
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
1058
ontology_platform/web/frontend/src/components/ui/data-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal file
214
ontology_platform/web/frontend/src/components/ui/dialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal file
143
ontology_platform/web/frontend/src/components/ui/drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal file
150
ontology_platform/web/frontend/src/components/ui/error-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal file
59
ontology_platform/web/frontend/src/components/ui/tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
|
||||
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
173
ontology_platform/web/frontend/src/hooks/useKeyboardShortcuts.ts
Normal 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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal file
30
ontology_platform/web/frontend/src/hooks/useTheme.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
1082
ontology_platform/web/frontend/src/pages/DomainBuilderPage.tsx
Normal file
1082
ontology_platform/web/frontend/src/pages/DomainBuilderPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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">-></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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user