mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): add MCP UI variants and TUI output (#545)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# MCP UI Bakeoff - Instructions & Test Plan
|
||||
|
||||
Last updated: 2026-02-02
|
||||
|
||||
## Scope
|
||||
|
||||
Compare three presentation paths for Basic Memory MCP tools:
|
||||
|
||||
1. **Tool‑UI (React)** via MCP App resources.
|
||||
2. **MCP‑UI Python SDK** embedded UI resources (legacy host path).
|
||||
3. **ASCII/ANSI** output for TUI clients.
|
||||
|
||||
This doc is the running instruction set and test plan. Update as implementation progresses.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
|
||||
- Node for tool‑ui build (already used for POC)
|
||||
- Python 3.12+ with `uv`
|
||||
|
||||
Optional (for MCP‑UI Python SDK path):
|
||||
|
||||
- Local repo: `/Users/phernandez/dev/mcp-ui`
|
||||
- Install the server SDK into the Basic Memory venv:
|
||||
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
|
||||
|
||||
---
|
||||
|
||||
## Build / Refresh Steps
|
||||
|
||||
### Tool‑UI React bundle
|
||||
|
||||
```bash
|
||||
cd ui/tool-ui-react
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This regenerates:
|
||||
|
||||
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
|
||||
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
|
||||
|
||||
---
|
||||
|
||||
## How to Run the MCP Server
|
||||
|
||||
```bash
|
||||
basic-memory mcp --transport stdio
|
||||
```
|
||||
|
||||
Optional to pick UI variant for MCP App resources:
|
||||
|
||||
```bash
|
||||
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1) MCP App Resource UI (tool‑ui / vanilla / mcp‑ui)
|
||||
|
||||
Tools:
|
||||
- `search_notes`
|
||||
- `read_note`
|
||||
|
||||
Expect:
|
||||
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
|
||||
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
|
||||
- Variant‑specific URIs also available:
|
||||
- `ui://basic-memory/search-results/vanilla`
|
||||
- `ui://basic-memory/search-results/tool-ui`
|
||||
- `ui://basic-memory/search-results/mcp-ui`
|
||||
- `ui://basic-memory/note-preview/vanilla`
|
||||
- `ui://basic-memory/note-preview/tool-ui`
|
||||
- `ui://basic-memory/note-preview/mcp-ui`
|
||||
|
||||
Manual check:
|
||||
- Trigger tool in MCP‑App‑capable host and confirm UI renders.
|
||||
|
||||
---
|
||||
|
||||
### 2) ASCII / ANSI TUI Output
|
||||
|
||||
Tools:
|
||||
- `search_notes(output_format="ascii" | "ansi")`
|
||||
- `read_note(output_format="ascii" | "ansi")`
|
||||
|
||||
Expect:
|
||||
- ASCII table for search, header + content preview for note.
|
||||
- ANSI variants include color escape codes.
|
||||
|
||||
Automated:
|
||||
- `uv run pytest test-int/mcp/test_output_format_ascii_integration.py`
|
||||
|
||||
---
|
||||
|
||||
### 3) MCP‑UI Python SDK (embedded UI resource)
|
||||
|
||||
Tools (embedded resource responses):
|
||||
- `search_notes_ui` (MCP‑UI SDK)
|
||||
- `read_note_ui` (MCP‑UI SDK)
|
||||
|
||||
Expected output:
|
||||
- Tool response content contains an EmbeddedResource (`type: "resource"`)
|
||||
- `mimeType` is `text/html`
|
||||
- `_meta` includes:
|
||||
- `mcpui.dev/ui-preferred-frame-size`
|
||||
- `mcpui.dev/ui-initial-render-data`
|
||||
|
||||
Manual check:
|
||||
- Render tool responses using `UIResourceRenderer` (legacy host flow).
|
||||
|
||||
Automated (if SDK installed):
|
||||
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
|
||||
|
||||
---
|
||||
|
||||
## Bakeoff Notes Template
|
||||
|
||||
Fill in after running:
|
||||
|
||||
- Tool‑UI (React): __
|
||||
- MCP‑UI SDK (embedded): __
|
||||
- ASCII/ANSI: __
|
||||
|
||||
Decision + rationale: __
|
||||
@@ -53,6 +53,7 @@ def mcp(
|
||||
# Import mcp tools/prompts to register them with the server
|
||||
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
|
||||
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Formatting helpers for MCP tool outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from basic_memory.schemas.search import SearchResponse, SearchResult
|
||||
|
||||
ANSI_RESET = "\x1b[0m"
|
||||
ANSI_BOLD = "\x1b[1m"
|
||||
ANSI_DIM = "\x1b[2m"
|
||||
ANSI_CYAN = "\x1b[36m"
|
||||
|
||||
|
||||
def _apply_style(text: str, style: str, enabled: bool) -> str:
|
||||
if not enabled:
|
||||
return text
|
||||
return f"{style}{text}{ANSI_RESET}"
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return text
|
||||
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
return "\n".join(lines[idx + 1 :]).lstrip()
|
||||
return text
|
||||
|
||||
|
||||
def _parse_title(text: str) -> str | None:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _truncate(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return text[:width]
|
||||
return text[: width - 3] + "..."
|
||||
|
||||
|
||||
def _make_separator(widths: Sequence[int]) -> str:
|
||||
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
|
||||
|
||||
|
||||
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
|
||||
cells = []
|
||||
for value, width in zip(values, widths, strict=True):
|
||||
cells.append(f" {_truncate(value, width).ljust(width)} ")
|
||||
return "|" + "|".join(cells) + "|"
|
||||
|
||||
|
||||
def _get_result_tags(result: SearchResult) -> str:
|
||||
metadata = result.metadata or {}
|
||||
if isinstance(metadata, dict):
|
||||
tags = metadata.get("tags")
|
||||
if isinstance(tags, list):
|
||||
return ", ".join(str(tag) for tag in tags if tag)
|
||||
return ""
|
||||
|
||||
|
||||
def _get_result_path(result: SearchResult) -> str:
|
||||
return result.permalink or result.file_path or ""
|
||||
|
||||
|
||||
def format_search_results_ascii(
|
||||
result: SearchResponse,
|
||||
query: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format search results as an ASCII table for TUI clients."""
|
||||
|
||||
results = result.results or []
|
||||
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header_line]
|
||||
|
||||
if query:
|
||||
lines.append(f"Query: {query}")
|
||||
|
||||
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
|
||||
lines.append(_apply_style(summary, ANSI_DIM, color))
|
||||
|
||||
if not results:
|
||||
lines.append("No results.")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
|
||||
rows = []
|
||||
for idx, item in enumerate(results, start=1):
|
||||
rows.append(
|
||||
[
|
||||
str(idx),
|
||||
item.title or "Untitled",
|
||||
item.type.value if hasattr(item.type, "value") else str(item.type),
|
||||
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
|
||||
_get_result_path(item),
|
||||
_get_result_tags(item),
|
||||
]
|
||||
)
|
||||
|
||||
max_widths = [3, 32, 10, 7, 36, 24]
|
||||
widths = []
|
||||
for index, header in enumerate(headers):
|
||||
column_values = [header] + [row[index] for row in rows]
|
||||
max_len = max(len(value) for value in column_values)
|
||||
widths.append(min(max_widths[index], max_len))
|
||||
|
||||
table = [_make_separator(widths)]
|
||||
header_row = _format_row(headers, widths)
|
||||
if color:
|
||||
header_cells = []
|
||||
for value, width in zip(headers, widths, strict=True):
|
||||
padded = f" {_truncate(value, width).ljust(width)} "
|
||||
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
|
||||
header_row = "|" + "|".join(header_cells) + "|"
|
||||
table.append(header_row)
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
for row in rows:
|
||||
table.append(_format_row(row, widths))
|
||||
|
||||
table.append(_make_separator(widths))
|
||||
|
||||
lines.append("")
|
||||
lines.extend(table)
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def format_note_preview_ascii(
|
||||
content: str,
|
||||
identifier: str | None = None,
|
||||
color: bool = False,
|
||||
) -> str:
|
||||
"""Format note content for ASCII/TUI display."""
|
||||
|
||||
identifier = identifier or ""
|
||||
cleaned = _strip_frontmatter(content)
|
||||
title = _parse_title(cleaned) or identifier or "Note Preview"
|
||||
|
||||
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
|
||||
lines = [header, f"Title: {title}"]
|
||||
|
||||
if identifier:
|
||||
lines.append(f"Identifier: {identifier}")
|
||||
|
||||
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
|
||||
|
||||
if content.strip():
|
||||
lines.append(content.rstrip())
|
||||
else:
|
||||
lines.append("(empty note)")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""MCP resources for Basic Memory."""
|
||||
|
||||
from basic_memory.mcp.resources.project_info import project_info
|
||||
from basic_memory.mcp.resources.ui import (
|
||||
note_preview_ui,
|
||||
note_preview_ui_mcp_ui,
|
||||
note_preview_ui_tool_ui,
|
||||
note_preview_ui_vanilla,
|
||||
search_results_ui,
|
||||
search_results_ui_mcp_ui,
|
||||
search_results_ui_tool_ui,
|
||||
search_results_ui_vanilla,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"project_info",
|
||||
"note_preview_ui",
|
||||
"note_preview_ui_mcp_ui",
|
||||
"note_preview_ui_tool_ui",
|
||||
"note_preview_ui_vanilla",
|
||||
"search_results_ui",
|
||||
"search_results_ui_mcp_ui",
|
||||
"search_results_ui_tool_ui",
|
||||
"search_results_ui_vanilla",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""UI resources for MCP Apps integration."""
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.ui import load_html, load_variant_html
|
||||
|
||||
# FastMCP's MIME type validator currently accepts only type/subtype, so we
|
||||
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
|
||||
MIME_TYPE = "text/html"
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results",
|
||||
name="Basic Memory Search Results",
|
||||
description="Search results UI for Basic Memory tools.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui() -> str:
|
||||
return load_variant_html("search-results")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview",
|
||||
name="Basic Memory Note Preview",
|
||||
description="Note preview UI for Basic Memory read_note tool.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui() -> str:
|
||||
return load_variant_html("note-preview")
|
||||
|
||||
|
||||
# Variant-specific resource URIs for bakeoff comparisons.
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/vanilla",
|
||||
name="Basic Memory Search Results (Vanilla)",
|
||||
description="Vanilla HTML search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_vanilla() -> str:
|
||||
return load_html("search-results-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/tool-ui",
|
||||
name="Basic Memory Search Results (Tool UI)",
|
||||
description="Tool UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_tool_ui() -> str:
|
||||
return load_html("search-results-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui",
|
||||
name="Basic Memory Search Results (MCP UI)",
|
||||
description="MCP UI styled search results UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def search_results_ui_mcp_ui() -> str:
|
||||
return load_html("search-results-mcp-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/vanilla",
|
||||
name="Basic Memory Note Preview (Vanilla)",
|
||||
description="Vanilla HTML note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_vanilla() -> str:
|
||||
return load_html("note-preview-vanilla.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/tool-ui",
|
||||
name="Basic Memory Note Preview (Tool UI)",
|
||||
description="Tool UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_tool_ui() -> str:
|
||||
return load_html("note-preview-tool-ui.html")
|
||||
|
||||
|
||||
@mcp.resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui",
|
||||
name="Basic Memory Note Preview (MCP UI)",
|
||||
description="MCP UI styled note preview UI.",
|
||||
mime_type=MIME_TYPE,
|
||||
)
|
||||
def note_preview_ui_mcp_ui() -> str:
|
||||
return load_html("note-preview-mcp-ui.html")
|
||||
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.read_content import read_content
|
||||
from basic_memory.mcp.tools.build_context import build_context
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.cloud_info import cloud_info
|
||||
@@ -47,6 +48,7 @@ __all__ = [
|
||||
"read_content",
|
||||
"read_note",
|
||||
"release_notes",
|
||||
"read_note_ui",
|
||||
"recent_activity",
|
||||
"schema_diff",
|
||||
"schema_infer",
|
||||
@@ -54,6 +56,7 @@ __all__ = [
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
"search_notes_ui",
|
||||
"view_note",
|
||||
"write_note",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Read note tool for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.formatting import format_note_preview_ascii
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.utils import validate_project_path
|
||||
@@ -15,12 +16,14 @@ from basic_memory.utils import validate_project_path
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a markdown note by title or permalink.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
|
||||
)
|
||||
async def read_note(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Return the raw markdown for a note, or guidance text if no match is found.
|
||||
@@ -46,6 +49,8 @@ async def read_note(
|
||||
Can be a full memory:// URL, a permalink, a title, or search text
|
||||
page: Page number for paginated results (default: 1)
|
||||
page_size: Number of items per page (default: 10)
|
||||
output_format: "default" returns markdown, "ascii" returns a plain text preview,
|
||||
"ansi" returns a colorized preview for TUI clients.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -116,6 +121,12 @@ async def read_note(
|
||||
# If successful, return the content
|
||||
if response.status_code == 200:
|
||||
logger.info("Returning read_note result from resource: {path}", path=entity_path)
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
|
||||
@@ -140,6 +151,12 @@ async def read_note(
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Found note by title search: {result.permalink}")
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_note_preview_ascii(
|
||||
response.text,
|
||||
identifier=identifier,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
return response.text
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.info(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
from basic_memory.mcp.formatting import format_search_results_ascii
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.schemas.search import (
|
||||
SearchItemType,
|
||||
@@ -230,6 +231,7 @@ Error searching for '{query}': {error_message}
|
||||
|
||||
@mcp.tool(
|
||||
description="Search across all content in the knowledge base with advanced syntax support.",
|
||||
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
|
||||
)
|
||||
async def search_notes(
|
||||
query: str,
|
||||
@@ -237,6 +239,7 @@ async def search_notes(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
output_format: Literal["default", "ascii", "ansi"] = "default",
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
@@ -320,6 +323,8 @@ async def search_notes(
|
||||
page_size: The number of results to return per page (default 10)
|
||||
search_type: Type of search to perform, one of:
|
||||
"text", "title", "permalink", "vector", "hybrid" (default: "text")
|
||||
output_format: "default" returns structured data, "ascii" returns a plain text table,
|
||||
"ansi" returns a colorized table for TUI clients.
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
|
||||
@@ -452,6 +457,13 @@ async def search_notes(
|
||||
# Don't treat this as an error, but the user might want guidance
|
||||
# We return the empty result as normal - the user can decide if they need help
|
||||
|
||||
if output_format in ("ascii", "ansi"):
|
||||
return format_search_results_ascii(
|
||||
result,
|
||||
query=query,
|
||||
color=output_format == "ansi",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Embedded UI tools using the MCP-UI Python SDK."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastmcp import Context
|
||||
from mcp.types import ContentBlock, TextContent
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.ui.sdk import MissingMCPUIServerError, build_embedded_ui_resource
|
||||
|
||||
|
||||
def _text_block(message: str) -> List[ContentBlock]:
|
||||
return [TextContent(type="text", text=message)]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def search_notes_ui(
|
||||
query: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: str = "text",
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a search results UI as an embedded MCP-UI resource."""
|
||||
result = await search_notes.fn(
|
||||
query=query,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
output_format="default",
|
||||
types=types,
|
||||
entity_types=entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if isinstance(result, str):
|
||||
return _text_block(result)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": result.model_dump(),
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/search-results/mcp-ui-sdk",
|
||||
html_filename="search-results-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "540px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
|
||||
output_schema=None,
|
||||
)
|
||||
async def read_note_ui(
|
||||
identifier: str,
|
||||
project: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
context: Context | None = None,
|
||||
) -> List[ContentBlock]:
|
||||
"""Return a note preview UI as an embedded MCP-UI resource."""
|
||||
content = await read_note.fn(
|
||||
identifier=identifier,
|
||||
project=project,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="default",
|
||||
context=context,
|
||||
)
|
||||
|
||||
render_data = {
|
||||
"toolInput": {
|
||||
"identifier": identifier,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
},
|
||||
"toolOutput": content,
|
||||
}
|
||||
|
||||
try:
|
||||
resource = build_embedded_ui_resource(
|
||||
uri="ui://basic-memory/note-preview/mcp-ui-sdk",
|
||||
html_filename="note-preview-mcp-ui.html",
|
||||
render_data=render_data,
|
||||
preferred_frame_size=["100%", "640px"],
|
||||
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
|
||||
)
|
||||
except MissingMCPUIServerError as exc:
|
||||
return _text_block(str(exc))
|
||||
|
||||
return [resource]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""UI helpers for MCP Apps resources."""
|
||||
|
||||
from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html
|
||||
|
||||
__all__ = ["get_ui_variant", "load_html", "load_variant_html"]
|
||||
@@ -0,0 +1,176 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview - MCP UI</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fdf2f2;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a1a1d;
|
||||
--muted: #6b6770;
|
||||
--border: #ecd7dd;
|
||||
--accent: #b91c1c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #fff7f7 0%, #f7ecec 60%, #f0e3e4 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fee2e2;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="badge">mcp-ui style</div>
|
||||
</div>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Note Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f1ea;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d1d1f;
|
||||
--muted: #6d6d6d;
|
||||
--accent: #0f766e;
|
||||
--border: #e1d7cc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
background: linear-gradient(140deg, #f7f3ed, #efe8dd);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">Note Preview</h1>
|
||||
<div class="subtitle" id="subtitle">Waiting for note content...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="empty" class="empty" style="display: none;">No content available.</div>
|
||||
<pre id="content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const title = document.getElementById("title");
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const contentEl = document.getElementById("content");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
let hasData = false;
|
||||
|
||||
function stripFrontmatter(text) {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text) {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) return line.replace("# ", "").trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput) {
|
||||
if (!toolOutput) return "";
|
||||
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) return textBlock.text;
|
||||
}
|
||||
|
||||
if (toolOutput.structuredContent) {
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
const identifier = renderData.toolInput && renderData.toolInput.identifier;
|
||||
const rawText = extractText(renderData.toolOutput);
|
||||
if (!rawText) {
|
||||
emptyEl.style.display = "block";
|
||||
contentEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = "none";
|
||||
const text = stripFrontmatter(rawText);
|
||||
const previewTitle = parseTitle(text) || identifier || "Note Preview";
|
||||
|
||||
title.textContent = previewTitle;
|
||||
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
|
||||
contentEl.textContent = text.slice(0, 1400).trim();
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory MCP UI Search</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f0f2;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a1a1d;
|
||||
--muted: #6b6770;
|
||||
--accent: #b91c1c;
|
||||
--accent-soft: #fee2e2;
|
||||
--border: #ead7dc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #fdf7f8 0%, #f7eef1 60%, #f2e7ea 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
#subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<h1>Search Results</h1>
|
||||
<div class="badge">mcp-ui style</div>
|
||||
</div>
|
||||
|
||||
<div id="subtitle">Waiting for results...</div>
|
||||
|
||||
<div class="panel">
|
||||
<div id="table"></div>
|
||||
<div id="empty" class="empty" style="display: none;">No results to show.</div>
|
||||
</div>
|
||||
|
||||
<div id="footer" class="footer"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const tableContainer = document.getElementById("table");
|
||||
const emptyState = document.getElementById("empty");
|
||||
const footer = document.getElementById("footer");
|
||||
let hasData = false;
|
||||
|
||||
function safeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructured(toolOutput) {
|
||||
if (!toolOutput) return null;
|
||||
const structured =
|
||||
toolOutput.structuredContent ||
|
||||
toolOutput.structured_content ||
|
||||
toolOutput.structured ||
|
||||
null;
|
||||
if (structured) return structured;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) {
|
||||
const parsed = safeJsonParse(textBlock.text);
|
||||
return parsed || { text: textBlock.text };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toolOutput === "string") {
|
||||
return safeJsonParse(toolOutput) || { text: toolOutput };
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
function getResults(structured) {
|
||||
if (!structured) return [];
|
||||
if (Array.isArray(structured.results)) return structured.results;
|
||||
if (structured.result && Array.isArray(structured.result.results)) {
|
||||
return structured.result.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderTable(results) {
|
||||
if (!results.length) {
|
||||
tableContainer.innerHTML = "";
|
||||
emptyState.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = "none";
|
||||
|
||||
const rows = results
|
||||
.map((result) => {
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class=\"title\">${result.title || "Untitled"}</div>
|
||||
<div class=\"path\">${result.permalink || result.file_path || ""}</div>
|
||||
</td>
|
||||
<td>${result.type || ""}</td>
|
||||
<td>${Number(result.score || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tableContainer.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
|
||||
const query = renderData.toolInput && renderData.toolInput.query;
|
||||
const structured = extractStructured(renderData.toolOutput);
|
||||
const results = getResults(structured);
|
||||
|
||||
subtitle.textContent = query ? `Query: ${query}` : "Search results";
|
||||
footer.textContent = `Results: ${results.length}`;
|
||||
|
||||
renderTable(results);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,280 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Basic Memory Search Results</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f7f4ef;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f1f1f;
|
||||
--muted: #5b5b5b;
|
||||
--accent: #0f766e;
|
||||
--accent-2: #0a4f4b;
|
||||
--border: #e3dbd2;
|
||||
--row: #fbf9f5;
|
||||
--row-hover: #f2ece3;
|
||||
--tag: #f3efe7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #faf6ef 0%, #f2ece2 45%, #efe7da 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
#subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: var(--row);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: var(--row-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--tag);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Search Results</h1>
|
||||
<div id="subtitle">Waiting for results...</div>
|
||||
</header>
|
||||
|
||||
<div class="panel">
|
||||
<div id="table"></div>
|
||||
<div id="empty" class="empty" style="display: none;">No results to show.</div>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footer"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const subtitle = document.getElementById("subtitle");
|
||||
const tableContainer = document.getElementById("table");
|
||||
const emptyState = document.getElementById("empty");
|
||||
const footer = document.getElementById("footer");
|
||||
let hasData = false;
|
||||
|
||||
function safeJsonParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructured(toolOutput) {
|
||||
if (!toolOutput) return null;
|
||||
|
||||
const structured =
|
||||
toolOutput.structuredContent ||
|
||||
toolOutput.structured_content ||
|
||||
toolOutput.structured ||
|
||||
null;
|
||||
if (structured) return structured;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block && block.type === "text" && typeof block.text === "string"
|
||||
);
|
||||
if (textBlock) {
|
||||
const parsed = safeJsonParse(textBlock.text);
|
||||
return parsed || { text: textBlock.text };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toolOutput === "string") {
|
||||
return safeJsonParse(toolOutput) || { text: toolOutput };
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
function getResults(structured) {
|
||||
if (!structured) return [];
|
||||
if (Array.isArray(structured.results)) return structured.results;
|
||||
if (structured.result && Array.isArray(structured.result.results)) {
|
||||
return structured.result.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderTable(results) {
|
||||
if (!results.length) {
|
||||
tableContainer.innerHTML = "";
|
||||
emptyState.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = "none";
|
||||
|
||||
const rows = results
|
||||
.map((result) => {
|
||||
const tags =
|
||||
(result.metadata && result.metadata.tags) ||
|
||||
result.tags ||
|
||||
result.metadata?.tag ||
|
||||
[];
|
||||
const tagsMarkup = Array.isArray(tags)
|
||||
? tags.map((tag) => `<span class=\"tag\">${tag}</span>`).join("")
|
||||
: "";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class=\"title\">${result.title || "Untitled"}</div>
|
||||
<div class=\"meta\">${result.permalink || result.file_path || ""}</div>
|
||||
</td>
|
||||
<td>${result.type || ""}</td>
|
||||
<td>${Number(result.score || 0).toFixed(2)}</td>
|
||||
<td>
|
||||
<div class=\"tags\">${tagsMarkup}</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tableContainer.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Score</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateFromRenderData(renderData) {
|
||||
if (!renderData) return;
|
||||
|
||||
const query = renderData.toolInput && renderData.toolInput.query;
|
||||
const structured = extractStructured(renderData.toolOutput);
|
||||
const results = getResults(structured);
|
||||
|
||||
subtitle.textContent = query ? `Query: ${query}` : "Search results";
|
||||
footer.textContent = `Results: ${results.length}`;
|
||||
|
||||
renderTable(results);
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
const message = event.data || {};
|
||||
if (message.type === "ui-lifecycle-iframe-render-data") {
|
||||
hasData = true;
|
||||
updateFromRenderData(message.payload && message.payload.renderData);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Helpers for embedded MCP-UI resources (Python SDK)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
from basic_memory.mcp.ui import load_html
|
||||
|
||||
try: # Optional dependency for MCP-UI embedded resources
|
||||
mcp_ui_server = importlib.import_module("mcp_ui_server")
|
||||
UIMetadataKey = mcp_ui_server.UIMetadataKey
|
||||
create_ui_resource = mcp_ui_server.create_ui_resource
|
||||
except ImportError: # pragma: no cover - handled by callers
|
||||
UIMetadataKey = None
|
||||
create_ui_resource = None
|
||||
|
||||
|
||||
class MissingMCPUIServerError(RuntimeError):
|
||||
"""Raised when the MCP-UI server SDK is not available."""
|
||||
|
||||
|
||||
def _ensure_sdk() -> tuple[Any, Any]:
|
||||
if create_ui_resource is None or UIMetadataKey is None:
|
||||
raise MissingMCPUIServerError(
|
||||
"mcp-ui-server is not installed. "
|
||||
"Install it with `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server` "
|
||||
"or `pip install mcp-ui-server`."
|
||||
)
|
||||
return create_ui_resource, UIMetadataKey
|
||||
|
||||
|
||||
def build_embedded_ui_resource(
|
||||
*,
|
||||
uri: str,
|
||||
html_filename: str,
|
||||
render_data: dict[str, Any],
|
||||
preferred_frame_size: list[str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Create an embedded UI resource using the MCP-UI Python SDK."""
|
||||
create_resource, metadata_keys = _ensure_sdk()
|
||||
html = load_html(html_filename)
|
||||
|
||||
return create_resource(
|
||||
{
|
||||
"uri": uri,
|
||||
"content": {"type": "rawHtml", "htmlString": html},
|
||||
"encoding": "text",
|
||||
"uiMetadata": {
|
||||
metadata_keys.PREFERRED_FRAME_SIZE: preferred_frame_size,
|
||||
metadata_keys.INITIAL_RENDER_DATA: render_data,
|
||||
},
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for serving MCP UI HTML resources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_VARIANT = "vanilla"
|
||||
SUPPORTED_VARIANTS = {"vanilla", "tool-ui", "mcp-ui"}
|
||||
|
||||
|
||||
def get_ui_variant() -> str:
|
||||
"""Return the active UI variant from environment settings."""
|
||||
value = os.getenv("BASIC_MEMORY_MCP_UI_VARIANT", DEFAULT_VARIANT).strip().lower()
|
||||
return value if value in SUPPORTED_VARIANTS else DEFAULT_VARIANT
|
||||
|
||||
|
||||
def load_html(filename: str) -> str:
|
||||
"""Load a UI HTML template from disk."""
|
||||
path = Path(__file__).parent / "html" / filename
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def load_variant_html(base_name: str) -> str:
|
||||
"""Load a UI template for the current variant."""
|
||||
variant = get_ui_variant()
|
||||
return load_html(f"{base_name}-{variant}.html")
|
||||
@@ -338,6 +338,9 @@ def mcp_server(config_manager, search_service):
|
||||
# Import mcp tools to register them
|
||||
import basic_memory.mcp.tools # noqa: F401
|
||||
|
||||
# Import resources to register them
|
||||
import basic_memory.mcp.resources # noqa: F401
|
||||
|
||||
# Import prompts to register them
|
||||
import basic_memory.mcp.prompts # noqa: F401
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Integration tests for ASCII/ANSI output formats in MCP tools.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_ascii_output(mcp_server, app, test_project):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "ASCII Note",
|
||||
"directory": "notes",
|
||||
"content": "# ASCII Note\n\nThis is a note for ASCII output.",
|
||||
"tags": "ascii,output",
|
||||
},
|
||||
)
|
||||
|
||||
search_result = await client.call_tool(
|
||||
"search_notes",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "ASCII",
|
||||
"output_format": "ascii",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(search_result.content) == 1
|
||||
assert search_result.content[0].type == "text"
|
||||
text = search_result.content[0].text
|
||||
assert "Search results" in text
|
||||
assert "ASCII Note" in text
|
||||
assert "+" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_note_ascii_and_ansi_output(mcp_server, app, test_project):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Color Note",
|
||||
"directory": "notes",
|
||||
"content": "# Color Note\n\nThis note is for ANSI output.",
|
||||
"tags": "ansi,output",
|
||||
},
|
||||
)
|
||||
|
||||
ascii_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "Color Note",
|
||||
"output_format": "ascii",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(ascii_result.content) == 1
|
||||
ascii_text = ascii_result.content[0].text
|
||||
assert "Note preview" in ascii_text
|
||||
assert "# Color Note" in ascii_text
|
||||
|
||||
ansi_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "Color Note",
|
||||
"output_format": "ansi",
|
||||
},
|
||||
)
|
||||
|
||||
ansi_text = ansi_result.content[0].text
|
||||
assert "\x1b[" in ansi_text
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Integration tests for MCP-UI Python SDK embedded resources.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
pytest.importorskip("mcp_ui_server")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_notes_ui_embedded_resource(mcp_server, app, test_project):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "SDK Note",
|
||||
"directory": "notes",
|
||||
"content": "# SDK Note\n\nThis is for embedded UI.",
|
||||
"tags": "sdk,ui",
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.call_tool(
|
||||
"search_notes_ui",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "SDK",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result.content) == 1
|
||||
block = result.content[0]
|
||||
assert block.type == "resource"
|
||||
assert block.resource.mimeType == "text/html"
|
||||
assert "<!doctype html>" in block.resource.text.lower()
|
||||
assert block.resource.meta is not None
|
||||
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_note_ui_embedded_resource(mcp_server, app, test_project):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "SDK Note Preview",
|
||||
"directory": "notes",
|
||||
"content": "# SDK Note Preview\n\nPreview for embedded UI.",
|
||||
"tags": "sdk,ui",
|
||||
},
|
||||
)
|
||||
|
||||
result = await client.call_tool(
|
||||
"read_note_ui",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "SDK Note Preview",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(result.content) == 1
|
||||
block = result.content[0]
|
||||
assert block.type == "resource"
|
||||
assert block.resource.mimeType == "text/html"
|
||||
assert "<!doctype html>" in block.resource.text.lower()
|
||||
assert block.resource.meta is not None
|
||||
assert "mcpui.dev/ui-initial-render-data" in block.resource.meta
|
||||
@@ -28,7 +28,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"list_memory_projects": [],
|
||||
"move_note": ["identifier", "destination_path", "is_directory", "project"],
|
||||
"read_content": ["path", "project"],
|
||||
"read_note": ["identifier", "project", "page", "page_size"],
|
||||
"read_note": ["identifier", "project", "page", "page_size", "output_format"],
|
||||
"release_notes": [],
|
||||
"recent_activity": ["type", "depth", "timeframe", "project"],
|
||||
"search": ["query"],
|
||||
@@ -39,6 +39,7 @@ EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = {
|
||||
"page",
|
||||
"page_size",
|
||||
"search_type",
|
||||
"output_format",
|
||||
"types",
|
||||
"entity_types",
|
||||
"after_date",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = __dirname;
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const htmlDir = path.resolve(rootDir, "..", "..", "src", "basic_memory", "mcp", "ui", "html");
|
||||
|
||||
const tailwindBin = path.join(rootDir, "node_modules", ".bin", "tailwindcss");
|
||||
const cssInput = path.join(rootDir, "src", "styles.css");
|
||||
const cssOutput = path.join(distDir, "styles.css");
|
||||
|
||||
function buildHtml(title, css, js) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>${js}</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async function buildCss() {
|
||||
execFileSync(
|
||||
tailwindBin,
|
||||
["-i", cssInput, "-o", cssOutput, "--minify"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
return readFile(cssOutput, "utf8");
|
||||
}
|
||||
|
||||
async function buildJs(entry, outfile) {
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
outfile,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: "browser",
|
||||
target: ["es2020"],
|
||||
jsx: "automatic",
|
||||
define: {
|
||||
"process.env.NODE_ENV": "\"production\"",
|
||||
},
|
||||
});
|
||||
return readFile(outfile, "utf8");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
const css = await buildCss();
|
||||
|
||||
const searchJsPath = path.join(distDir, "search-results.js");
|
||||
const noteJsPath = path.join(distDir, "note-preview.js");
|
||||
|
||||
const [searchJs, noteJs] = await Promise.all([
|
||||
buildJs(path.join(rootDir, "src", "search-results.tsx"), searchJsPath),
|
||||
buildJs(path.join(rootDir, "src", "note-preview.tsx"), noteJsPath),
|
||||
]);
|
||||
|
||||
await mkdir(htmlDir, { recursive: true });
|
||||
|
||||
const searchHtml = buildHtml("Basic Memory Tool UI Search", css, searchJs);
|
||||
const noteHtml = buildHtml("Basic Memory Tool UI Note Preview", css, noteJs);
|
||||
|
||||
await Promise.all([
|
||||
writeFile(path.join(htmlDir, "search-results-tool-ui.html"), searchHtml, "utf8"),
|
||||
writeFile(path.join(htmlDir, "note-preview-tool-ui.html"), noteHtml, "utf8"),
|
||||
]);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Generated
+2453
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "basic-memory-tool-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "^0.25.10",
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
|
||||
* Button → shadcn/ui Button
|
||||
* DropdownMenu → shadcn/ui DropdownMenu
|
||||
* Accordion → shadcn/ui Accordion
|
||||
* Tooltip → shadcn/ui Tooltip
|
||||
* Badge → shadcn/ui Badge
|
||||
* Table → shadcn/ui Table
|
||||
*/
|
||||
|
||||
export { cn } from "../../../lib/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../ui/dropdown-menu";
|
||||
export {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../../ui/accordion";
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../../ui/tooltip";
|
||||
export { Badge } from "../../ui/badge";
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "../../ui/table";
|
||||
@@ -0,0 +1,869 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "./_adapter";
|
||||
import { sortData, getRowIdentifier } from "./utilities";
|
||||
import { renderFormattedValue } from "./formatters";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableContextValue,
|
||||
RowData,
|
||||
DataTableRowData,
|
||||
ColumnKey,
|
||||
Column,
|
||||
} from "./types";
|
||||
import { ActionButtons, normalizeActionsConfig } from "../shared";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
import { DataTableErrorBoundary } from "./error-boundary";
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US" as const;
|
||||
|
||||
function isNumericFormat(format?: FormatConfig): boolean {
|
||||
const kind = format?.kind;
|
||||
return (
|
||||
kind === "number" ||
|
||||
kind === "currency" ||
|
||||
kind === "percent" ||
|
||||
kind === "delta"
|
||||
);
|
||||
}
|
||||
|
||||
function getAlignmentClass(
|
||||
align?: "left" | "right" | "center",
|
||||
): string | undefined {
|
||||
if (align === "right") return "text-right";
|
||||
if (align === "center") return "text-center";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const DataTableContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataTableContextValue<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useDataTable<T extends object = RowData>() {
|
||||
const context = React.useContext(DataTableContext) as
|
||||
| DataTableContextValue<T>
|
||||
| undefined;
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within a DataTable");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object = RowData>({
|
||||
columns,
|
||||
data: rawData,
|
||||
rowIdKey,
|
||||
layout = "auto",
|
||||
defaultSort,
|
||||
sort: controlledSort,
|
||||
emptyMessage = "No data available",
|
||||
isLoading = false,
|
||||
maxHeight,
|
||||
id,
|
||||
onSortChange,
|
||||
className,
|
||||
locale,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
onBeforeResponseAction,
|
||||
}: DataTableProps<T>) {
|
||||
// Default locale avoids SSR/client formatting mismatches.
|
||||
const resolvedLocale = locale ?? DEFAULT_LOCALE;
|
||||
|
||||
const [internalSortBy, setInternalSortBy] = React.useState<
|
||||
ColumnKey<T> | undefined
|
||||
>(defaultSort?.by);
|
||||
const [internalSortDirection, setInternalSortDirection] = React.useState<
|
||||
"asc" | "desc" | undefined
|
||||
>(defaultSort?.direction);
|
||||
|
||||
const sortBy = controlledSort?.by ?? internalSortBy;
|
||||
const sortDirection = controlledSort?.direction ?? internalSortDirection;
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
if (!sortBy || !sortDirection) return rawData;
|
||||
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
|
||||
}, [rawData, sortBy, sortDirection, resolvedLocale]);
|
||||
|
||||
const handleSort = React.useCallback(
|
||||
(key: ColumnKey<T>) => {
|
||||
let newDirection: "asc" | "desc" | undefined;
|
||||
|
||||
if (sortBy === key) {
|
||||
if (sortDirection === "asc") {
|
||||
newDirection = "desc";
|
||||
} else if (sortDirection === "desc") {
|
||||
newDirection = undefined;
|
||||
} else {
|
||||
newDirection = "asc";
|
||||
}
|
||||
} else {
|
||||
newDirection = "asc";
|
||||
}
|
||||
|
||||
const next = {
|
||||
by: newDirection ? key : undefined,
|
||||
direction: newDirection,
|
||||
} as const;
|
||||
|
||||
if (controlledSort) {
|
||||
onSortChange?.(next);
|
||||
} else {
|
||||
setInternalSortBy(next.by);
|
||||
setInternalSortDirection(next.direction);
|
||||
}
|
||||
},
|
||||
[sortBy, sortDirection, controlledSort, onSortChange],
|
||||
);
|
||||
|
||||
const contextValue: DataTableContextValue<T> = {
|
||||
columns,
|
||||
data,
|
||||
rowIdKey,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
toggleSort: handleSort,
|
||||
id,
|
||||
isLoading,
|
||||
locale: resolvedLocale,
|
||||
};
|
||||
|
||||
const sortAnnouncement = React.useMemo(() => {
|
||||
const col = columns.find((c) => c.key === sortBy);
|
||||
const label = col?.label ?? sortBy;
|
||||
return sortBy && sortDirection
|
||||
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
|
||||
: "";
|
||||
}, [columns, sortBy, sortDirection]);
|
||||
|
||||
const normalizedFooterActions = React.useMemo(
|
||||
() => normalizeActionsConfig(responseActions),
|
||||
[responseActions],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("@container w-full min-w-80", className)}
|
||||
data-tool-ui-id={id}
|
||||
data-slot="data-table"
|
||||
data-layout={layout}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
layout === "table"
|
||||
? "block"
|
||||
: layout === "cards"
|
||||
? "hidden"
|
||||
: "hidden @md:block",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
|
||||
"touch-pan-x",
|
||||
maxHeight && "max-h-[--max-height]",
|
||||
)}
|
||||
style={
|
||||
maxHeight
|
||||
? ({ "--max-height": maxHeight } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DataTableErrorBoundary>
|
||||
<Table aria-busy={isLoading || undefined}>
|
||||
{columns.length > 0 && (
|
||||
<colgroup>
|
||||
{columns.map((col) => (
|
||||
<col
|
||||
key={String(col.key)}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton />
|
||||
) : data.length === 0 ? (
|
||||
<DataTableEmpty message={emptyMessage} />
|
||||
) : (
|
||||
<DataTableContent />
|
||||
)}
|
||||
</Table>
|
||||
</DataTableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
layout === "cards"
|
||||
? ""
|
||||
: layout === "table"
|
||||
? "hidden"
|
||||
: "@md:hidden",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Data table (mobile card view)"
|
||||
aria-describedby="mobile-table-description"
|
||||
>
|
||||
<div id="mobile-table-description" className="sr-only">
|
||||
Table data shown as expandable cards. Each card represents one row.
|
||||
{columns.length > 0 &&
|
||||
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
|
||||
</div>
|
||||
|
||||
<DataTableErrorBoundary>
|
||||
{isLoading ? (
|
||||
<DataTableSkeletonCards />
|
||||
) : data.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
|
||||
{data.map((row, i) => {
|
||||
const keyVal = rowIdKey ? row[rowIdKey] : undefined;
|
||||
const rowKey = keyVal != null ? String(keyVal) : String(i);
|
||||
return (
|
||||
<DataTableAccordionCard
|
||||
key={rowKey}
|
||||
row={row as unknown as DataTableRowData}
|
||||
index={i}
|
||||
isFirst={i === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DataTableErrorBoundary>
|
||||
</div>
|
||||
|
||||
{sortAnnouncement && (
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{sortAnnouncement}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{normalizedFooterActions ? (
|
||||
<div className="@container/actions mt-4">
|
||||
<ActionButtons
|
||||
actions={normalizedFooterActions.items}
|
||||
align={normalizedFooterActions.align}
|
||||
confirmTimeout={normalizedFooterActions.confirmTimeout}
|
||||
onAction={(id) => onResponseAction?.(id)}
|
||||
onBeforeAction={onBeforeResponseAction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableContent() {
|
||||
return (
|
||||
<>
|
||||
<DataTableHeader />
|
||||
<DataTableBody />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableEmpty({ message }: { message: string }) {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
<TableRow className="bg-card h-24 text-center">
|
||||
<TableCell colSpan={columns.length} role="status" aria-live="polite">
|
||||
{message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableSkeleton() {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableHeader />
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<div className="bg-muted/50 h-4 rounded motion-safe:animate-pulse" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableSkeletonCards() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-lg border p-4">
|
||||
<div className="bg-muted/50 h-5 w-1/2 rounded motion-safe:animate-pulse" />
|
||||
<div className="bg-muted/50 h-4 w-3/4 rounded motion-safe:animate-pulse" />
|
||||
<div className="bg-muted/50 h-4 w-2/3 rounded motion-safe:animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ state }: { state?: "asc" | "desc" }) {
|
||||
let char = "⇅";
|
||||
let className = "opacity-20";
|
||||
|
||||
if (state === "asc") {
|
||||
char = "↑";
|
||||
className = "";
|
||||
}
|
||||
|
||||
if (state === "desc") {
|
||||
char = "↓";
|
||||
className = "";
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
|
||||
{char}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableHeader() {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{columns.map((column, columnIndex) => (
|
||||
<DataTableHead
|
||||
key={column.key}
|
||||
column={column}
|
||||
columnIndex={columnIndex}
|
||||
totalColumns={columns.length}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableHeadProps {
|
||||
column: Column;
|
||||
columnIndex?: number;
|
||||
totalColumns?: number;
|
||||
}
|
||||
|
||||
function DataTableHead({
|
||||
column,
|
||||
columnIndex = 0,
|
||||
totalColumns = 1,
|
||||
}: DataTableHeadProps) {
|
||||
const { sortBy, sortDirection, toggleSort, isLoading } = useDataTable();
|
||||
const isFirstColumn = columnIndex === 0;
|
||||
const isLastColumn = columnIndex === totalColumns - 1;
|
||||
|
||||
const isSortable = column.sortable !== false;
|
||||
|
||||
const isSorted = sortBy === column.key;
|
||||
const direction = isSorted ? sortDirection : undefined;
|
||||
const isDisabled = isLoading || !isSortable;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDisabled && toggleSort) {
|
||||
toggleSort(column.key);
|
||||
}
|
||||
};
|
||||
|
||||
const displayText = column.abbr || column.label;
|
||||
const shouldShowTooltip = column.abbr || displayText.length > 15;
|
||||
const isNumericKind = isNumericFormat(column.format);
|
||||
const align =
|
||||
column.align ??
|
||||
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
|
||||
const alignClass = getAlignmentClass(align);
|
||||
const buttonAlignClass = cn(
|
||||
"min-w-0 gap-1 font-normal",
|
||||
align === "right" && "text-right",
|
||||
align === "center" && "text-center",
|
||||
align === "left" && "text-left",
|
||||
);
|
||||
const labelAlignClass =
|
||||
align === "right"
|
||||
? "text-right"
|
||||
: align === "center"
|
||||
? "text-center"
|
||||
: "text-left";
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
scope="col"
|
||||
className={cn(
|
||||
alignClass,
|
||||
isFirstColumn && "pl-1",
|
||||
isLastColumn && "pr-1",
|
||||
)}
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
aria-sort={
|
||||
isSorted
|
||||
? direction === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
buttonAlignClass,
|
||||
"w-fit min-w-10",
|
||||
isFirstColumn && "pl-4",
|
||||
isLastColumn && "pr-4",
|
||||
)}
|
||||
aria-label={
|
||||
`Sort by ${column.label}` +
|
||||
(isSorted && direction
|
||||
? ` (${direction === "asc" ? "ascending" : "descending"})`
|
||||
: "")
|
||||
}
|
||||
aria-disabled={isDisabled || undefined}
|
||||
>
|
||||
{shouldShowTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate", labelAlignClass)}>
|
||||
{column.abbr ? (
|
||||
<abbr
|
||||
title={column.label}
|
||||
className={cn(
|
||||
"cursor-help border-b border-dotted border-current no-underline",
|
||||
labelAlignClass,
|
||||
)}
|
||||
>
|
||||
{column.abbr}
|
||||
</abbr>
|
||||
) : (
|
||||
<span className={labelAlignClass}>{column.label}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{column.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={cn("truncate", labelAlignClass)}>
|
||||
{column.label}
|
||||
</span>
|
||||
)}
|
||||
{isSortable && <SortIcon state={direction} />}
|
||||
</Button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableBody() {
|
||||
const { data, rowIdKey } = useDataTable<DataTableRowData>();
|
||||
const hasWarnedRowKeyRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasWarnedRowKeyRef.current) return;
|
||||
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
|
||||
hasWarnedRowKeyRef.current = true;
|
||||
console.warn(
|
||||
"[DataTable] Missing `rowIdKey` prop. Using array index as React key can cause reconciliation issues when data reorders (focus traps, animation glitches, incorrect state preservation). " +
|
||||
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
|
||||
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
|
||||
);
|
||||
}
|
||||
}, [rowIdKey, data.length]);
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{data.map((row, index) => {
|
||||
const keyVal = rowIdKey ? row[rowIdKey] : undefined;
|
||||
const rowKey = keyVal != null ? String(keyVal) : String(index);
|
||||
return <DataTableRow key={rowKey} row={row} />;
|
||||
})}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableRowProps {
|
||||
row: DataTableRowData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DataTableRow({ row, className }: DataTableRowProps) {
|
||||
const { columns } = useDataTable();
|
||||
|
||||
return (
|
||||
<TableRow className={className}>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<DataTableCell
|
||||
key={column.key}
|
||||
value={row[column.key]}
|
||||
column={column}
|
||||
row={row}
|
||||
columnIndex={columnIndex}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableCellProps {
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| (string | number | boolean | null)[];
|
||||
column: Column;
|
||||
row: DataTableRowData;
|
||||
className?: string;
|
||||
columnIndex?: number;
|
||||
}
|
||||
|
||||
function DataTableCell({
|
||||
value,
|
||||
column,
|
||||
row,
|
||||
className,
|
||||
columnIndex = 0,
|
||||
}: DataTableCellProps) {
|
||||
const { locale } = useDataTable();
|
||||
const isNumericKind = isNumericFormat(column.format);
|
||||
const isNumericValue = typeof value === "number";
|
||||
const displayValue = renderFormattedValue({ value, column, row, locale });
|
||||
const align =
|
||||
column.align ??
|
||||
(columnIndex === 0
|
||||
? "left"
|
||||
: isNumericKind || isNumericValue
|
||||
? "right"
|
||||
: "left");
|
||||
const alignClass = getAlignmentClass(align);
|
||||
|
||||
return (
|
||||
<TableCell className={cn("px-5 py-3", alignClass, className)}>
|
||||
{displayValue}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
function categorizeColumns(columns: Column[]) {
|
||||
const primary: Column[] = [];
|
||||
const secondary: Column[] = [];
|
||||
|
||||
let visibleColumnCount = 0;
|
||||
columns.forEach((col) => {
|
||||
if (col.hideOnMobile) return;
|
||||
|
||||
if (col.priority === "primary") {
|
||||
primary.push(col);
|
||||
} else if (col.priority === "secondary") {
|
||||
secondary.push(col);
|
||||
} else if (col.priority === "tertiary") {
|
||||
return;
|
||||
} else {
|
||||
if (visibleColumnCount < 2) {
|
||||
primary.push(col);
|
||||
} else {
|
||||
secondary.push(col);
|
||||
}
|
||||
visibleColumnCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
interface DataTableAccordionCardProps {
|
||||
row: DataTableRowData;
|
||||
index: number;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
function DataTableAccordionCard({
|
||||
row,
|
||||
index,
|
||||
isFirst = false,
|
||||
}: DataTableAccordionCardProps) {
|
||||
const { columns, locale, rowIdKey } = useDataTable();
|
||||
|
||||
const { primary, secondary } = React.useMemo(
|
||||
() => categorizeColumns(columns),
|
||||
[columns],
|
||||
);
|
||||
|
||||
if (secondary.length === 0) {
|
||||
return (
|
||||
<SimpleCard row={row} columns={primary} index={index} isFirst={isFirst} />
|
||||
);
|
||||
}
|
||||
|
||||
const primaryColumn = primary[0];
|
||||
const remainingPrimaryColumns = primary.slice(1);
|
||||
|
||||
const stableRowId =
|
||||
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
|
||||
`${index}-${primaryColumn?.key ?? "row"}`;
|
||||
|
||||
const headingId = `row-${stableRowId}-heading`;
|
||||
const detailsId = `row-${stableRowId}-details`;
|
||||
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
|
||||
(col) => `row-${stableRowId}-${String(col.key)}`,
|
||||
);
|
||||
|
||||
const primaryValue = primaryColumn
|
||||
? String(row[primaryColumn.key] ?? "")
|
||||
: "";
|
||||
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
|
||||
const accordionItemId = `row-${stableRowId}`;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className={cn(!isFirst && "border-t")}
|
||||
role="listitem"
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
<AccordionItem value={accordionItemId} className="group border-0">
|
||||
<AccordionTrigger
|
||||
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
|
||||
aria-controls={detailsId}
|
||||
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{primaryColumn && (
|
||||
<div
|
||||
id={headingId}
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
className="truncate"
|
||||
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[primaryColumn.key],
|
||||
column: primaryColumn,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingPrimaryColumns.length > 0 && (
|
||||
<div
|
||||
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
|
||||
role="group"
|
||||
aria-label="Summary information"
|
||||
>
|
||||
{remainingPrimaryColumns.map((col, idx) => (
|
||||
<span
|
||||
key={col.key}
|
||||
id={remainingPrimaryDataIds[idx]}
|
||||
className="flex min-w-0 gap-1 font-normal"
|
||||
role="cell"
|
||||
aria-label={`${col.label}: ${row[col.key]}`}
|
||||
>
|
||||
<span className="sr-only">{col.label}:</span>
|
||||
<span aria-hidden="true">{col.label}:</span>
|
||||
<span className="truncate">
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent
|
||||
className={"flex flex-col gap-4 px-4 pb-4"}
|
||||
id={detailsId}
|
||||
role="region"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
{secondary.length > 0 && (
|
||||
<dl
|
||||
className={cn(
|
||||
"flex flex-col gap-2 pt-4",
|
||||
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
|
||||
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
|
||||
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
|
||||
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
|
||||
"duration-150",
|
||||
)}
|
||||
role="list"
|
||||
aria-label="Additional data"
|
||||
>
|
||||
{secondary.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-start justify-between gap-4"
|
||||
role="listitem"
|
||||
>
|
||||
<dt
|
||||
className="text-muted-foreground shrink-0"
|
||||
id={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{col.label}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-foreground min-w-0 text-pretty wrap-break-word",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
role="cell"
|
||||
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple card with no accordion, for when there are only primary columns
|
||||
*/
|
||||
function SimpleCard({
|
||||
row,
|
||||
columns,
|
||||
index,
|
||||
isFirst = false,
|
||||
}: {
|
||||
row: DataTableRowData;
|
||||
columns: Column[];
|
||||
index: number;
|
||||
isFirst?: boolean;
|
||||
}) {
|
||||
const { locale, rowIdKey } = useDataTable();
|
||||
const primaryColumn = columns[0];
|
||||
const otherColumns = columns.slice(1);
|
||||
|
||||
const stableRowId =
|
||||
getRowIdentifier(row, rowIdKey ? String(rowIdKey) : undefined) ||
|
||||
`${index}-${primaryColumn?.key ?? "row"}`;
|
||||
|
||||
const primaryValue = primaryColumn
|
||||
? String(row[primaryColumn.key] ?? "")
|
||||
: "";
|
||||
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
|
||||
role="listitem"
|
||||
aria-label={rowLabel}
|
||||
>
|
||||
{primaryColumn && (
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={3}
|
||||
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[primaryColumn.key],
|
||||
column: primaryColumn,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherColumns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-start justify-between gap-4"
|
||||
role="group"
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
id={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{col.label}:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 wrap-break-word",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
role="cell"
|
||||
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
|
||||
>
|
||||
{renderFormattedValue({
|
||||
value: row[col.key],
|
||||
column: col,
|
||||
row,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ToolUIErrorBoundary,
|
||||
type ToolUIErrorBoundaryProps,
|
||||
} from "../shared";
|
||||
|
||||
export function DataTableErrorBoundary(
|
||||
props: Omit<ToolUIErrorBoundaryProps, "componentName">,
|
||||
) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ToolUIErrorBoundary componentName="DataTable" {...rest}>
|
||||
{children}
|
||||
</ToolUIErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
|
||||
|
||||
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
|
||||
|
||||
export type FormatConfig =
|
||||
| { kind: "text" }
|
||||
| {
|
||||
kind: "number";
|
||||
decimals?: number;
|
||||
unit?: string;
|
||||
compact?: boolean;
|
||||
showSign?: boolean;
|
||||
}
|
||||
| { kind: "currency"; currency: string; decimals?: number }
|
||||
| {
|
||||
kind: "percent";
|
||||
decimals?: number;
|
||||
showSign?: boolean;
|
||||
basis?: "fraction" | "unit";
|
||||
}
|
||||
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
|
||||
| {
|
||||
kind: "delta";
|
||||
decimals?: number;
|
||||
upIsPositive?: boolean;
|
||||
showSign?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "status";
|
||||
statusMap: Record<string, { tone: Tone; label?: string }>;
|
||||
}
|
||||
| { kind: "boolean"; labels?: { true: string; false: string } }
|
||||
| { kind: "link"; hrefKey?: string; external?: boolean }
|
||||
| { kind: "badge"; colorMap?: Record<string, Tone> }
|
||||
| { kind: "array"; maxVisible?: number };
|
||||
|
||||
interface DeltaValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "delta" }>;
|
||||
}
|
||||
|
||||
export function DeltaValue({ value, options }: DeltaValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const upIsPositive = options?.upIsPositive ?? true;
|
||||
const showSign = options?.showSign ?? true;
|
||||
|
||||
const isPositive = value > 0;
|
||||
const isNegative = value < 0;
|
||||
const isNeutral = value === 0;
|
||||
|
||||
const isGood = upIsPositive ? isPositive : isNegative;
|
||||
const isBad = upIsPositive ? isNegative : isPositive;
|
||||
|
||||
const colorClass = isGood
|
||||
? "text-green-700 dark:text-green-500"
|
||||
: isBad
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const formatted = value.toFixed(decimals);
|
||||
const display = showSign && !isNegative ? `+${formatted}` : formatted;
|
||||
|
||||
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
|
||||
|
||||
return (
|
||||
<span className={cn("tabular-nums", colorClass)}>
|
||||
{display}
|
||||
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "status" }>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ value, options }: StatusBadgeProps) {
|
||||
const config = options?.statusMap?.[value] ?? {
|
||||
tone: "neutral" as Tone,
|
||||
label: value,
|
||||
};
|
||||
const label = config.label ?? value;
|
||||
|
||||
const variant =
|
||||
config.tone === "danger"
|
||||
? "destructive"
|
||||
: config.tone === "neutral"
|
||||
? "outline"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"border",
|
||||
config.tone === "warning" &&
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
|
||||
config.tone === "success" &&
|
||||
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
|
||||
config.tone === "info" &&
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
|
||||
config.tone === "danger" &&
|
||||
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface CurrencyValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "currency" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
|
||||
const currency = options?.currency ?? "USD";
|
||||
const decimals = options?.decimals ?? 2;
|
||||
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
|
||||
return <span className="tabular-nums">{formatted}</span>;
|
||||
}
|
||||
|
||||
interface PercentValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "percent" }>;
|
||||
}
|
||||
|
||||
export function PercentValue({ value, options }: PercentValueProps) {
|
||||
const decimals = options?.decimals ?? 2;
|
||||
const showSign = options?.showSign ?? false;
|
||||
const basis = options?.basis ?? "fraction";
|
||||
|
||||
const numeric = basis === "fraction" ? value * 100 : value;
|
||||
const absoluteFormatted = Math.abs(numeric).toFixed(decimals);
|
||||
const signed =
|
||||
numeric > 0 && showSign
|
||||
? `+${absoluteFormatted}`
|
||||
: numeric < 0
|
||||
? `-${absoluteFormatted}`
|
||||
: absoluteFormatted;
|
||||
|
||||
return <span className="tabular-nums">{signed}%</span>;
|
||||
}
|
||||
|
||||
interface DateValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "date" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function DateValue({ value, options, locale }: DateValueProps) {
|
||||
const dateFormat = options?.dateFormat ?? "short";
|
||||
const date = new Date(value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return <span>Invalid date</span>;
|
||||
}
|
||||
|
||||
let formatted: string;
|
||||
|
||||
if (dateFormat === "relative") {
|
||||
formatted = getRelativeTime(date, locale);
|
||||
} else if (dateFormat === "long") {
|
||||
formatted = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
} else {
|
||||
formatted = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const title = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
|
||||
return (
|
||||
<span className="tabular-nums" title={title}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getRelativeTime(date: Date, locale?: string): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "just now";
|
||||
if (diffInSeconds < 3600) {
|
||||
const mins = Math.floor(diffInSeconds / 60);
|
||||
return `${mins} ${mins === 1 ? "minute" : "minutes"} ago`;
|
||||
}
|
||||
if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
||||
}
|
||||
if (diffInSeconds < 604800) {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface BooleanValueProps {
|
||||
value: boolean;
|
||||
options?: Extract<FormatConfig, { kind: "boolean" }>;
|
||||
}
|
||||
|
||||
export function BooleanValue({ value, options }: BooleanValueProps) {
|
||||
const labels = options?.labels ?? { true: "Yes", false: "No" };
|
||||
const label = value ? labels.true : labels.false;
|
||||
const variant = value ? "secondary" : "outline";
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
|
||||
interface LinkValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "link" }>;
|
||||
row?: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>;
|
||||
}
|
||||
|
||||
export function LinkValue({ value, options, row }: LinkValueProps) {
|
||||
const href =
|
||||
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
|
||||
const external = options?.external ?? false;
|
||||
|
||||
if (!href) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
|
||||
aria-label={external ? `${value} (opens in a new tab)` : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{value}
|
||||
{external && (
|
||||
<span className="ml-1 inline-block" aria-label="Opens in new tab">
|
||||
↗
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberValueProps {
|
||||
value: number;
|
||||
options?: Extract<FormatConfig, { kind: "number" }>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function NumberValue({ value, options, locale }: NumberValueProps) {
|
||||
const decimals = options?.decimals ?? 0;
|
||||
const unit = options?.unit ?? "";
|
||||
const compact = options?.compact ?? false;
|
||||
const showSign = options?.showSign ?? false;
|
||||
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
notation: compact ? "compact" : "standard",
|
||||
}).format(value);
|
||||
|
||||
const display = showSign && value > 0 ? `+${formatted}` : formatted;
|
||||
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{display}
|
||||
{unit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface BadgeValueProps {
|
||||
value: string;
|
||||
options?: Extract<FormatConfig, { kind: "badge" }>;
|
||||
}
|
||||
|
||||
export function BadgeValue({ value, options }: BadgeValueProps) {
|
||||
const tone = options?.colorMap?.[value] ?? "neutral";
|
||||
|
||||
const variant =
|
||||
tone === "danger"
|
||||
? "destructive"
|
||||
: tone === "neutral"
|
||||
? "outline"
|
||||
: "secondary";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"border",
|
||||
tone === "warning" &&
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
|
||||
tone === "success" &&
|
||||
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
|
||||
tone === "info" &&
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
|
||||
tone === "danger" &&
|
||||
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArrayValueProps {
|
||||
value: (string | number | boolean | null)[] | string;
|
||||
options?: Extract<FormatConfig, { kind: "array" }>;
|
||||
}
|
||||
|
||||
export function ArrayValue({ value, options }: ArrayValueProps) {
|
||||
const maxVisible = options?.maxVisible ?? 3;
|
||||
const items: (string | number | boolean | null)[] = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
const visible = items.slice(0, maxVisible);
|
||||
const remaining = items.length - maxVisible;
|
||||
|
||||
const hidden = items.slice(maxVisible);
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
{visible.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
|
||||
>
|
||||
{item === null ? "null" : String(item)}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">
|
||||
+{remaining} more
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hidden
|
||||
.map((item) => (item === null ? "null" : String(item)))
|
||||
.join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderFormattedValueParams {
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| (string | number | boolean | null)[];
|
||||
column: { format?: FormatConfig };
|
||||
row?: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function renderFormattedValue({
|
||||
value,
|
||||
column,
|
||||
row,
|
||||
locale,
|
||||
}: RenderFormattedValueParams): React.ReactNode {
|
||||
if (value == null || value === "") {
|
||||
return <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
const fmt = column.format;
|
||||
|
||||
switch (fmt?.kind) {
|
||||
case "delta":
|
||||
return <DeltaValue value={Number(value)} options={fmt} />;
|
||||
case "status":
|
||||
return <StatusBadge value={String(value)} options={fmt} />;
|
||||
case "currency":
|
||||
return (
|
||||
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
|
||||
);
|
||||
case "percent":
|
||||
return <PercentValue value={Number(value)} options={fmt} />;
|
||||
case "date":
|
||||
return <DateValue value={String(value)} options={fmt} locale={locale} />;
|
||||
case "boolean":
|
||||
return <BooleanValue value={Boolean(value)} options={fmt} />;
|
||||
case "link":
|
||||
return <LinkValue value={String(value)} options={fmt} row={row} />;
|
||||
case "number":
|
||||
return (
|
||||
<NumberValue value={Number(value)} options={fmt} locale={locale} />
|
||||
);
|
||||
case "badge":
|
||||
return <BadgeValue value={String(value)} options={fmt} />;
|
||||
case "array":
|
||||
return (
|
||||
<ArrayValue
|
||||
value={Array.isArray(value) ? value : String(value)}
|
||||
options={fmt}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export { DataTable, useDataTable } from "./data-table";
|
||||
export { DataTableErrorBoundary } from "./error-boundary";
|
||||
|
||||
export { renderFormattedValue } from "./formatters";
|
||||
export {
|
||||
NumberValue,
|
||||
CurrencyValue,
|
||||
PercentValue,
|
||||
DeltaValue,
|
||||
DateValue,
|
||||
BooleanValue,
|
||||
LinkValue,
|
||||
BadgeValue,
|
||||
StatusBadge,
|
||||
ArrayValue,
|
||||
} from "./formatters";
|
||||
export { parseSerializableDataTable } from "./schema";
|
||||
|
||||
export type {
|
||||
Column,
|
||||
DataTableProps,
|
||||
DataTableSerializableProps,
|
||||
DataTableClientProps,
|
||||
DataTableRowData,
|
||||
RowPrimitive,
|
||||
RowData,
|
||||
ColumnKey,
|
||||
} from "./types";
|
||||
export type { FormatConfig } from "./formatters";
|
||||
|
||||
export { sortData, parseNumericLike } from "./utilities";
|
||||
@@ -0,0 +1,234 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
parseWithSchema,
|
||||
} from "../shared";
|
||||
import type { Column, DataTableProps, RowData } from "./types";
|
||||
|
||||
const AlignEnum = z.enum(["left", "right", "center"]);
|
||||
const PriorityEnum = z.enum(["primary", "secondary", "tertiary"]);
|
||||
const LayoutEnum = z.enum(["auto", "table", "cards"]);
|
||||
|
||||
const formatSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("text") }),
|
||||
z.object({
|
||||
kind: z.literal("number"),
|
||||
decimals: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
compact: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("currency"),
|
||||
currency: z.string(),
|
||||
decimals: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("percent"),
|
||||
decimals: z.number().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
basis: z.enum(["fraction", "unit"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("date"),
|
||||
dateFormat: z.enum(["short", "long", "relative"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("delta"),
|
||||
decimals: z.number().optional(),
|
||||
upIsPositive: z.boolean().optional(),
|
||||
showSign: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("status"),
|
||||
statusMap: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
tone: z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("boolean"),
|
||||
labels: z
|
||||
.object({
|
||||
true: z.string(),
|
||||
false: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("link"),
|
||||
hrefKey: z.string().optional(),
|
||||
external: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("badge"),
|
||||
colorMap: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.enum(["success", "warning", "danger", "info", "neutral"]),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("array"),
|
||||
maxVisible: z.number().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const serializableColumnSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
abbr: z.string().optional(),
|
||||
sortable: z.boolean().optional(),
|
||||
align: AlignEnum.optional(),
|
||||
width: z.string().optional(),
|
||||
truncate: z.boolean().optional(),
|
||||
priority: PriorityEnum.optional(),
|
||||
hideOnMobile: z.boolean().optional(),
|
||||
format: formatSchema.optional(),
|
||||
});
|
||||
|
||||
const JsonPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
||||
|
||||
/**
|
||||
* Schema for serializable row data.
|
||||
*
|
||||
* Supports:
|
||||
* - Primitives: string, number, boolean, null
|
||||
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
|
||||
*
|
||||
* Does NOT support:
|
||||
* - Functions
|
||||
* - Class instances (Date, Map, Set, etc.)
|
||||
* - Plain objects (use format configs instead)
|
||||
*
|
||||
* @example
|
||||
* Valid row data:
|
||||
* ```json
|
||||
* {
|
||||
* "name": "Widget",
|
||||
* "price": 29.99,
|
||||
* "active": true,
|
||||
* "tags": ["electronics", "featured"],
|
||||
* "metrics": [1.2, 3.4, 5.6],
|
||||
* "flags": [true, false, true],
|
||||
* "mixed": ["label", 42, true]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const serializableDataSchema = z.record(
|
||||
z.string(),
|
||||
z.union([JsonPrimitiveSchema, z.array(JsonPrimitiveSchema)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for validating DataTable payloads from LLM tool calls.
|
||||
*
|
||||
* This schema validates the serializable parts of a DataTable:
|
||||
* - id: Unique identifier for this tool UI in the conversation
|
||||
* - columns: Column definitions (keys, labels, formatting, etc.)
|
||||
* - data: Data rows (primitives only - no functions or class instances)
|
||||
* - layout: Optional layout override ('auto' | 'table' | 'cards')
|
||||
*
|
||||
* Non-serializable props like `onSortChange`, `className`, and `isLoading`
|
||||
* must be provided separately in your React component.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = SerializableDataTableSchema.safeParse(llmResponse)
|
||||
* if (result.success) {
|
||||
* // result.data contains validated id, columns, and data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SerializableDataTableSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
columns: z.array(serializableColumnSchema),
|
||||
data: z.array(serializableDataSchema),
|
||||
layout: LayoutEnum.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type representing the serializable parts of a DataTable payload.
|
||||
*
|
||||
* This type includes only JSON-serializable data that can come from LLM tool calls:
|
||||
* - Column definitions (format configs, alignment, labels, etc.)
|
||||
* - Row data (primitives: strings, numbers, booleans, null, string arrays)
|
||||
*
|
||||
* Excluded from this type:
|
||||
* - Event handlers (`onSortChange`, `onResponseAction`)
|
||||
* - React-specific props (`className`, `isLoading`, `responseActions`)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const payload: SerializableDataTable = {
|
||||
* id: "data-table-expenses",
|
||||
* columns: [
|
||||
* { key: "name", label: "Name" },
|
||||
* { key: "price", label: "Price", format: { kind: "currency", currency: "USD" } }
|
||||
* ],
|
||||
* data: [
|
||||
* { name: "Widget", price: 29.99 }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type SerializableDataTable = z.infer<typeof SerializableDataTableSchema>;
|
||||
|
||||
/**
|
||||
* Validates and parses a DataTable payload from unknown data (e.g., LLM tool call result).
|
||||
*
|
||||
* This function:
|
||||
* 1. Validates the input against the `SerializableDataTableSchema`
|
||||
* 2. Throws a descriptive error if validation fails
|
||||
* 3. Returns typed serializable props ready to pass to the `<DataTable>` component
|
||||
*
|
||||
* The returned props are **serializable only** - you must provide client-side props
|
||||
* separately (onSortChange, isLoading, className, responseActions, onResponseAction).
|
||||
*
|
||||
* @param input - Unknown data to validate (typically from an LLM tool call)
|
||||
* @returns Validated and typed DataTable serializable props (id, columns, data)
|
||||
* @throws Error with validation details if input is invalid
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyToolUI({ result }: { result: unknown }) {
|
||||
* const serializableProps = parseSerializableDataTable(result)
|
||||
*
|
||||
* return (
|
||||
* <DataTable
|
||||
* {...serializableProps}
|
||||
* responseActions={[{ id: "export", label: "Export" }]}
|
||||
* onResponseAction={(id) => console.log(id)}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function parseSerializableDataTable(
|
||||
input: unknown,
|
||||
): Pick<
|
||||
DataTableProps<RowData>,
|
||||
"id" | "role" | "receipt" | "columns" | "data" | "layout"
|
||||
> {
|
||||
const { id, role, receipt, columns, data, layout } = parseWithSchema(
|
||||
SerializableDataTableSchema,
|
||||
input,
|
||||
"DataTable",
|
||||
);
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
receipt,
|
||||
columns: columns as unknown as Column<RowData>[],
|
||||
data: data as RowData[],
|
||||
layout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
ActionsProp,
|
||||
ToolUIId,
|
||||
ToolUIReceipt,
|
||||
ToolUIRole,
|
||||
} from "../shared";
|
||||
import type { FormatConfig } from "./formatters";
|
||||
|
||||
/**
|
||||
* JSON primitive type that can be serialized.
|
||||
*/
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Valid row value types for serializable DataTable data.
|
||||
*
|
||||
* Supports:
|
||||
* - Primitives: string, number, boolean, null
|
||||
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
|
||||
*
|
||||
* For complex data (objects with href/label, etc.), use column format configs
|
||||
* instead of putting objects in row data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 👍 Good: Use primitives and primitive arrays
|
||||
* const row = {
|
||||
* name: "Widget",
|
||||
* price: 29.99,
|
||||
* tags: ["electronics", "featured"],
|
||||
* metrics: [1.2, 3.4, 5.6]
|
||||
* }
|
||||
*
|
||||
* // 🚫 Bad: Don't put objects in row data
|
||||
* const row = {
|
||||
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
|
||||
export type DataTableRowData = Record<string, RowPrimitive>;
|
||||
export type RowData = Record<string, unknown>;
|
||||
export type ColumnKey<T extends object> = Extract<keyof T, string>;
|
||||
|
||||
export type FormatFor<V> = V extends number
|
||||
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
|
||||
: V extends boolean
|
||||
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
|
||||
: V extends (string | number | boolean | null)[]
|
||||
? Extract<FormatConfig, { kind: "array" }>
|
||||
: V extends string
|
||||
? Extract<
|
||||
FormatConfig,
|
||||
{ kind: "text" | "link" | "date" | "badge" | "status" }
|
||||
>
|
||||
: Extract<FormatConfig, { kind: "text" }>;
|
||||
|
||||
/**
|
||||
* Column definition for DataTable
|
||||
*
|
||||
* @remarks
|
||||
* **Important:** Columns are sortable by default (opt-out pattern).
|
||||
* Set `sortable: false` explicitly to disable sorting for specific columns.
|
||||
*/
|
||||
export interface Column<
|
||||
T extends object = DataTableRowData,
|
||||
K extends ColumnKey<T> = ColumnKey<T>,
|
||||
> {
|
||||
/** Unique identifier that maps to a key in the row data */
|
||||
key: K;
|
||||
/** Display text for the column header */
|
||||
label: string;
|
||||
/** Abbreviated label for narrow viewports */
|
||||
abbr?: string;
|
||||
/** Whether column is sortable. Default: true (opt-out pattern) */
|
||||
sortable?: boolean;
|
||||
/** Text alignment for column cells */
|
||||
align?: "left" | "right" | "center";
|
||||
/** Optional fixed width (CSS value) */
|
||||
width?: string;
|
||||
/** Enable text truncation with ellipsis */
|
||||
truncate?: boolean;
|
||||
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
|
||||
priority?: "primary" | "secondary" | "tertiary";
|
||||
/** Completely hide column on mobile viewports */
|
||||
hideOnMobile?: boolean;
|
||||
/** Formatting configuration for cell values */
|
||||
format?: FormatFor<T[K]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable props that can come from LLM tool calls or be JSON-serialized.
|
||||
*
|
||||
* These props contain only primitive values, arrays, and plain objects -
|
||||
* no functions, class instances, or other non-serializable values.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const serializableProps: DataTableSerializableProps = {
|
||||
* columns: [...],
|
||||
* data: [...],
|
||||
* rowIdKey: "id",
|
||||
* defaultSort: { by: "price", direction: "desc" }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableSerializableProps<T extends object = RowData> {
|
||||
/**
|
||||
* Unique identifier for this tool UI instance in the conversation.
|
||||
*
|
||||
* Used for:
|
||||
* - Assistant referencing ("the table above")
|
||||
* - Receipt generation (linking actions to their source)
|
||||
* - Narration context
|
||||
*
|
||||
* Should be stable across re-renders, meaningful, and unique within the conversation.
|
||||
*
|
||||
* @example "data-table-expenses-q3", "search-results-repos"
|
||||
*/
|
||||
id: ToolUIId;
|
||||
/** Optional surface role metadata (serializable) */
|
||||
role?: ToolUIRole;
|
||||
/** Optional receipt metadata for consequential outcomes (serializable) */
|
||||
receipt?: ToolUIReceipt;
|
||||
/** Column definitions */
|
||||
columns: Column<T>[];
|
||||
/** Row data (primitives only - no functions or class instances) */
|
||||
data: T[];
|
||||
/**
|
||||
* Layout mode for the component.
|
||||
* - 'auto' (default): Container queries choose table/cards
|
||||
* - 'table': Force table layout
|
||||
* - 'cards': Force stacked card layout
|
||||
*/
|
||||
layout?: "auto" | "table" | "cards";
|
||||
/**
|
||||
* Key in row data to use as unique identifier for React keys
|
||||
*
|
||||
* **Strongly recommended:** Always provide this for dynamic data to prevent
|
||||
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
|
||||
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
|
||||
*
|
||||
* @example rowIdKey="id" or rowIdKey="uuid"
|
||||
*/
|
||||
rowIdKey?: ColumnKey<T>;
|
||||
/**
|
||||
* Uncontrolled initial sort state (table manages its own sort state internally)
|
||||
*
|
||||
* **Sorting cycle:** Clicking column headers cycles through tri-state:
|
||||
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Start with descending price sort
|
||||
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
|
||||
* ```
|
||||
*/
|
||||
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
|
||||
/**
|
||||
* Controlled sort state (use with onSortChange from client props)
|
||||
*
|
||||
* When provided, you must also provide `onSortChange` to handle sort updates.
|
||||
* The table will cycle through: none → asc → desc → none.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
|
||||
* <DataTable sort={sort} onSortChange={setSort} />
|
||||
* ```
|
||||
*/
|
||||
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
|
||||
/** Empty state message */
|
||||
emptyMessage?: string;
|
||||
/** Max table height with vertical scroll (CSS value) */
|
||||
maxHeight?: string;
|
||||
/**
|
||||
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
|
||||
*
|
||||
* Defaults to 'en-US' to ensure consistent server/client rendering.
|
||||
* Pass explicit locale for internationalization.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DataTable locale="de-DE" /> // German formatting
|
||||
* <DataTable locale="ja-JP" /> // Japanese formatting
|
||||
* <DataTable /> // Uses 'en-US' default
|
||||
* ```
|
||||
*/
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side React-only props that cannot be serialized.
|
||||
*
|
||||
* These props contain functions, component state, or other React-specific values
|
||||
* that must be provided by your React code (not from LLM tool calls).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const clientProps: DataTableClientProps = {
|
||||
* isLoading: false,
|
||||
* className: "my-table",
|
||||
* onSortChange: (next) => setSort(next),
|
||||
* responseActions: [{ id: "export", label: "Export" }],
|
||||
* onResponseAction: (id) => console.log(id)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableClientProps<T extends object = RowData> {
|
||||
/** Show loading skeleton */
|
||||
isLoading?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/**
|
||||
* Sort change handler for controlled mode (required if sort is provided)
|
||||
*
|
||||
* **Tri-state cycle behavior:**
|
||||
* - Click unsorted column: `{ by: "column", direction: "asc" }`
|
||||
* - Click asc column: `{ by: "column", direction: "desc" }`
|
||||
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
|
||||
* - Click different column: `{ by: "newColumn", direction: "asc" }`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
|
||||
*
|
||||
* <DataTable
|
||||
* sort={sort}
|
||||
* onSortChange={(next) => {
|
||||
* console.log("Sort changed:", next)
|
||||
* setSort(next)
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
onSortChange?: (next: {
|
||||
by?: ColumnKey<T>;
|
||||
direction?: "asc" | "desc";
|
||||
}) => void;
|
||||
/** Optional response actions rendered below the table */
|
||||
responseActions?: ActionsProp;
|
||||
onResponseAction?: (actionId: string) => void | Promise<void>;
|
||||
onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete props for the DataTable component.
|
||||
*
|
||||
* Combines serializable props (can come from LLM tool calls) with client-side
|
||||
* React-only props. This separation makes the boundary explicit and prevents
|
||||
* accidental serialization of non-serializable values.
|
||||
*
|
||||
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
|
||||
* @see {@link DataTableClientProps} for React-only props
|
||||
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // From LLM tool call
|
||||
* const serializableProps = parseSerializableDataTable(llmResult)
|
||||
*
|
||||
* // Combine with React-specific props
|
||||
* <DataTable
|
||||
* {...serializableProps}
|
||||
* onSortChange={setSort}
|
||||
* responseActions={[{ id: "export", label: "Export" }]}
|
||||
* onResponseAction={(id) => handleAction(id)}
|
||||
* isLoading={loading}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface DataTableProps<T extends object = RowData>
|
||||
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
|
||||
|
||||
export interface DataTableContextValue<T extends object = RowData> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
rowIdKey?: ColumnKey<T>;
|
||||
sortBy?: ColumnKey<T>;
|
||||
sortDirection?: "asc" | "desc";
|
||||
toggleSort?: (key: ColumnKey<T>) => void;
|
||||
id?: string;
|
||||
isLoading?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Sort an array of objects by a key
|
||||
*/
|
||||
export function sortData<T, K extends Extract<keyof T, string>>(
|
||||
data: T[],
|
||||
key: K,
|
||||
direction: "asc" | "desc",
|
||||
locale?: string,
|
||||
): T[] {
|
||||
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
|
||||
const collator = new Intl.Collator(locale, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = get(a, key);
|
||||
const bVal = get(b, key);
|
||||
|
||||
// Handle nulls
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Type-specific comparison
|
||||
// Numbers
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return direction === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
// Dates (Date instances)
|
||||
if (aVal instanceof Date && bVal instanceof Date) {
|
||||
const diff = aVal.getTime() - bVal.getTime();
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Booleans: false < true
|
||||
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
|
||||
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Arrays: compare length
|
||||
if (Array.isArray(aVal) && Array.isArray(bVal)) {
|
||||
const diff = aVal.length - bVal.length;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// Strings that look like numbers -> numeric compare
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
const numA = parseNumericLike(aVal);
|
||||
const numB = parseNumericLike(bVal);
|
||||
if (numA != null && numB != null) {
|
||||
const diff = numA - numB;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
// ISO-like date strings
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
|
||||
const da = new Date(aVal).getTime();
|
||||
const db = new Date(bVal).getTime();
|
||||
const diff = da - db;
|
||||
return direction === "asc" ? diff : -diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: locale-aware string compare with numeric collation
|
||||
const aStr = String(aVal);
|
||||
const bStr = String(bVal);
|
||||
const comparison = collator.compare(aStr, bStr);
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-friendly identifier for a row using common keys
|
||||
*
|
||||
* Accepts any JSON-serializable primitive or array of primitives.
|
||||
* Arrays are converted to comma-separated strings.
|
||||
*/
|
||||
export function getRowIdentifier(
|
||||
row: Record<
|
||||
string,
|
||||
string | number | boolean | null | (string | number | boolean | null)[]
|
||||
>,
|
||||
identifierKey?: string,
|
||||
): string {
|
||||
const candidate =
|
||||
(identifierKey ? row[identifierKey] : undefined) ??
|
||||
(row as Record<string, unknown>).name ??
|
||||
(row as Record<string, unknown>).title ??
|
||||
(row as Record<string, unknown>).id;
|
||||
|
||||
if (candidate == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle arrays by joining them
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
|
||||
}
|
||||
|
||||
return String(candidate).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string that represents a numeric value, handling various formats:
|
||||
* - Currency symbols: $, €, £, ¥, etc.
|
||||
* - Percent symbols: %
|
||||
* - Accounting negatives: (1234) → -1234
|
||||
* - Thousands/decimal separators: 1,234.56 or 1.234,56
|
||||
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
|
||||
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
|
||||
*
|
||||
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
|
||||
*
|
||||
* @param input - String to parse
|
||||
* @returns Parsed number or null if unparseable
|
||||
*
|
||||
* @example
|
||||
* parseNumericLike("$1,234.56") // 1234.56
|
||||
* parseNumericLike("2.8T") // 2800000000000
|
||||
* parseNumericLike("768B") // 768
|
||||
* parseNumericLike("50%") // 50
|
||||
* parseNumericLike("(1234)") // -1234
|
||||
*/
|
||||
export function parseNumericLike(input: string): number | null {
|
||||
// Normalize whitespace (spaces, NBSPs, thin spaces)
|
||||
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
|
||||
if (!s) return null;
|
||||
|
||||
// Accounting negatives: (1234) -> -1234
|
||||
s = s.replace(/^\((.*)\)$/g, "-$1");
|
||||
|
||||
// Strip common currency and percent symbols
|
||||
s = s.replace(/[\%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
|
||||
|
||||
const lastComma = s.lastIndexOf(",");
|
||||
const lastDot = s.lastIndexOf(".");
|
||||
if (lastComma !== -1 && lastDot !== -1) {
|
||||
// Decide decimal by whichever occurs last
|
||||
const decimalSep = lastComma > lastDot ? "," : ".";
|
||||
const thousandSep = decimalSep === "," ? "." : ",";
|
||||
s = s.split(thousandSep).join("");
|
||||
s = s.replace(decimalSep, ".");
|
||||
} else if (lastComma !== -1) {
|
||||
// Only comma present
|
||||
const frac = s.length - lastComma - 1;
|
||||
if (frac === 2 || frac === 3) s = s.replace(/,/g, ".");
|
||||
else s = s.replace(/,/g, "");
|
||||
} else if (lastDot !== -1) {
|
||||
// Only dot present; if multiple dots, treat as thousands and strip
|
||||
if ((s.match(/\./g) || []).length > 1) s = s.replace(/\./g, "");
|
||||
}
|
||||
|
||||
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
|
||||
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
|
||||
if (compactMatch) {
|
||||
const baseNum = Number(compactMatch[1]);
|
||||
if (Number.isNaN(baseNum)) return null;
|
||||
|
||||
const suffix = compactMatch[2].toUpperCase();
|
||||
|
||||
// Disambiguate single "B" (bytes vs billions)
|
||||
// If whole number < 1024, treat as bytes. Otherwise, billions.
|
||||
if (suffix === "B") {
|
||||
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
|
||||
return isLikelyBytes ? baseNum : baseNum * 1e9;
|
||||
}
|
||||
|
||||
const multipliers: Record<string, number> = {
|
||||
K: 1e3,
|
||||
KB: 1024, // Kilo: metric vs binary
|
||||
M: 1e6,
|
||||
MB: 1024 ** 2, // Mega
|
||||
G: 1e9,
|
||||
GB: 1024 ** 3, // Giga
|
||||
T: 1e12,
|
||||
TB: 1024 ** 4, // Tera
|
||||
P: 1e15,
|
||||
PB: 1024 ** 5, // Peta
|
||||
};
|
||||
|
||||
return baseNum * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
|
||||
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
|
||||
const n = Number(s);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
|
||||
* Button → shadcn/ui Button
|
||||
*/
|
||||
|
||||
export { cn } from "../../../lib/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { Action } from "./schema";
|
||||
import { cn, Button } from "./_adapter";
|
||||
import { useActionButtons } from "./use-action-buttons";
|
||||
|
||||
export interface ActionButtonsProps {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
align = "right",
|
||||
className,
|
||||
}: ActionButtonsProps) {
|
||||
const { actions: resolvedActions, runAction } = useActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
|
||||
align === "left" && "@sm/actions:justify-start",
|
||||
align === "center" && "@sm/actions:justify-center",
|
||||
align === "right" && "@sm/actions:justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{resolvedActions.map((action) => {
|
||||
const label = action.currentLabel;
|
||||
const variant = action.variant || "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={variant}
|
||||
onClick={() => runAction(action.id)}
|
||||
disabled={action.isDisabled}
|
||||
className={cn(
|
||||
"rounded-full px-4!",
|
||||
"justify-center",
|
||||
"min-h-11 w-full text-base",
|
||||
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
|
||||
action.isConfirming &&
|
||||
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
|
||||
)}
|
||||
aria-label={
|
||||
action.shortcut ? `${label} (${action.shortcut})` : label
|
||||
}
|
||||
>
|
||||
{action.isLoading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 motion-safe:animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{action.icon && !action.isLoading && (
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
)}
|
||||
{label}
|
||||
{action.shortcut && !action.isLoading && (
|
||||
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Action, ActionsConfig } from "./schema";
|
||||
|
||||
export type ActionsProp = ActionsConfig | Action[];
|
||||
|
||||
const NEGATORY_ACTION_IDS = new Set([
|
||||
"cancel",
|
||||
"dismiss",
|
||||
"skip",
|
||||
"no",
|
||||
"reset",
|
||||
"close",
|
||||
"decline",
|
||||
"reject",
|
||||
"back",
|
||||
"later",
|
||||
"not-now",
|
||||
"maybe-later",
|
||||
]);
|
||||
|
||||
function inferVariant(action: Action): Action {
|
||||
if (action.variant) return action;
|
||||
if (NEGATORY_ACTION_IDS.has(action.id)) {
|
||||
return { ...action, variant: "ghost" };
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
export function normalizeActionsConfig(
|
||||
actions?: ActionsProp,
|
||||
): ActionsConfig | null {
|
||||
if (!actions) return null;
|
||||
|
||||
const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);
|
||||
|
||||
if (rawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = rawItems.map(inferVariant);
|
||||
|
||||
return Array.isArray(actions)
|
||||
? { items }
|
||||
: {
|
||||
items,
|
||||
align: actions.align,
|
||||
confirmTimeout: actions.confirmTimeout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export interface ToolUIErrorBoundaryProps {
|
||||
componentName: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ToolUIErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ToolUIErrorBoundary extends React.Component<
|
||||
ToolUIErrorBoundaryProps,
|
||||
ToolUIErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ToolUIErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ToolUIErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`[${this.props.componentName}] render error:`, error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="border-destructive text-destructive rounded-lg border p-4">
|
||||
<p className="font-semibold">
|
||||
{this.props.componentName} failed to render
|
||||
</p>
|
||||
<p className="text-sm">{this.state.error?.message}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ActionButtons } from "./action-buttons";
|
||||
export type { ActionButtonsProps } from "./action-buttons";
|
||||
export { normalizeActionsConfig, type ActionsProp } from "./actions-config";
|
||||
export * from "./error-boundary";
|
||||
export * from "./parse";
|
||||
export * from "./schema";
|
||||
export * from "./use-copy-to-clipboard";
|
||||
export * from "./utils";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AspectRatioSchema = z
|
||||
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
|
||||
.default("auto");
|
||||
|
||||
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
|
||||
|
||||
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
|
||||
|
||||
export type MediaFit = z.infer<typeof MediaFitSchema>;
|
||||
|
||||
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
|
||||
auto: "",
|
||||
"1:1": "aspect-square",
|
||||
"4:3": "aspect-[4/3]",
|
||||
"16:9": "aspect-video",
|
||||
"9:16": "aspect-[9/16]",
|
||||
};
|
||||
|
||||
export function getRatioClass(ratio: AspectRatio): string {
|
||||
return RATIO_CLASS_MAP[ratio];
|
||||
}
|
||||
|
||||
export function getFitClass(fit: MediaFit): string {
|
||||
return fit === "cover" ? "object-cover" : "object-contain";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string.
|
||||
* @example formatDuration(128000) => "2:08"
|
||||
* @example formatDuration(3661000) => "1:01:01"
|
||||
*/
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const totalSeconds = Math.round(durationMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human-readable string.
|
||||
* @example formatFileSize(1024) => "1 KB"
|
||||
* @example formatFileSize(1536000) => "1.5 MB"
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
AspectRatioSchema,
|
||||
MediaFitSchema,
|
||||
RATIO_CLASS_MAP,
|
||||
getRatioClass,
|
||||
getFitClass,
|
||||
type AspectRatio,
|
||||
type MediaFit,
|
||||
} from "./aspect-ratio";
|
||||
|
||||
export { OVERLAY_GRADIENT } from "./overlay-gradient";
|
||||
|
||||
export { formatDuration, formatFileSize } from "./format-utils";
|
||||
|
||||
export { sanitizeHref } from "./sanitize-href";
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Eased gradient for hover overlays on media elements.
|
||||
* Creates a smooth fade from opaque black at top to transparent.
|
||||
*
|
||||
* @see https://larsenwork.com/easing-gradients/
|
||||
*/
|
||||
export const OVERLAY_GRADIENT = `linear-gradient(
|
||||
to bottom,
|
||||
hsl(0, 0%, 0%) 0%,
|
||||
hsla(0, 0%, 0%, 0.987) 8.3%,
|
||||
hsla(0, 0%, 0%, 0.951) 16.6%,
|
||||
hsla(0, 0%, 0%, 0.896) 24.6%,
|
||||
hsla(0, 0%, 0%, 0.825) 32.5%,
|
||||
hsla(0, 0%, 0%, 0.741) 40.1%,
|
||||
hsla(0, 0%, 0%, 0.648) 47.6%,
|
||||
hsla(0, 0%, 0%, 0.55) 54.8%,
|
||||
hsla(0, 0%, 0%, 0.45) 61.7%,
|
||||
hsla(0, 0%, 0%, 0.352) 68.3%,
|
||||
hsla(0, 0%, 0%, 0.259) 74.5%,
|
||||
hsla(0, 0%, 0%, 0.175) 80.4%,
|
||||
hsla(0, 0%, 0%, 0.104) 86%,
|
||||
hsla(0, 0%, 0%, 0.049) 91.1%,
|
||||
hsla(0, 0%, 0%, 0.013) 95.8%,
|
||||
hsla(0, 0%, 0%, 0) 100%
|
||||
)` as const;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Sanitize a URL to ensure it's safe for use in href attributes.
|
||||
* Only allows http: and https: protocols.
|
||||
*
|
||||
* @returns The sanitized URL string, or undefined if invalid/unsafe
|
||||
*/
|
||||
export function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.toString();
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
function formatZodPath(path: Array<string | number | symbol>): string {
|
||||
if (path.length === 0) return "root";
|
||||
return path
|
||||
.map((segment) =>
|
||||
typeof segment === "number" ? `[${segment}]` : String(segment),
|
||||
)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Zod errors into a compact `path: message` string.
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): string {
|
||||
const parts = error.issues.map((issue) => {
|
||||
const path = formatZodPath(issue.path);
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return Array.from(new Set(parts)).join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input and throw a readable error.
|
||||
*/
|
||||
export function parseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
name: string,
|
||||
): T {
|
||||
const res = schema.safeParse(input);
|
||||
if (!res.success) {
|
||||
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Tool UI conventions:
|
||||
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
|
||||
* - Schema: `SerializableXSchema`
|
||||
* - Parser: `parseSerializableX(input: unknown)`
|
||||
* - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`
|
||||
* - Root attrs: `data-tool-ui-id` + `data-slot`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for tool UI identity.
|
||||
*
|
||||
* Every tool UI should have a unique identifier that:
|
||||
* - Is stable across re-renders
|
||||
* - Is meaningful (not auto-generated)
|
||||
* - Is unique within the conversation
|
||||
*
|
||||
* Format recommendation: `{component-type}-{semantic-identifier}`
|
||||
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
|
||||
*/
|
||||
export const ToolUIIdSchema = z.string().min(1);
|
||||
|
||||
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
|
||||
|
||||
/**
|
||||
* Primary role of a Tool UI surface in a chat context.
|
||||
*/
|
||||
export const ToolUIRoleSchema = z.enum([
|
||||
"information",
|
||||
"decision",
|
||||
"control",
|
||||
"state",
|
||||
"composite",
|
||||
]);
|
||||
|
||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||
|
||||
export const ToolUIReceiptOutcomeSchema = z.enum([
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Optional receipt metadata: a durable summary of an outcome.
|
||||
*/
|
||||
export const ToolUIReceiptSchema = z.object({
|
||||
outcome: ToolUIReceiptOutcomeSchema,
|
||||
summary: z.string().min(1),
|
||||
identifiers: z.record(z.string(), z.string()).optional(),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for Tool UI payloads (id + optional role/receipt).
|
||||
*/
|
||||
export const ToolUISurfaceSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
});
|
||||
|
||||
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
/**
|
||||
* Canonical narration the assistant can use after this action is taken.
|
||||
*
|
||||
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
|
||||
*/
|
||||
sentence: z.string().optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
variant: z
|
||||
.enum(["default", "destructive", "secondary", "ghost", "outline"])
|
||||
.optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
|
||||
export const ActionButtonsPropsSchema = z.object({
|
||||
actions: z.array(ActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
|
||||
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
|
||||
actions: z.array(SerializableActionSchema),
|
||||
}).omit({ className: true });
|
||||
|
||||
export interface ActionsConfig {
|
||||
items: Action[];
|
||||
align?: "left" | "center" | "right";
|
||||
confirmTimeout?: number;
|
||||
}
|
||||
|
||||
export const SerializableActionsConfigSchema = z.object({
|
||||
items: z.array(SerializableActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SerializableActionsConfig = z.infer<
|
||||
typeof SerializableActionsConfigSchema
|
||||
>;
|
||||
|
||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Action } from "./schema";
|
||||
|
||||
export type UseActionButtonsOptions = {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
};
|
||||
|
||||
export type UseActionButtonsResult = {
|
||||
actions: Array<
|
||||
Action & {
|
||||
currentLabel: string;
|
||||
isConfirming: boolean;
|
||||
isExecuting: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>;
|
||||
runAction: (actionId: string) => Promise<void>;
|
||||
confirmingActionId: string | null;
|
||||
executingActionId: string | null;
|
||||
};
|
||||
|
||||
export function useActionButtons(
|
||||
options: UseActionButtonsOptions,
|
||||
): UseActionButtonsResult {
|
||||
const {
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
} = options;
|
||||
|
||||
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [executingActionId, setExecutingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
|
||||
return () => clearTimeout(id);
|
||||
}, [confirmingActionId, confirmTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [confirmingActionId]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) return;
|
||||
|
||||
const isAnyActionExecuting = executingActionId !== null;
|
||||
if (action.disabled || action.loading || isAnyActionExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.confirmLabel && confirmingActionId !== action.id) {
|
||||
setConfirmingActionId(action.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onBeforeAction) {
|
||||
const shouldProceed = await onBeforeAction(action.id);
|
||||
if (!shouldProceed) {
|
||||
setConfirmingActionId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setExecutingActionId(action.id);
|
||||
await onAction(action.id);
|
||||
} finally {
|
||||
setExecutingActionId(null);
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
},
|
||||
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
|
||||
);
|
||||
|
||||
const resolvedActions = useMemo(
|
||||
() =>
|
||||
actions.map((action) => {
|
||||
const isConfirming = confirmingActionId === action.id;
|
||||
const isThisActionExecuting = executingActionId === action.id;
|
||||
const isLoading = action.loading || isThisActionExecuting;
|
||||
const isDisabled =
|
||||
action.disabled || (executingActionId !== null && !isThisActionExecuting);
|
||||
const currentLabel =
|
||||
isConfirming && action.confirmLabel
|
||||
? action.confirmLabel
|
||||
: action.label;
|
||||
|
||||
return {
|
||||
...action,
|
||||
currentLabel,
|
||||
isConfirming,
|
||||
isExecuting: isThisActionExecuting,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
};
|
||||
}),
|
||||
[actions, confirmingActionId, executingActionId],
|
||||
);
|
||||
|
||||
return {
|
||||
actions: resolvedActions,
|
||||
runAction,
|
||||
confirmingActionId,
|
||||
executingActionId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function fallbackCopyToClipboard(text: string): boolean {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.setAttribute("readonly", "");
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.top = "-9999px";
|
||||
textArea.style.left = "-9999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(options?: {
|
||||
resetAfterMs?: number;
|
||||
}): {
|
||||
copiedId: string | null;
|
||||
copy: (text: string, id?: string) => Promise<boolean>;
|
||||
} {
|
||||
const resetAfterMs = options?.resetAfterMs ?? 2000;
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string, id: string = "default") => {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} else {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
} catch {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setCopiedId(id);
|
||||
}
|
||||
|
||||
return ok;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copiedId) return;
|
||||
const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copiedId, resetAfterMs]);
|
||||
|
||||
return { copiedId, copy };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
|
||||
if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;
|
||||
return `${Math.round(seconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function formatCount(count: number): string {
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
export function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:scale-y-[-1]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="group data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-y-clip overflow-x-visible text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pt-0 pb-4",
|
||||
"group-data-[state=open]:animate-in group-data-[state=open]:fade-in-0",
|
||||
"group-data-[state=closed]:animate-out group-data-[state=closed]:fade-out-0",
|
||||
"duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
warning:
|
||||
"border-transparent bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 [a&]:hover:bg-amber-500/20 dark:[a&]:hover:bg-amber-500/30",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
homeCTA: "rounded-full px-6 py-3 text-lg w-fit text-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "../../lib/ui/cn";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { Badge } from "./components/ui/badge";
|
||||
|
||||
type ToolOutput = {
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
structuredContent?: unknown;
|
||||
structured_content?: unknown;
|
||||
structured?: unknown;
|
||||
};
|
||||
|
||||
type RenderData = {
|
||||
toolInput?: { identifier?: string };
|
||||
toolOutput?: ToolOutput | string;
|
||||
};
|
||||
|
||||
function stripFrontmatter(text: string): string {
|
||||
if (!text.startsWith("---")) return text;
|
||||
const end = text.indexOf("---", 3);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + 3).trim();
|
||||
}
|
||||
|
||||
function parseTitle(text: string): string | null {
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("# ")) {
|
||||
return line.replace("# ", "").trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractText(toolOutput: ToolOutput | string | undefined): string {
|
||||
if (!toolOutput) return "";
|
||||
if (typeof toolOutput === "string") return toolOutput;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block?.type === "text" && typeof block.text === "string",
|
||||
);
|
||||
if (textBlock?.text) return textBlock.text;
|
||||
}
|
||||
|
||||
if (typeof toolOutput.structuredContent === "string") {
|
||||
return toolOutput.structuredContent;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function NotePreviewApp() {
|
||||
const [identifier, setIdentifier] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("Note Preview");
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [hasData, setHasData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
const message = event.data as { type?: string; payload?: { renderData?: RenderData } };
|
||||
if (message?.type === "ui-lifecycle-iframe-render-data") {
|
||||
setHasData(true);
|
||||
const renderData = message.payload?.renderData;
|
||||
const nextIdentifier = renderData?.toolInput?.identifier || "";
|
||||
const rawText = extractText(renderData?.toolOutput);
|
||||
const cleaned = stripFrontmatter(rawText).trim();
|
||||
const parsedTitle = parseTitle(cleaned);
|
||||
|
||||
setIdentifier(nextIdentifier);
|
||||
setTitle(parsedTitle || nextIdentifier || "Note Preview");
|
||||
setContent(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [hasData]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground" data-theme="light">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 px-6 py-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{identifier ? `Identifier: ${identifier}` : "Note content"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">tool-ui (React)</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-card rounded-2xl border p-4 shadow-sm">
|
||||
{content ? (
|
||||
<pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No content available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(<NotePreviewApp />);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { DataTable, DataTableErrorBoundary } from "./components/tool-ui/data-table";
|
||||
import type { Column, DataTableRowData } from "./components/tool-ui/data-table/types";
|
||||
|
||||
type SearchResult = {
|
||||
title?: string;
|
||||
type?: string;
|
||||
score?: number;
|
||||
permalink?: string;
|
||||
file_path?: string;
|
||||
metadata?: { tags?: string[] };
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
type ToolOutput = {
|
||||
structuredContent?: unknown;
|
||||
structured_content?: unknown;
|
||||
structured?: unknown;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
|
||||
type RenderData = {
|
||||
toolInput?: { query?: string };
|
||||
toolOutput?: ToolOutput | string;
|
||||
};
|
||||
|
||||
function safeJsonParse(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStructured(toolOutput: ToolOutput | string | undefined): unknown {
|
||||
if (!toolOutput) return null;
|
||||
if (typeof toolOutput === "string") {
|
||||
return safeJsonParse(toolOutput) || { text: toolOutput };
|
||||
}
|
||||
|
||||
const structured =
|
||||
toolOutput.structuredContent ||
|
||||
toolOutput.structured_content ||
|
||||
toolOutput.structured ||
|
||||
null;
|
||||
if (structured) return structured;
|
||||
|
||||
const content = toolOutput.content;
|
||||
if (Array.isArray(content)) {
|
||||
const textBlock = content.find(
|
||||
(block) => block?.type === "text" && typeof block.text === "string",
|
||||
);
|
||||
if (textBlock?.text) {
|
||||
return safeJsonParse(textBlock.text) || { text: textBlock.text };
|
||||
}
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
function getResults(structured: unknown): SearchResult[] {
|
||||
if (!structured || typeof structured !== "object") return [];
|
||||
|
||||
const record = structured as Record<string, unknown>;
|
||||
if (Array.isArray(record.results)) return record.results as SearchResult[];
|
||||
|
||||
const nested = record.result as Record<string, unknown> | undefined;
|
||||
if (nested && Array.isArray(nested.results)) return nested.results as SearchResult[];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatTags(result: SearchResult): string {
|
||||
const tags = result.metadata?.tags || result.tags || [];
|
||||
if (!Array.isArray(tags)) return "";
|
||||
return tags.join(", ");
|
||||
}
|
||||
|
||||
function formatPath(result: SearchResult): string {
|
||||
return result.permalink || result.file_path || "";
|
||||
}
|
||||
|
||||
const columns: Column<DataTableRowData>[] = [
|
||||
{ key: "title", label: "Title", priority: "primary" },
|
||||
{ key: "type", label: "Type", priority: "secondary" },
|
||||
{ key: "score", label: "Score", align: "right", priority: "secondary" },
|
||||
{ key: "path", label: "Path", priority: "tertiary" },
|
||||
{ key: "tags", label: "Tags", priority: "tertiary" },
|
||||
];
|
||||
|
||||
function SearchResultsApp() {
|
||||
const [query, setQuery] = useState<string>("Search results");
|
||||
const [rows, setRows] = useState<DataTableRowData[]>([]);
|
||||
const [hasData, setHasData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
const message = event.data as { type?: string; payload?: { renderData?: RenderData } };
|
||||
if (message?.type === "ui-lifecycle-iframe-render-data") {
|
||||
setHasData(true);
|
||||
const renderData = message.payload?.renderData;
|
||||
const nextQuery = renderData?.toolInput?.query;
|
||||
setQuery(nextQuery ? `Query: ${nextQuery}` : "Search results");
|
||||
|
||||
const structured = extractStructured(renderData?.toolOutput);
|
||||
const results = getResults(structured);
|
||||
const nextRows = results.map((result, index) => ({
|
||||
id: result.permalink || result.file_path || result.title || `row-${index}`,
|
||||
title: result.title || "Untitled",
|
||||
type: result.type || "",
|
||||
score: typeof result.score === "number" ? Number(result.score.toFixed(2)) : 0,
|
||||
path: formatPath(result),
|
||||
tags: formatTags(result),
|
||||
}));
|
||||
setRows(nextRows);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasData) {
|
||||
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [hasData]);
|
||||
|
||||
const footer = useMemo(() => {
|
||||
if (!rows.length) return "No results";
|
||||
return `${rows.length} result${rows.length === 1 ? "" : "s"}`;
|
||||
}, [rows.length]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground" data-theme="light">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-4 px-6 py-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Search Results</h1>
|
||||
<p className="text-muted-foreground text-xs">{query}</p>
|
||||
</div>
|
||||
<span className="border-border bg-card text-foreground rounded-full border px-3 py-1 text-[11px]">
|
||||
tool-ui (React)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-card rounded-2xl border p-3 shadow-sm">
|
||||
<DataTableErrorBoundary>
|
||||
<DataTable
|
||||
id="basic-memory-search-results"
|
||||
rowIdKey="id"
|
||||
columns={columns}
|
||||
data={rows}
|
||||
emptyMessage="No results to show"
|
||||
/>
|
||||
</DataTableErrorBoundary>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(<SearchResultsApp />);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@custom-variant dark (&:is(.dark *, [data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "./**/*.{ts,tsx,html}";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
[data-theme="light"][data-theme] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Geist", "Space Grotesk", system-ui, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{ts,tsx,html}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user