mirror of
https://github.com/langgenius/dify.git
synced 2026-04-13 22:57:26 +08:00
Merge remote-tracking branch 'myori/main' into feat/collaboration
This commit is contained in:
commit
ba17f576e9
@ -548,7 +548,7 @@ class UpdateConfig(BaseSettings):
|
||||
|
||||
class WorkflowVariableTruncationConfig(BaseSettings):
|
||||
WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field(
|
||||
# 100KB
|
||||
# 1000 KiB
|
||||
1024_000,
|
||||
description="Maximum size for variable to trigger final truncation.",
|
||||
)
|
||||
|
||||
@ -49,62 +49,80 @@ class IndexingRunner:
|
||||
self.storage = storage
|
||||
self.model_manager = ModelManager()
|
||||
|
||||
def _handle_indexing_error(self, document_id: str, error: Exception) -> None:
|
||||
"""Handle indexing errors by updating document status."""
|
||||
logger.exception("consume document failed")
|
||||
document = db.session.get(DatasetDocument, document_id)
|
||||
if document:
|
||||
document.indexing_status = "error"
|
||||
error_message = getattr(error, "description", str(error))
|
||||
document.error = str(error_message)
|
||||
document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
|
||||
def run(self, dataset_documents: list[DatasetDocument]):
|
||||
"""Run the indexing process."""
|
||||
for dataset_document in dataset_documents:
|
||||
document_id = dataset_document.id
|
||||
try:
|
||||
# Re-query the document to ensure it's bound to the current session
|
||||
requeried_document = db.session.get(DatasetDocument, document_id)
|
||||
if not requeried_document:
|
||||
logger.warning("Document not found, skipping document id: %s", document_id)
|
||||
continue
|
||||
|
||||
# get dataset
|
||||
dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first()
|
||||
dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first()
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("no dataset found")
|
||||
# get the process rule
|
||||
stmt = select(DatasetProcessRule).where(
|
||||
DatasetProcessRule.id == dataset_document.dataset_process_rule_id
|
||||
DatasetProcessRule.id == requeried_document.dataset_process_rule_id
|
||||
)
|
||||
processing_rule = db.session.scalar(stmt)
|
||||
if not processing_rule:
|
||||
raise ValueError("no process rule found")
|
||||
index_type = dataset_document.doc_form
|
||||
index_type = requeried_document.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
# extract
|
||||
text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict())
|
||||
text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict())
|
||||
|
||||
# transform
|
||||
documents = self._transform(
|
||||
index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict()
|
||||
index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict()
|
||||
)
|
||||
# save segment
|
||||
self._load_segments(dataset, dataset_document, documents)
|
||||
self._load_segments(dataset, requeried_document, documents)
|
||||
|
||||
# load
|
||||
self._load(
|
||||
index_processor=index_processor,
|
||||
dataset=dataset,
|
||||
dataset_document=dataset_document,
|
||||
dataset_document=requeried_document,
|
||||
documents=documents,
|
||||
)
|
||||
except DocumentIsPausedError:
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}")
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {document_id}")
|
||||
except ProviderTokenNotInitError as e:
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e.description)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
except ObjectDeletedError:
|
||||
logger.warning("Document deleted, document id: %s", dataset_document.id)
|
||||
logger.warning("Document deleted, document id: %s", document_id)
|
||||
except Exception as e:
|
||||
logger.exception("consume document failed")
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
|
||||
def run_in_splitting_status(self, dataset_document: DatasetDocument):
|
||||
"""Run the indexing process when the index_status is splitting."""
|
||||
document_id = dataset_document.id
|
||||
try:
|
||||
# Re-query the document to ensure it's bound to the current session
|
||||
requeried_document = db.session.get(DatasetDocument, document_id)
|
||||
if not requeried_document:
|
||||
logger.warning("Document not found: %s", document_id)
|
||||
return
|
||||
|
||||
# get dataset
|
||||
dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first()
|
||||
dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first()
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("no dataset found")
|
||||
@ -112,57 +130,60 @@ class IndexingRunner:
|
||||
# get exist document_segment list and delete
|
||||
document_segments = (
|
||||
db.session.query(DocumentSegment)
|
||||
.filter_by(dataset_id=dataset.id, document_id=dataset_document.id)
|
||||
.filter_by(dataset_id=dataset.id, document_id=requeried_document.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
for document_segment in document_segments:
|
||||
db.session.delete(document_segment)
|
||||
if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX:
|
||||
if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX:
|
||||
# delete child chunks
|
||||
db.session.query(ChildChunk).where(ChildChunk.segment_id == document_segment.id).delete()
|
||||
db.session.commit()
|
||||
# get the process rule
|
||||
stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == dataset_document.dataset_process_rule_id)
|
||||
stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == requeried_document.dataset_process_rule_id)
|
||||
processing_rule = db.session.scalar(stmt)
|
||||
if not processing_rule:
|
||||
raise ValueError("no process rule found")
|
||||
|
||||
index_type = dataset_document.doc_form
|
||||
index_type = requeried_document.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
# extract
|
||||
text_docs = self._extract(index_processor, dataset_document, processing_rule.to_dict())
|
||||
text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict())
|
||||
|
||||
# transform
|
||||
documents = self._transform(
|
||||
index_processor, dataset, text_docs, dataset_document.doc_language, processing_rule.to_dict()
|
||||
index_processor, dataset, text_docs, requeried_document.doc_language, processing_rule.to_dict()
|
||||
)
|
||||
# save segment
|
||||
self._load_segments(dataset, dataset_document, documents)
|
||||
self._load_segments(dataset, requeried_document, documents)
|
||||
|
||||
# load
|
||||
self._load(
|
||||
index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents
|
||||
index_processor=index_processor,
|
||||
dataset=dataset,
|
||||
dataset_document=requeried_document,
|
||||
documents=documents,
|
||||
)
|
||||
except DocumentIsPausedError:
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}")
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {document_id}")
|
||||
except ProviderTokenNotInitError as e:
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e.description)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
except Exception as e:
|
||||
logger.exception("consume document failed")
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
|
||||
def run_in_indexing_status(self, dataset_document: DatasetDocument):
|
||||
"""Run the indexing process when the index_status is indexing."""
|
||||
document_id = dataset_document.id
|
||||
try:
|
||||
# Re-query the document to ensure it's bound to the current session
|
||||
requeried_document = db.session.get(DatasetDocument, document_id)
|
||||
if not requeried_document:
|
||||
logger.warning("Document not found: %s", document_id)
|
||||
return
|
||||
|
||||
# get dataset
|
||||
dataset = db.session.query(Dataset).filter_by(id=dataset_document.dataset_id).first()
|
||||
dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first()
|
||||
|
||||
if not dataset:
|
||||
raise ValueError("no dataset found")
|
||||
@ -170,7 +191,7 @@ class IndexingRunner:
|
||||
# get exist document_segment list and delete
|
||||
document_segments = (
|
||||
db.session.query(DocumentSegment)
|
||||
.filter_by(dataset_id=dataset.id, document_id=dataset_document.id)
|
||||
.filter_by(dataset_id=dataset.id, document_id=requeried_document.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
@ -188,7 +209,7 @@ class IndexingRunner:
|
||||
"dataset_id": document_segment.dataset_id,
|
||||
},
|
||||
)
|
||||
if dataset_document.doc_form == IndexType.PARENT_CHILD_INDEX:
|
||||
if requeried_document.doc_form == IndexType.PARENT_CHILD_INDEX:
|
||||
child_chunks = document_segment.get_child_chunks()
|
||||
if child_chunks:
|
||||
child_documents = []
|
||||
@ -206,24 +227,20 @@ class IndexingRunner:
|
||||
document.children = child_documents
|
||||
documents.append(document)
|
||||
# build index
|
||||
index_type = dataset_document.doc_form
|
||||
index_type = requeried_document.doc_form
|
||||
index_processor = IndexProcessorFactory(index_type).init_index_processor()
|
||||
self._load(
|
||||
index_processor=index_processor, dataset=dataset, dataset_document=dataset_document, documents=documents
|
||||
index_processor=index_processor,
|
||||
dataset=dataset,
|
||||
dataset_document=requeried_document,
|
||||
documents=documents,
|
||||
)
|
||||
except DocumentIsPausedError:
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {dataset_document.id}")
|
||||
raise DocumentIsPausedError(f"Document paused, document id: {document_id}")
|
||||
except ProviderTokenNotInitError as e:
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e.description)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
except Exception as e:
|
||||
logger.exception("consume document failed")
|
||||
dataset_document.indexing_status = "error"
|
||||
dataset_document.error = str(e)
|
||||
dataset_document.stopped_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
self._handle_indexing_error(document_id, e)
|
||||
|
||||
def indexing_estimate(
|
||||
self,
|
||||
|
||||
@ -100,7 +100,7 @@ class LLMGenerator:
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str):
|
||||
def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str) -> Sequence[str]:
|
||||
output_parser = SuggestedQuestionsAfterAnswerOutputParser()
|
||||
format_instructions = output_parser.get_format_instructions()
|
||||
|
||||
@ -119,6 +119,8 @@ class LLMGenerator:
|
||||
|
||||
prompt_messages = [UserPromptMessage(content=prompt)]
|
||||
|
||||
questions: Sequence[str] = []
|
||||
|
||||
try:
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages),
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.llm_generator.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuggestedQuestionsAfterAnswerOutputParser:
|
||||
def get_format_instructions(self) -> str:
|
||||
return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
|
||||
|
||||
def parse(self, text: str):
|
||||
def parse(self, text: str) -> Sequence[str]:
|
||||
action_match = re.search(r"\[.*?\]", text.strip(), re.DOTALL)
|
||||
questions: list[str] = []
|
||||
if action_match is not None:
|
||||
json_obj = json.loads(action_match.group(0).strip())
|
||||
else:
|
||||
json_obj = []
|
||||
return json_obj
|
||||
try:
|
||||
json_obj = json.loads(action_match.group(0).strip())
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Failed to decode suggested questions payload: %s", exc)
|
||||
else:
|
||||
if isinstance(json_obj, list):
|
||||
questions = [question for question in json_obj if isinstance(question, str)]
|
||||
return questions
|
||||
|
||||
@ -441,10 +441,14 @@ class LLMNode(Node):
|
||||
usage = LLMUsage.empty_usage()
|
||||
finish_reason = None
|
||||
full_text_buffer = io.StringIO()
|
||||
collected_structured_output = None # Collect structured_output from streaming chunks
|
||||
# Consume the invoke result and handle generator exception
|
||||
try:
|
||||
for result in invoke_result:
|
||||
if isinstance(result, LLMResultChunkWithStructuredOutput):
|
||||
# Collect structured_output from the chunk
|
||||
if result.structured_output is not None:
|
||||
collected_structured_output = dict(result.structured_output)
|
||||
yield result
|
||||
if isinstance(result, LLMResultChunk):
|
||||
contents = result.delta.message.content
|
||||
@ -492,6 +496,8 @@ class LLMNode(Node):
|
||||
finish_reason=finish_reason,
|
||||
# Reasoning content for workflow variables and downstream nodes
|
||||
reasoning_content=reasoning_content,
|
||||
# Pass structured output if collected from streaming chunks
|
||||
structured_output=collected_structured_output,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -747,7 +747,7 @@ class ParameterExtractorNode(Node):
|
||||
if model_mode == ModelMode.CHAT:
|
||||
system_prompt_messages = ChatModelMessage(
|
||||
role=PromptMessageRole.SYSTEM,
|
||||
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str).replace("{{instructions}}", instruction),
|
||||
text=CHAT_GENERATE_JSON_PROMPT.format(histories=memory_str, instructions=instruction),
|
||||
)
|
||||
user_prompt_message = ChatModelMessage(role=PromptMessageRole.USER, text=input_text)
|
||||
return [system_prompt_messages, user_prompt_message]
|
||||
|
||||
@ -135,7 +135,7 @@ Here are the chat histories between human and assistant, inside <histories></his
|
||||
### Instructions:
|
||||
Some extra information are provided below, you should always follow the instructions as possible as you can.
|
||||
<instructions>
|
||||
{{instructions}}
|
||||
{instructions}
|
||||
</instructions>
|
||||
"""
|
||||
|
||||
|
||||
@ -6,8 +6,8 @@ from tasks.clean_dataset_task import clean_dataset_task
|
||||
@dataset_was_deleted.connect
|
||||
def handle(sender: Dataset, **kwargs):
|
||||
dataset = sender
|
||||
assert dataset.doc_form
|
||||
assert dataset.indexing_technique
|
||||
if not dataset.doc_form or not dataset.indexing_technique:
|
||||
return
|
||||
clean_dataset_task.delay(
|
||||
dataset.id,
|
||||
dataset.tenant_id,
|
||||
|
||||
@ -8,6 +8,6 @@ def handle(sender, **kwargs):
|
||||
dataset_id = kwargs.get("dataset_id")
|
||||
doc_form = kwargs.get("doc_form")
|
||||
file_id = kwargs.get("file_id")
|
||||
assert dataset_id is not None
|
||||
assert doc_form is not None
|
||||
if not dataset_id or not doc_form:
|
||||
return
|
||||
clean_document_task.delay(document_id, dataset_id, doc_form, file_id)
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
from configs import dify_config
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN
|
||||
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN, HEADER_NAME_PASSPORT
|
||||
from dify_app import DifyApp
|
||||
|
||||
BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT)
|
||||
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
|
||||
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
# register blueprint routers
|
||||
@ -17,7 +22,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
CORS(
|
||||
service_api_bp,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE],
|
||||
allow_headers=list(SERVICE_API_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(service_api_bp)
|
||||
@ -26,7 +31,7 @@ def init_app(app: DifyApp):
|
||||
web_bp,
|
||||
resources={r"/*": {"origins": dify_config.WEB_API_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_APP_CODE, HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
@ -36,7 +41,7 @@ def init_app(app: DifyApp):
|
||||
console_app_bp,
|
||||
resources={r"/*": {"origins": dify_config.CONSOLE_CORS_ALLOW_ORIGINS}},
|
||||
supports_credentials=True,
|
||||
allow_headers=["Content-Type", "Authorization", HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(AUTHENTICATED_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
expose_headers=["X-Version", "X-Env"],
|
||||
)
|
||||
@ -44,7 +49,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
CORS(
|
||||
files_bp,
|
||||
allow_headers=["Content-Type", HEADER_NAME_CSRF_TOKEN],
|
||||
allow_headers=list(FILES_HEADERS),
|
||||
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
|
||||
)
|
||||
app.register_blueprint(files_bp)
|
||||
|
||||
@ -22,55 +22,6 @@ def upgrade():
|
||||
batch_op.add_column(sa.Column('app_mode', sa.String(length=255), nullable=True))
|
||||
batch_op.create_index('message_app_mode_idx', ['app_mode'], unique=False)
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
# Strategy: Update in batches to minimize lock time
|
||||
# For large tables (millions of rows), this prevents long-running transactions
|
||||
batch_size = 10000
|
||||
|
||||
print("Starting backfill of app_mode from conversations...")
|
||||
|
||||
# Use a more efficient UPDATE with JOIN
|
||||
# This query updates messages.app_mode from conversations.mode
|
||||
# Using string formatting for LIMIT since it's a constant
|
||||
update_query = f"""
|
||||
UPDATE messages m
|
||||
SET app_mode = c.mode
|
||||
FROM conversations c
|
||||
WHERE m.conversation_id = c.id
|
||||
AND m.app_mode IS NULL
|
||||
AND m.id IN (
|
||||
SELECT id FROM messages
|
||||
WHERE app_mode IS NULL
|
||||
LIMIT {batch_size}
|
||||
)
|
||||
"""
|
||||
|
||||
# Execute batched updates
|
||||
total_updated = 0
|
||||
iteration = 0
|
||||
while True:
|
||||
iteration += 1
|
||||
result = conn.execute(sa.text(update_query))
|
||||
|
||||
# Check if result is None or has no rowcount
|
||||
if result is None:
|
||||
print("Warning: Query returned None, stopping backfill")
|
||||
break
|
||||
|
||||
rows_updated = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
total_updated += rows_updated
|
||||
|
||||
if rows_updated == 0:
|
||||
break
|
||||
|
||||
print(f"Iteration {iteration}: Updated {rows_updated} messages (total: {total_updated})")
|
||||
|
||||
# For very large tables, add a small delay to reduce load
|
||||
# Uncomment if needed: import time; time.sleep(0.1)
|
||||
|
||||
print(f"Backfill completed. Total messages updated: {total_updated}")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
|
||||
@ -64,7 +64,7 @@ dependencies = [
|
||||
"pycryptodome==3.19.1",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.9.1",
|
||||
"pydantic-settings~=2.11.0",
|
||||
"pyjwt~=2.10.1",
|
||||
"pypdfium2==4.30.0",
|
||||
"python-docx~=1.1.0",
|
||||
|
||||
@ -288,9 +288,10 @@ class MessageService:
|
||||
)
|
||||
|
||||
with measure_time() as timer:
|
||||
questions: list[str] = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
|
||||
tenant_id=app_model.tenant_id, histories=histories
|
||||
)
|
||||
questions: list[str] = list(questions_sequence)
|
||||
|
||||
# get tracing instance
|
||||
trace_manager = TraceQueueManager(app_id=app_model.id)
|
||||
|
||||
@ -79,7 +79,7 @@ class VariableTruncator:
|
||||
self,
|
||||
string_length_limit=5000,
|
||||
array_element_limit: int = 20,
|
||||
max_size_bytes: int = 1024_000, # 100KB
|
||||
max_size_bytes: int = 1024_000, # 1000 KiB
|
||||
):
|
||||
if string_length_limit <= 3:
|
||||
raise ValueError("string_length_limit should be greater than 3.")
|
||||
|
||||
@ -0,0 +1,216 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.account import Account, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
|
||||
class DatasetDeleteTestDataFactory:
|
||||
"""Factory class for creating test data and mock objects for dataset delete tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_dataset_mock(
|
||||
dataset_id: str = "dataset-123",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
created_by: str = "creator-456",
|
||||
doc_form: str | None = None,
|
||||
indexing_technique: str | None = "high_quality",
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock dataset with specified attributes."""
|
||||
dataset = Mock(spec=Dataset)
|
||||
dataset.id = dataset_id
|
||||
dataset.tenant_id = tenant_id
|
||||
dataset.created_by = created_by
|
||||
dataset.doc_form = doc_form
|
||||
dataset.indexing_technique = indexing_technique
|
||||
for key, value in kwargs.items():
|
||||
setattr(dataset, key, value)
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def create_user_mock(
|
||||
user_id: str = "user-789",
|
||||
tenant_id: str = "test-tenant-123",
|
||||
role: TenantAccountRole = TenantAccountRole.ADMIN,
|
||||
**kwargs,
|
||||
) -> Mock:
|
||||
"""Create a mock user with specified attributes."""
|
||||
user = Mock(spec=Account)
|
||||
user.id = user_id
|
||||
user.current_tenant_id = tenant_id
|
||||
user.current_role = role
|
||||
for key, value in kwargs.items():
|
||||
setattr(user, key, value)
|
||||
return user
|
||||
|
||||
|
||||
class TestDatasetServiceDeleteDataset:
|
||||
"""
|
||||
Comprehensive unit tests for DatasetService.delete_dataset method.
|
||||
|
||||
This test suite covers all deletion scenarios including:
|
||||
- Normal dataset deletion with documents
|
||||
- Empty dataset deletion (no documents, doc_form is None)
|
||||
- Dataset deletion with missing indexing_technique
|
||||
- Permission checks
|
||||
- Event handling
|
||||
|
||||
This test suite provides regression protection for issue #27073.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dataset_service_dependencies(self):
|
||||
"""Common mock setup for dataset service dependencies."""
|
||||
with (
|
||||
patch("services.dataset_service.DatasetService.get_dataset") as mock_get_dataset,
|
||||
patch("services.dataset_service.DatasetService.check_dataset_permission") as mock_check_perm,
|
||||
patch("extensions.ext_database.db.session") as mock_db,
|
||||
patch("services.dataset_service.dataset_was_deleted") as mock_dataset_was_deleted,
|
||||
):
|
||||
yield {
|
||||
"get_dataset": mock_get_dataset,
|
||||
"check_permission": mock_check_perm,
|
||||
"db_session": mock_db,
|
||||
"dataset_was_deleted": mock_dataset_was_deleted,
|
||||
}
|
||||
|
||||
def test_delete_dataset_with_documents_success(self, mock_dataset_service_dependencies):
|
||||
"""
|
||||
Test successful deletion of a dataset with documents.
|
||||
|
||||
This test verifies:
|
||||
- Dataset is retrieved correctly
|
||||
- Permission check is performed
|
||||
- dataset_was_deleted event is sent
|
||||
- Dataset is deleted from database
|
||||
- Method returns True
|
||||
"""
|
||||
# Arrange
|
||||
dataset = DatasetDeleteTestDataFactory.create_dataset_mock(
|
||||
doc_form="text_model", indexing_technique="high_quality"
|
||||
)
|
||||
user = DatasetDeleteTestDataFactory.create_user_mock()
|
||||
|
||||
mock_dataset_service_dependencies["get_dataset"].return_value = dataset
|
||||
|
||||
# Act
|
||||
result = DatasetService.delete_dataset(dataset.id, user)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id)
|
||||
mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
|
||||
mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].commit.assert_called_once()
|
||||
|
||||
def test_delete_empty_dataset_success(self, mock_dataset_service_dependencies):
|
||||
"""
|
||||
Test successful deletion of an empty dataset (no documents, doc_form is None).
|
||||
|
||||
This test verifies that:
|
||||
- Empty datasets can be deleted without errors
|
||||
- dataset_was_deleted event is sent (event handler will skip cleanup if doc_form is None)
|
||||
- Dataset is deleted from database
|
||||
- Method returns True
|
||||
|
||||
This is the primary test for issue #27073 where deleting an empty dataset
|
||||
caused internal server error due to assertion failure in event handlers.
|
||||
"""
|
||||
# Arrange
|
||||
dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form=None, indexing_technique=None)
|
||||
user = DatasetDeleteTestDataFactory.create_user_mock()
|
||||
|
||||
mock_dataset_service_dependencies["get_dataset"].return_value = dataset
|
||||
|
||||
# Act
|
||||
result = DatasetService.delete_dataset(dataset.id, user)
|
||||
|
||||
# Assert - Verify complete deletion flow
|
||||
assert result is True
|
||||
mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id)
|
||||
mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
|
||||
mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].commit.assert_called_once()
|
||||
|
||||
def test_delete_dataset_with_partial_none_values(self, mock_dataset_service_dependencies):
|
||||
"""
|
||||
Test deletion of dataset with partial None values.
|
||||
|
||||
This test verifies that datasets with partial None values (e.g., doc_form exists
|
||||
but indexing_technique is None) can be deleted successfully. The event handler
|
||||
will skip cleanup if any required field is None.
|
||||
|
||||
Improvement based on Gemini Code Assist suggestion: Added comprehensive assertions
|
||||
to verify all core deletion operations are performed, not just event sending.
|
||||
"""
|
||||
# Arrange
|
||||
dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form="text_model", indexing_technique=None)
|
||||
user = DatasetDeleteTestDataFactory.create_user_mock()
|
||||
|
||||
mock_dataset_service_dependencies["get_dataset"].return_value = dataset
|
||||
|
||||
# Act
|
||||
result = DatasetService.delete_dataset(dataset.id, user)
|
||||
|
||||
# Assert - Verify complete deletion flow (Gemini suggestion implemented)
|
||||
assert result is True
|
||||
mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id)
|
||||
mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
|
||||
mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].commit.assert_called_once()
|
||||
|
||||
def test_delete_dataset_with_doc_form_none_indexing_technique_exists(self, mock_dataset_service_dependencies):
|
||||
"""
|
||||
Test deletion of dataset where doc_form is None but indexing_technique exists.
|
||||
|
||||
This edge case can occur in certain dataset configurations and should be handled
|
||||
gracefully by the event handler's conditional check.
|
||||
"""
|
||||
# Arrange
|
||||
dataset = DatasetDeleteTestDataFactory.create_dataset_mock(doc_form=None, indexing_technique="high_quality")
|
||||
user = DatasetDeleteTestDataFactory.create_user_mock()
|
||||
|
||||
mock_dataset_service_dependencies["get_dataset"].return_value = dataset
|
||||
|
||||
# Act
|
||||
result = DatasetService.delete_dataset(dataset.id, user)
|
||||
|
||||
# Assert - Verify complete deletion flow
|
||||
assert result is True
|
||||
mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset.id)
|
||||
mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
|
||||
mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].delete.assert_called_once_with(dataset)
|
||||
mock_dataset_service_dependencies["db_session"].commit.assert_called_once()
|
||||
|
||||
def test_delete_dataset_not_found(self, mock_dataset_service_dependencies):
|
||||
"""
|
||||
Test deletion attempt when dataset doesn't exist.
|
||||
|
||||
This test verifies that:
|
||||
- Method returns False when dataset is not found
|
||||
- No deletion operations are performed
|
||||
- No events are sent
|
||||
"""
|
||||
# Arrange
|
||||
dataset_id = "non-existent-dataset"
|
||||
user = DatasetDeleteTestDataFactory.create_user_mock()
|
||||
|
||||
mock_dataset_service_dependencies["get_dataset"].return_value = None
|
||||
|
||||
# Act
|
||||
result = DatasetService.delete_dataset(dataset_id, user)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
mock_dataset_service_dependencies["get_dataset"].assert_called_once_with(dataset_id)
|
||||
mock_dataset_service_dependencies["check_permission"].assert_not_called()
|
||||
mock_dataset_service_dependencies["dataset_was_deleted"].send.assert_not_called()
|
||||
mock_dataset_service_dependencies["db_session"].delete.assert_not_called()
|
||||
mock_dataset_service_dependencies["db_session"].commit.assert_not_called()
|
||||
8
api/uv.lock
generated
8
api/uv.lock
generated
@ -1557,7 +1557,7 @@ requires-dist = [
|
||||
{ name = "pycryptodome", specifier = "==3.19.1" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.9.1" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.11.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.10.1" },
|
||||
{ name = "pypdfium2", specifier = "==4.30.0" },
|
||||
{ name = "python-docx", specifier = "~=1.1.0" },
|
||||
@ -4779,16 +4779,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.9.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
4
web/.storybook/__mocks__/context-block.tsx
Normal file
4
web/.storybook/__mocks__/context-block.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
// Mock for context-block plugin to avoid circular dependency in Storybook
|
||||
export const ContextBlockNode = null
|
||||
export const ContextBlockReplacementBlock = null
|
||||
export default null
|
||||
4
web/.storybook/__mocks__/history-block.tsx
Normal file
4
web/.storybook/__mocks__/history-block.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
// Mock for history-block plugin to avoid circular dependency in Storybook
|
||||
export const HistoryBlockNode = null
|
||||
export const HistoryBlockReplacementBlock = null
|
||||
export default null
|
||||
4
web/.storybook/__mocks__/query-block.tsx
Normal file
4
web/.storybook/__mocks__/query-block.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
// Mock for query-block plugin to avoid circular dependency in Storybook
|
||||
export const QueryBlockNode = null
|
||||
export const QueryBlockReplacementBlock = null
|
||||
export default null
|
||||
@ -1,19 +1,46 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const config: StorybookConfig = {
|
||||
// stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-docs',
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/nextjs',
|
||||
options: {},
|
||||
options: {
|
||||
builder: {
|
||||
useSWC: true,
|
||||
lazyCompilation: false,
|
||||
},
|
||||
nextConfigPath: undefined,
|
||||
},
|
||||
},
|
||||
staticDirs: ['../public'],
|
||||
core: {
|
||||
disableWhatsNewNotifications: true,
|
||||
},
|
||||
docs: {
|
||||
defaultName: 'Documentation',
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
// Add alias to mock problematic modules with circular dependencies
|
||||
config.resolve = config.resolve || {}
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
// Mock the plugin index files to avoid circular dependencies
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'),
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'),
|
||||
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'),
|
||||
}
|
||||
return config
|
||||
},
|
||||
}
|
||||
export default config
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
import React from 'react'
|
||||
import type { Preview } from '@storybook/react'
|
||||
import { withThemeByDataAttribute } from '@storybook/addon-themes'
|
||||
import I18nServer from '../app/components/i18n-server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import I18N from '../app/components/i18n'
|
||||
import { ToastProvider } from '../app/components/base/toast'
|
||||
|
||||
import '../app/styles/globals.css'
|
||||
import '../app/styles/markdown.scss'
|
||||
import './storybook.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const decorators = [
|
||||
withThemeByDataAttribute({
|
||||
themes: {
|
||||
@ -17,9 +26,15 @@ export const decorators = [
|
||||
attributeName: 'data-theme',
|
||||
}),
|
||||
(Story) => {
|
||||
return <I18nServer>
|
||||
<Story />
|
||||
</I18nServer>
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18N locale="en-US">
|
||||
<ToastProvider>
|
||||
<Story />
|
||||
</ToastProvider>
|
||||
</I18N>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
@ -31,7 +46,11 @@ const preview: Preview = {
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
toc: true,
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default preview
|
||||
|
||||
@ -160,8 +160,7 @@ describe('Navigation Utilities', () => {
|
||||
page: 1,
|
||||
limit: '',
|
||||
keyword: 'test',
|
||||
empty: null,
|
||||
undefined,
|
||||
filter: '',
|
||||
})
|
||||
|
||||
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
|
||||
|
||||
@ -39,28 +39,38 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
|
||||
const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
|
||||
const matches = isDarkQuery ? systemPrefersDark : false
|
||||
|
||||
const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
|
||||
const handleAddEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.add(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleRemoveEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.delete(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleDispatchEvent = (event: Event) => {
|
||||
listeners.forEach(listener => listener(event as MediaQueryListEvent))
|
||||
return true
|
||||
}
|
||||
|
||||
const mediaQueryList: MediaQueryList = {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: (listener: MediaQueryListListener) => {
|
||||
listeners.add(listener)
|
||||
},
|
||||
removeListener: (listener: MediaQueryListListener) => {
|
||||
listeners.delete(listener)
|
||||
},
|
||||
addEventListener: (_event, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.add(listener as MediaQueryListListener)
|
||||
},
|
||||
removeEventListener: (_event, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.delete(listener as MediaQueryListListener)
|
||||
},
|
||||
dispatchEvent: (event: Event) => {
|
||||
listeners.forEach(listener => listener(event as MediaQueryListEvent))
|
||||
return true
|
||||
},
|
||||
addListener: handleAddListener,
|
||||
removeListener: handleRemoveListener,
|
||||
addEventListener: handleAddEventListener,
|
||||
removeEventListener: handleRemoveEventListener,
|
||||
dispatchEvent: handleDispatchEvent,
|
||||
}
|
||||
|
||||
return mediaQueryList
|
||||
@ -69,6 +79,121 @@ const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = fa
|
||||
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
|
||||
}
|
||||
|
||||
// Helper function to create timing page component
|
||||
const createTimingPageComponent = (
|
||||
timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>,
|
||||
) => {
|
||||
const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => {
|
||||
timingData.push({
|
||||
phase,
|
||||
timestamp: performance.now(),
|
||||
styles,
|
||||
})
|
||||
}
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
recordTiming(mounted ? 'CSR' : 'Initial', currentStyles)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return TimingPageComponent
|
||||
}
|
||||
|
||||
// Helper function to create CSS test component
|
||||
const createCSSTestComponent = (
|
||||
cssStates: Array<{ className: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordCSSState = (className: string) => {
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
recordCSSState(className)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return CSSTestComponent
|
||||
}
|
||||
|
||||
// Helper function to create performance test component
|
||||
const createPerformanceTestComponent = (
|
||||
performanceMarks: Array<{ event: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordPerformanceMark = (event: string) => {
|
||||
performanceMarks.push({ event, timestamp: performance.now() })
|
||||
}
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
recordPerformanceMark('component-render')
|
||||
|
||||
useEffect(() => {
|
||||
recordPerformanceMark('mount-start')
|
||||
setMounted(true)
|
||||
recordPerformanceMark('mount-complete')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
recordPerformanceMark('theme-available')
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return PerformanceTestComponent
|
||||
}
|
||||
|
||||
// Simulate real page component based on Dify's actual theme usage
|
||||
const PageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@ -227,39 +352,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Record timing and styles for each render phase
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
timingData.push({
|
||||
phase: mounted ? 'CSR' : 'Initial',
|
||||
timestamp: performance.now(),
|
||||
styles: currentStyles,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const TimingPageComponent = createTimingPageComponent(timingData)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
@ -295,33 +388,7 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const cssStates: Array<{ className: string; timestamp: number }> = []
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
// Simulate Tailwind CSS class application
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const CSSTestComponent = createCSSTestComponent(cssStates)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
@ -413,34 +480,12 @@ describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
|
||||
const performanceMarks: Array<{ event: string; timestamp: number }> = []
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
performanceMarks.push({ event: 'component-render', timestamp: performance.now() })
|
||||
|
||||
useEffect(() => {
|
||||
performanceMarks.push({ event: 'mount-start', timestamp: performance.now() })
|
||||
setMounted(true)
|
||||
performanceMarks.push({ event: 'mount-complete', timestamp: performance.now() })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
performanceMarks.push({ event: 'theme-available', timestamp: performance.now() })
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
expect(window.localStorage.getItem('theme')).toBe('dark')
|
||||
|
||||
const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PerformanceTestComponent />
|
||||
|
||||
@ -70,14 +70,18 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
})
|
||||
|
||||
describe('Fallback Logic (from layout-main.tsx)', () => {
|
||||
type Tag = { id: string; name: string }
|
||||
type AppDetail = { tags: Tag[] }
|
||||
type FallbackResult = { tags?: Tag[] } | null
|
||||
// no-op
|
||||
it('should trigger fallback when tags are missing or empty', () => {
|
||||
const appDetailWithoutTags = { tags: [] }
|
||||
const appDetailWithTags = { tags: [{ id: 'tag1' }] }
|
||||
const appDetailWithUndefinedTags = { tags: undefined as any }
|
||||
const appDetailWithoutTags: AppDetail = { tags: [] }
|
||||
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
|
||||
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
|
||||
|
||||
// This simulates the condition in layout-main.tsx
|
||||
const shouldFallback1 = !appDetailWithoutTags.tags || appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = !appDetailWithTags.tags || appDetailWithTags.tags.length === 0
|
||||
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = appDetailWithTags.tags.length === 0
|
||||
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
|
||||
|
||||
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
|
||||
@ -86,24 +90,26 @@ describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
})
|
||||
|
||||
it('should preserve tags when fallback succeeds', () => {
|
||||
const originalAppDetail = { tags: [] as any[] }
|
||||
const fallbackResult = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
|
||||
// This simulates the successful fallback in layout-main.tsx
|
||||
if (fallbackResult?.tags)
|
||||
originalAppDetail.tags = fallbackResult.tags
|
||||
const tags = fallbackResult.tags
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
|
||||
expect(originalAppDetail.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should continue with empty tags when fallback fails', () => {
|
||||
const originalAppDetail: { tags: any[] } = { tags: [] }
|
||||
const fallbackResult: { tags?: any[] } | null = null
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult = null as FallbackResult
|
||||
|
||||
// This simulates fallback failure in layout-main.tsx
|
||||
if (fallbackResult?.tags)
|
||||
originalAppDetail.tags = fallbackResult.tags
|
||||
const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual([])
|
||||
})
|
||||
|
||||
@ -73,7 +73,7 @@ const ConfigPopup: FC<PopupProps> = ({
|
||||
}
|
||||
}, [onChooseProvider])
|
||||
|
||||
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => {
|
||||
const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig) => {
|
||||
onConfigUpdated(currentProvider!, payload)
|
||||
hideConfigModal()
|
||||
}, [currentProvider, hideConfigModal, onConfigUpdated])
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
|
||||
@ -5,7 +5,7 @@ import copy from 'copy-to-clipboard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiErrorWarningFill,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ConfirmAddVar from './confirm-add-var'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
|
||||
@ -3,7 +3,7 @@ import type { ChangeEvent, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import Panel from '../base/feature-panel'
|
||||
import EditModal from './config-modal'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ParamConfig from './param-config'
|
||||
import { Vision } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import ParamItem from '@/app/components/base/param-item'
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
||||
import { Microphone01 } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
||||
import { Document } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useFormattingChangedDispatcher } from '../debug/hooks'
|
||||
import DatasetConfig from '../dataset-config'
|
||||
import HistoryPanel from '../config-prompt/conversation-history/history-panel'
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { intersectionBy } from 'lodash-es'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { useFormattingChangedDispatcher } from '../debug/hooks'
|
||||
import FeaturePanel from '../base/feature-panel'
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import produce, { setAutoFreeze } from 'immer'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
RiAddLine,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { clone } from 'lodash-es'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
|
||||
@ -6,7 +6,7 @@ import { basePath } from '@/utils/var'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone, isEqual } from 'lodash-es'
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
262
web/app/components/base/action-button/index.stories.tsx
Normal file
262
web/app/components/base/action-button/index.stories.tsx
Normal file
@ -0,0 +1,262 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
|
||||
import ActionButton, { ActionButtonState } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/ActionButton',
|
||||
component: ActionButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'm', 'l', 'xl'],
|
||||
description: 'Button size',
|
||||
},
|
||||
state: {
|
||||
control: 'select',
|
||||
options: [
|
||||
ActionButtonState.Default,
|
||||
ActionButtonState.Active,
|
||||
ActionButtonState.Disabled,
|
||||
ActionButtonState.Destructive,
|
||||
ActionButtonState.Hover,
|
||||
],
|
||||
description: 'Button state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Button content',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Native disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ActionButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// With text
|
||||
export const WithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: 'Edit',
|
||||
},
|
||||
}
|
||||
|
||||
// Icon with text
|
||||
export const IconWithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: (
|
||||
<>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
Add Item
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
export const ExtraSmall: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3 w-3" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3.5 w-3.5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'l',
|
||||
children: <RiEditLine className="h-5 w-5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
size: 'xl',
|
||||
children: <RiEditLine className="h-6 w-6" />,
|
||||
},
|
||||
}
|
||||
|
||||
// State variations
|
||||
export const ActiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Active,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Disabled,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DestructiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Destructive,
|
||||
children: <RiDeleteBinLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const HoverState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Hover,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world examples
|
||||
export const ToolbarActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiShareLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiSaveLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary">Item name</span>
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<ActionButton size="xs">
|
||||
<RiMore2Fill className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3 w-3" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XS</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">S</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">M</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="l">
|
||||
<RiEditLine className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">L</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xl">
|
||||
<RiEditLine className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XL</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Default}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Default</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Active}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Active</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Hover}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Hover</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Disabled}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Destructive</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Default,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
204
web/app/components/base/auto-height-textarea/index.stories.tsx
Normal file
204
web/app/components/base/auto-height-textarea/index.stories.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AutoHeightTextarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/AutoHeightTextarea',
|
||||
component: AutoHeightTextarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
minHeight: {
|
||||
control: 'number',
|
||||
description: 'Minimum height in pixels',
|
||||
},
|
||||
maxHeight: {
|
||||
control: 'number',
|
||||
description: 'Maximum height in pixels',
|
||||
},
|
||||
autoFocus: {
|
||||
control: 'boolean',
|
||||
description: 'Auto focus on mount',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
wrapperClassName: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AutoHeightTextarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const AutoHeightTextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<AutoHeightTextarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Text changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'This is a pre-filled textarea with some initial content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With multiline content
|
||||
export const MultilineContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Custom min height
|
||||
export const CustomMinHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Taller minimum height...',
|
||||
value: '',
|
||||
minHeight: 100,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Small max height (scrollable)
|
||||
export const SmallMaxHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type multiple lines...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.',
|
||||
minHeight: 36,
|
||||
maxHeight: 80,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Auto focus enabled
|
||||
export const AutoFocus: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'This textarea auto-focuses on mount',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: true,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Custom styled textarea...',
|
||||
value: '',
|
||||
minHeight: 50,
|
||||
maxHeight: 150,
|
||||
className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600',
|
||||
wrapperClassName: 'shadow-lg',
|
||||
},
|
||||
}
|
||||
|
||||
// Long content example
|
||||
export const LongContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||
minHeight: 36,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Chat input
|
||||
export const ChatInput: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type your message...',
|
||||
value: '',
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Comment box
|
||||
export const CommentBox: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Write a comment...',
|
||||
value: '',
|
||||
minHeight: 60,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: false,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
wrapperClassName: '',
|
||||
},
|
||||
}
|
||||
@ -31,7 +31,7 @@ const AutoHeightTextarea = (
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
}: IProps & {
|
||||
ref: React.RefObject<unknown>;
|
||||
ref?: React.RefObject<HTMLTextAreaElement>;
|
||||
},
|
||||
) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
|
||||
191
web/app/components/base/block-input/index.stories.tsx
Normal file
191
web/app/components/base/block-input/index.stories.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import BlockInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/BlockInput',
|
||||
component: BlockInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Input value (supports {{variable}} syntax)',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
highLightClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for highlighted variables (default: text-blue-500)',
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean',
|
||||
description: 'Read-only mode',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BlockInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const BlockInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
const [keys, setKeys] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<BlockInput
|
||||
{...args}
|
||||
value={value}
|
||||
onConfirm={(newValue, extractedKeys) => {
|
||||
setValue(newValue)
|
||||
setKeys(extractedKeys)
|
||||
console.log('Value confirmed:', newValue)
|
||||
console.log('Extracted keys:', extractedKeys)
|
||||
}}
|
||||
/>
|
||||
{keys.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keys.map(key => (
|
||||
<span key={key} className="rounded bg-blue-500 px-2 py-1 text-xs text-white">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With single variable
|
||||
export const SingleVariable: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello {{name}}, welcome to the application!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With multiple variables
|
||||
export const MultipleVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Complex template
|
||||
export const ComplexTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
|
||||
readonly: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Empty state
|
||||
export const EmptyState: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content
|
||||
export const LongContent: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Variables with underscores
|
||||
export const VariablesWithUnderscores: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Adjacent variables
|
||||
export const AdjacentVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
export const EmailTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Notification template
|
||||
export const NotificationTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This template uses {{custom_variable}} with custom styling.',
|
||||
readonly: false,
|
||||
className: 'bg-gray-50 border-2 border-blue-200',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Try editing this text and adding variables like {{example}}',
|
||||
readonly: false,
|
||||
className: '',
|
||||
highLightClassName: '',
|
||||
},
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { fn } from '@storybook/test'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
|
||||
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '.'
|
||||
@ -20,8 +19,7 @@ const meta = {
|
||||
},
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
onClick: fn(),
|
||||
children: 'adsf',
|
||||
children: 'Button',
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
@ -33,6 +31,9 @@ export const Default: Story = {
|
||||
variant: 'primary',
|
||||
loading: false,
|
||||
children: 'Primary Button',
|
||||
styleCss: {},
|
||||
spinnerClassName: '',
|
||||
destructive: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { mockedWorkflowProcess } from './__mocks__/workflowProcess'
|
||||
import { markdownContent } from './__mocks__/markdownContent'
|
||||
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
|
||||
import Answer from '.'
|
||||
@ -34,6 +32,11 @@ const mockedBaseChatItem = {
|
||||
content: 'Hello, how can I assist you today?',
|
||||
} satisfies ChatItem
|
||||
|
||||
const mockedWorkflowProcess = {
|
||||
status: 'succeeded',
|
||||
tracing: [],
|
||||
}
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
item: mockedBaseChatItem,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
|
||||
import type { ChatItem } from '../types'
|
||||
import Question from './question'
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
|
||||
394
web/app/components/base/checkbox/index.stories.tsx
Normal file
394
web/app/components/base/checkbox/index.stories.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '.'
|
||||
|
||||
// Helper function for toggling items in an array
|
||||
const createToggleItem = <T extends { id: string; checked: boolean }>(
|
||||
items: T[],
|
||||
setItems: (items: T[]) => void,
|
||||
) => (id: string) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, checked: !item.checked } as T : item,
|
||||
))
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Checkbox',
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state',
|
||||
},
|
||||
indeterminate: {
|
||||
control: 'boolean',
|
||||
description: 'Indeterminate state (partially checked)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
id: {
|
||||
control: 'text',
|
||||
description: 'HTML id attribute',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const CheckboxDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
{...args}
|
||||
checked={checked}
|
||||
onCheck={() => {
|
||||
if (!args.disabled) {
|
||||
setChecked(!checked)
|
||||
console.log('Checkbox toggled:', !checked)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{checked ? 'Checked' : 'Unchecked'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default unchecked
|
||||
export const Default: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Indeterminate state
|
||||
export const Indeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled unchecked
|
||||
export const DisabledUnchecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
indeterminate: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled indeterminate
|
||||
export const DisabledIndeterminate: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
indeterminate: true,
|
||||
},
|
||||
}
|
||||
|
||||
// State comparison
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Unchecked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Checked</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Checkbox checked={false} indeterminate={true} disabled={true} onCheck={() => undefined} />
|
||||
<span className="text-xs text-gray-600">Disabled Indeterminate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Enable notifications', checked: true },
|
||||
{ id: '2', label: 'Enable email updates', checked: false },
|
||||
{ id: '3', label: 'Enable SMS alerts', checked: false },
|
||||
])
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// Select all example
|
||||
const SelectAllExampleDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', label: 'Item 1', checked: false },
|
||||
{ id: '2', label: 'Item 2', checked: false },
|
||||
{ id: '3', label: 'Item 3', checked: false },
|
||||
])
|
||||
|
||||
const allChecked = items.every(item => item.checked)
|
||||
const someChecked = items.some(item => item.checked)
|
||||
const indeterminate = someChecked && !allChecked
|
||||
|
||||
const toggleAll = () => {
|
||||
const newChecked = !allChecked
|
||||
setItems(items.map(item => ({ ...item, checked: newChecked })))
|
||||
}
|
||||
|
||||
const toggleItem = createToggleItem(items, setItems)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
onCheck={toggleAll}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Select All</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pl-7">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.checked}
|
||||
onCheck={() => toggleItem(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="cursor-pointer text-sm text-gray-600"
|
||||
onClick={() => toggleItem(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SelectAllExample: Story = {
|
||||
render: () => <SelectAllExampleDemo />,
|
||||
}
|
||||
|
||||
// Form example
|
||||
const FormExampleDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
terms: false,
|
||||
newsletter: false,
|
||||
privacy: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Account Settings</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={formData.terms}
|
||||
onCheck={() => setFormData({ ...formData, terms: !formData.terms })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="terms" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I agree to the terms and conditions
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="newsletter"
|
||||
checked={formData.newsletter}
|
||||
onCheck={() => setFormData({ ...formData, newsletter: !formData.newsletter })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="newsletter" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
Subscribe to newsletter
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Get updates about new features
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="privacy"
|
||||
checked={formData.privacy}
|
||||
onCheck={() => setFormData({ ...formData, privacy: !formData.privacy })}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="privacy" className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
I have read the privacy policy
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Required to continue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormExample: Story = {
|
||||
render: () => <FormExampleDemo />,
|
||||
}
|
||||
|
||||
// Task list example
|
||||
const TaskListExampleDemo = () => {
|
||||
const [tasks, setTasks] = useState([
|
||||
{ id: '1', title: 'Review pull request', completed: true },
|
||||
{ id: '2', title: 'Update documentation', completed: true },
|
||||
{ id: '3', title: 'Fix navigation bug', completed: false },
|
||||
{ id: '4', title: 'Deploy to staging', completed: false },
|
||||
])
|
||||
|
||||
const toggleTask = (id: string) => {
|
||||
setTasks(tasks.map(task =>
|
||||
task.id === id ? { ...task, completed: !task.completed } : task,
|
||||
))
|
||||
}
|
||||
|
||||
const completedCount = tasks.filter(t => t.completed).length
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Today's Tasks</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{completedCount} of {tasks.length} completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tasks.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded p-2 hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={task.id}
|
||||
checked={task.completed}
|
||||
onCheck={() => toggleTask(task.id)}
|
||||
/>
|
||||
<span
|
||||
className={`cursor-pointer text-sm ${
|
||||
task.completed ? 'text-gray-400 line-through' : 'text-gray-700'
|
||||
}`}
|
||||
onClick={() => toggleTask(task.id)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskListExample: Story = {
|
||||
render: () => <TaskListExampleDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <CheckboxDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
indeterminate: false,
|
||||
disabled: false,
|
||||
id: 'playground-checkbox',
|
||||
},
|
||||
}
|
||||
199
web/app/components/base/confirm/index.stories.tsx
Normal file
199
web/app/components/base/confirm/index.stories.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Confirm from '.'
|
||||
import Button from '../button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Confirm',
|
||||
component: Confirm,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Confirmation dialog component that supports warning and info types, with customizable button text and behavior.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['info', 'warning'],
|
||||
description: 'Dialog type',
|
||||
},
|
||||
isShow: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the dialog',
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Dialog title',
|
||||
},
|
||||
content: {
|
||||
control: 'text',
|
||||
description: 'Dialog content',
|
||||
},
|
||||
confirmText: {
|
||||
control: 'text',
|
||||
description: 'Confirm button text',
|
||||
},
|
||||
cancelText: {
|
||||
control: 'text',
|
||||
description: 'Cancel button text',
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
description: 'Confirm button loading state',
|
||||
},
|
||||
isDisabled: {
|
||||
control: 'boolean',
|
||||
description: 'Confirm button disabled state',
|
||||
},
|
||||
showConfirm: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show confirm button',
|
||||
},
|
||||
showCancel: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show cancel button',
|
||||
},
|
||||
maskClosable: {
|
||||
control: 'boolean',
|
||||
description: 'Whether clicking mask closes dialog',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Confirm>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const ConfirmDemo = (args: any) => {
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button variant="primary" onClick={() => setIsShow(true)}>
|
||||
Open Dialog
|
||||
</Button>
|
||||
<Confirm
|
||||
{...args}
|
||||
isShow={isShow}
|
||||
onConfirm={() => {
|
||||
console.log('✅ User clicked confirm')
|
||||
setIsShow(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
console.log('❌ User clicked cancel')
|
||||
setIsShow(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Basic warning dialog - Delete action
|
||||
export const WarningDialog: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'Delete Confirmation',
|
||||
content: 'Are you sure you want to delete this project? This action cannot be undone.',
|
||||
},
|
||||
}
|
||||
|
||||
// Info dialog
|
||||
export const InfoDialog: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'info',
|
||||
title: 'Notice',
|
||||
content: 'Your changes have been saved. Do you want to proceed to the next step?',
|
||||
},
|
||||
}
|
||||
|
||||
// Custom button text
|
||||
export const CustomButtonText: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'Exit Editor',
|
||||
content: 'You have unsaved changes. Are you sure you want to exit?',
|
||||
confirmText: 'Discard Changes',
|
||||
cancelText: 'Continue Editing',
|
||||
},
|
||||
}
|
||||
|
||||
// Loading state
|
||||
export const LoadingState: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'Deleting...',
|
||||
content: 'Please wait while we delete the file...',
|
||||
isLoading: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const DisabledState: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'info',
|
||||
title: 'Verification Required',
|
||||
content: 'Please complete email verification before proceeding.',
|
||||
isDisabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Alert style - Confirm button only
|
||||
export const AlertStyle: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'info',
|
||||
title: 'Success',
|
||||
content: 'Your settings have been updated!',
|
||||
showCancel: false,
|
||||
confirmText: 'Got it',
|
||||
},
|
||||
}
|
||||
|
||||
// Dangerous action - Long content
|
||||
export const DangerousAction: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'Permanently Delete Account',
|
||||
content: 'This action will permanently delete your account and all associated data, including: all projects and files, collaboration history, and personal settings. This action cannot be reversed!',
|
||||
confirmText: 'Delete My Account',
|
||||
cancelText: 'Keep My Account',
|
||||
},
|
||||
}
|
||||
|
||||
// Non-closable mask
|
||||
export const NotMaskClosable: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'Important Action',
|
||||
content: 'This action requires your explicit choice. Clicking outside will not close this dialog.',
|
||||
maskClosable: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Full feature demo - Playground
|
||||
export const Playground: Story = {
|
||||
render: args => <ConfirmDemo {...args} />,
|
||||
args: {
|
||||
type: 'warning',
|
||||
title: 'This is a title',
|
||||
content: 'This is the dialog content text...',
|
||||
confirmText: undefined,
|
||||
cancelText: undefined,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
showConfirm: true,
|
||||
showCancel: true,
|
||||
maskClosable: true,
|
||||
},
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiEqualizer2Line, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { queryAnnotationJobStatus, updateAnnotationStatus } from '@/service/annotation'
|
||||
import type { EmbeddingModelConfig } from '@/app/components/app/annotation/type'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { Citations } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { LoveMessage } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { FolderUpload } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { VirtualAssistant } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiEqualizer2Line, RiImage2Fill } from '@remixicon/react'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import SettingModal from '@/app/components/base/features/new-feature-panel/file-upload/setting-modal'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { ContentModeration } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiSparklingFill } from '@remixicon/react'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { Microphone01 } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { TextToAudio } from '@/app/components/base/icons/src/vender/features'
|
||||
import FeatureCard from '@/app/components/base/features/new-feature-panel/feature-card'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import useSWR from 'swr'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import React, { Fragment } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FileEntity } from './types'
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="route" clip-path="url(#clip0_3167_28693)">
|
||||
<path id="Icon" d="M6.70866 2.91699H6.96206C8.73962 2.91699 9.6284 2.91699 9.96578 3.23624C10.2574 3.51221 10.3867 3.91874 10.3079 4.31245C10.2168 4.76792 9.49122 5.28116 8.03999 6.30763L5.66899 7.98468C4.21777 9.01116 3.49215 9.5244 3.40106 9.97987C3.32233 10.3736 3.45157 10.7801 3.7432 11.0561C4.08059 11.3753 4.96937 11.3753 6.74693 11.3753H7.29199M4.66699 2.91699C4.66699 3.88349 3.88349 4.66699 2.91699 4.66699C1.95049 4.66699 1.16699 3.88349 1.16699 2.91699C1.16699 1.95049 1.95049 1.16699 2.91699 1.16699C3.88349 1.16699 4.66699 1.95049 4.66699 2.91699ZM12.8337 11.0837C12.8337 12.0502 12.0502 12.8337 11.0837 12.8337C10.1172 12.8337 9.33366 12.0502 9.33366 11.0837C9.33366 10.1172 10.1172 9.33366 11.0837 9.33366C12.0502 9.33366 12.8337 10.1172 12.8337 11.0837Z" stroke="#344054" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3167_28693">
|
||||
<rect width="14" height="14" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,8 +0,0 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<g id="Solid">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.39498 2.71706C6.90587 2.57557 7.44415 2.49996 8.00008 2.49996C9.30806 2.49996 10.5183 2.91849 11.5041 3.62893C10.9796 3.97562 10.5883 4.35208 10.3171 4.75458C9.90275 5.36959 9.79654 6.00558 9.88236 6.58587C9.96571 7.1494 10.2245 7.63066 10.4965 7.98669C10.7602 8.33189 11.0838 8.6206 11.3688 8.76305C12.0863 9.12177 12.9143 9.30141 13.5334 9.39399C14.0933 9.47774 15.2805 9.75802 15.3244 8.86608C15.3304 8.74474 15.3334 8.62267 15.3334 8.49996C15.3334 4.44987 12.0502 1.16663 8.00008 1.16663C3.94999 1.16663 0.666748 4.44987 0.666748 8.49996C0.666748 12.55 3.94999 15.8333 8.00008 15.8333C8.1228 15.8333 8.24486 15.8303 8.3662 15.8243C8.73395 15.8062 9.01738 15.4934 8.99927 15.1256C8.98117 14.7579 8.66837 14.4745 8.30063 14.4926C8.20111 14.4975 8.10091 14.5 8.00008 14.5C5.6605 14.5 3.63367 13.1609 2.6442 11.2074L3.28991 10.8346L5.67171 11.2804C6.28881 11.3959 6.85846 10.9208 6.85566 10.293L6.84632 8.19093L8.06357 6.10697C8.26079 5.76932 8.24312 5.3477 8.01833 5.02774L6.39498 2.71706Z" fill="#1570EF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29718 8.93736C9.05189 8.84432 8.77484 8.90379 8.58934 9.08929C8.40383 9.27479 8.34437 9.55184 8.43741 9.79713L10.5486 15.363C10.6461 15.6199 10.8912 15.7908 11.166 15.7932C11.4408 15.7956 11.689 15.6292 11.791 15.374L12.6714 13.1714L14.874 12.2909C15.1292 12.1889 15.2957 11.9408 15.2932 11.666C15.2908 11.3912 15.12 11.146 14.863 11.0486L9.29718 8.93736Z" fill="#1570EF"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,7 +0,0 @@
|
||||
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="route-sep">
|
||||
<path id="Icon" d="M6.08303 2.5H6.30023C7.82386 2.5 8.58567 2.5 8.87485 2.77364C9.12483 3.01018 9.23561 3.35864 9.16812 3.69611C9.09004 4.08651 8.46809 4.52643 7.22418 5.40627L5.19189 6.84373C3.94799 7.72357 3.32603 8.16349 3.24795 8.55389C3.18046 8.89136 3.29124 9.23982 3.54122 9.47636C3.8304 9.75 4.59221 9.75 6.11584 9.75H6.58303" stroke="#F79009" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Icon_2" d="M2.83301 4C3.66143 4 4.33301 3.32843 4.33301 2.5C4.33301 1.67157 3.66143 1 2.83301 1C2.00458 1 1.33301 1.67157 1.33301 2.5C1.33301 3.32843 2.00458 4 2.83301 4Z" fill="#F79009"/>
|
||||
<path id="Icon_3" d="M9.83301 11C10.6614 11 11.333 10.3284 11.333 9.5C11.333 8.67157 10.6614 8 9.83301 8C9.00458 8 8.33301 8.67157 8.33301 9.5C8.33301 10.3284 9.00458 11 9.83301 11Z" fill="#F79009"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 927 B |
@ -1,39 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "14",
|
||||
"height": "14",
|
||||
"viewBox": "0 0 14 14",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "ai-text"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Vector",
|
||||
"d": "M2.33301 10.5H4.08301M2.33301 7H5.24967M2.33301 3.5H11.6663M9.91634 5.83333L10.7913 7.875L12.833 8.75L10.7913 9.625L9.91634 11.6667L9.04134 9.625L6.99967 8.75L9.04134 7.875L9.91634 5.83333Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.25",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "AiText"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './AiText.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'AiText'
|
||||
|
||||
export default Icon
|
||||
@ -1,4 +1,3 @@
|
||||
export { default as AiText } from './AiText'
|
||||
export { default as ChatBotSlim } from './ChatBotSlim'
|
||||
export { default as ChatBot } from './ChatBot'
|
||||
export { default as CuteRobot } from './CuteRobot'
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "14",
|
||||
"height": "14",
|
||||
"viewBox": "0 0 14 14",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "route",
|
||||
"clip-path": "url(#clip0_3167_28693)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon",
|
||||
"d": "M6.70866 2.91699H6.96206C8.73962 2.91699 9.6284 2.91699 9.96578 3.23624C10.2574 3.51221 10.3867 3.91874 10.3079 4.31245C10.2168 4.76792 9.49122 5.28116 8.03999 6.30763L5.66899 7.98468C4.21777 9.01116 3.49215 9.5244 3.40106 9.97987C3.32233 10.3736 3.45157 10.7801 3.7432 11.0561C4.08059 11.3753 4.96937 11.3753 6.74693 11.3753H7.29199M4.66699 2.91699C4.66699 3.88349 3.88349 4.66699 2.91699 4.66699C1.95049 4.66699 1.16699 3.88349 1.16699 2.91699C1.16699 1.95049 1.95049 1.16699 2.91699 1.16699C3.88349 1.16699 4.66699 1.95049 4.66699 2.91699ZM12.8337 11.0837C12.8337 12.0502 12.0502 12.8337 11.0837 12.8337C10.1172 12.8337 9.33366 12.0502 9.33366 11.0837C9.33366 10.1172 10.1172 9.33366 11.0837 9.33366C12.0502 9.33366 12.8337 10.1172 12.8337 11.0837Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.25",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_3167_28693"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "14",
|
||||
"height": "14",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Route"
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "24",
|
||||
"height": "24",
|
||||
"viewBox": "0 0 24 24",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4 5C3.44772 5 3 5.44772 3 6C3 6.55228 3.44772 7 4 7H20C20.5523 7 21 6.55228 21 6C21 5.44772 20.5523 5 20 5H4Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M17.9191 9.60608C17.7616 9.2384 17.4 9 17 9C16.6 9 16.2384 9.2384 16.0809 9.60608L14.7384 12.7384L11.6061 14.0809C11.2384 14.2384 11 14.6 11 15C11 15.4 11.2384 15.7616 11.6061 15.9191L14.7384 17.2616L16.0809 20.3939C16.2384 20.7616 16.6 21 17 21C17.4 21 17.7616 20.7616 17.9191 20.3939L19.2616 17.2616L22.3939 15.9191C22.7616 15.7616 23 15.4 23 15C23 14.6 22.7616 14.2384 22.3939 14.0809L19.2616 12.7384L17.9191 9.60608Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4 11C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H9C9.55228 13 10 12.5523 10 12C10 11.4477 9.55228 11 9 11H4Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4 17C3.44772 17 3 17.4477 3 18C3 18.5523 3.44772 19 4 19H7C7.55228 19 8 18.5523 8 18C8 17.4477 7.55228 17 7 17H4Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "AiText"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './AiText.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'AiText'
|
||||
|
||||
export default Icon
|
||||
@ -1,4 +1,3 @@
|
||||
export { default as AiText } from './AiText'
|
||||
export { default as BubbleTextMod } from './BubbleTextMod'
|
||||
export { default as ChatBot } from './ChatBot'
|
||||
export { default as CuteRobot } from './CuteRobot'
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "17",
|
||||
"viewBox": "0 0 16 17",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Icon"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Solid"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M6.39498 2.71706C6.90587 2.57557 7.44415 2.49996 8.00008 2.49996C9.30806 2.49996 10.5183 2.91849 11.5041 3.62893C10.9796 3.97562 10.5883 4.35208 10.3171 4.75458C9.90275 5.36959 9.79654 6.00558 9.88236 6.58587C9.96571 7.1494 10.2245 7.63066 10.4965 7.98669C10.7602 8.33189 11.0838 8.6206 11.3688 8.76305C12.0863 9.12177 12.9143 9.30141 13.5334 9.39399C14.0933 9.47774 15.2805 9.75802 15.3244 8.86608C15.3304 8.74474 15.3334 8.62267 15.3334 8.49996C15.3334 4.44987 12.0502 1.16663 8.00008 1.16663C3.94999 1.16663 0.666748 4.44987 0.666748 8.49996C0.666748 12.55 3.94999 15.8333 8.00008 15.8333C8.1228 15.8333 8.24486 15.8303 8.3662 15.8243C8.73395 15.8062 9.01738 15.4934 8.99927 15.1256C8.98117 14.7579 8.66837 14.4745 8.30063 14.4926C8.20111 14.4975 8.10091 14.5 8.00008 14.5C5.6605 14.5 3.63367 13.1609 2.6442 11.2074L3.28991 10.8346L5.67171 11.2804C6.28881 11.3959 6.85846 10.9208 6.85566 10.293L6.84632 8.19093L8.06357 6.10697C8.26079 5.76932 8.24312 5.3477 8.01833 5.02774L6.39498 2.71706Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M9.29718 8.93736C9.05189 8.84432 8.77484 8.90379 8.58934 9.08929C8.40383 9.27479 8.34437 9.55184 8.43741 9.79713L10.5486 15.363C10.6461 15.6199 10.8912 15.7908 11.166 15.7932C11.4408 15.7956 11.689 15.6292 11.791 15.374L12.6714 13.1714L14.874 12.2909C15.1292 12.1889 15.2957 11.9408 15.2932 11.666C15.2908 11.3912 15.12 11.146 14.863 11.0486L9.29718 8.93736Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Globe06"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './Globe06.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Globe06'
|
||||
|
||||
export default Icon
|
||||
@ -1,58 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "13",
|
||||
"height": "12",
|
||||
"viewBox": "0 0 13 12",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "route-sep"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon",
|
||||
"d": "M6.08303 2.5H6.30023C7.82386 2.5 8.58567 2.5 8.87485 2.77364C9.12483 3.01018 9.23561 3.35864 9.16812 3.69611C9.09004 4.08651 8.46809 4.52643 7.22418 5.40627L5.19189 6.84373C3.94799 7.72357 3.32603 8.16349 3.24795 8.55389C3.18046 8.89136 3.29124 9.23982 3.54122 9.47636C3.8304 9.75 4.59221 9.75 6.11584 9.75H6.58303",
|
||||
"stroke": "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon_2",
|
||||
"d": "M2.83301 4C3.66143 4 4.33301 3.32843 4.33301 2.5C4.33301 1.67157 3.66143 1 2.83301 1C2.00458 1 1.33301 1.67157 1.33301 2.5C1.33301 3.32843 2.00458 4 2.83301 4Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "Icon_3",
|
||||
"d": "M9.83301 11C10.6614 11 11.333 10.3284 11.333 9.5C11.333 8.67157 10.6614 8 9.83301 8C9.00458 8 8.33301 8.67157 8.33301 9.5C8.33301 10.3284 9.00458 11 9.83301 11Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Route"
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './Route.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Route'
|
||||
|
||||
export default Icon
|
||||
@ -1,2 +0,0 @@
|
||||
export { default as Globe06 } from './Globe06'
|
||||
export { default as Route } from './Route'
|
||||
@ -3,13 +3,13 @@ import React from 'react'
|
||||
export type AbstractNode = {
|
||||
name: string
|
||||
attributes: {
|
||||
[key: string]: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
children?: AbstractNode[]
|
||||
}
|
||||
|
||||
export type Attrs = {
|
||||
[key: string]: string
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export function normalizeAttrs(attrs: Attrs = {}): Attrs {
|
||||
@ -24,6 +24,9 @@ export function normalizeAttrs(attrs: Attrs = {}): Attrs {
|
||||
return acc
|
||||
|
||||
const val = attrs[key]
|
||||
if (val === undefined)
|
||||
return acc
|
||||
|
||||
key = key.replace(/([-]\w)/g, (g: string) => g[1].toUpperCase())
|
||||
key = key.replace(/([:]\w)/g, (g: string) => g[1].toUpperCase())
|
||||
|
||||
|
||||
438
web/app/components/base/input-number/index.stories.tsx
Normal file
438
web/app/components/base/input-number/index.stories.tsx
Normal file
@ -0,0 +1,438 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { InputNumber } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/InputNumber',
|
||||
component: InputNumber,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current value',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value',
|
||||
},
|
||||
amount: {
|
||||
control: 'number',
|
||||
description: 'Step amount for increment/decrement',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed (e.g., "px", "ms")',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'number',
|
||||
description: 'Default value when undefined',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputNumber>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputNumberDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value ?? 0)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<InputNumber
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue)
|
||||
console.log('Value changed:', newValue)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Current value: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'large',
|
||||
},
|
||||
}
|
||||
|
||||
// With min/max constraints
|
||||
export const WithMinMax: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom step amount
|
||||
export const CustomStepAmount: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
amount: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 100,
|
||||
unit: 'px',
|
||||
min: 0,
|
||||
max: 1000,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 42,
|
||||
disabled: true,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
amount: 0.5,
|
||||
min: 0,
|
||||
max: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Negative values allowed
|
||||
export const NegativeValues: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 0,
|
||||
min: -100,
|
||||
max: 100,
|
||||
amount: 10,
|
||||
size: 'regular',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState(10)
|
||||
const [largeValue, setLargeValue] = useState(20)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<InputNumber
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={setRegularValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={setLargeValue}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Font size picker
|
||||
const FontSizePickerDemo = () => {
|
||||
const [fontSize, setFontSize] = useState(16)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Font Size</label>
|
||||
<InputNumber
|
||||
value={fontSize}
|
||||
onChange={setFontSize}
|
||||
min={8}
|
||||
max={72}
|
||||
amount={2}
|
||||
unit="px"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
|
||||
Preview Text
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FontSizePicker: Story = {
|
||||
render: () => <FontSizePickerDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Quantity selector
|
||||
const QuantitySelectorDemo = () => {
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const pricePerItem = 29.99
|
||||
const total = (quantity * pricePerItem).toFixed(2)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
|
||||
<p className="text-sm text-gray-500">${pricePerItem} each</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Quantity</label>
|
||||
<InputNumber
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
min={1}
|
||||
max={99}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Total</span>
|
||||
<span className="text-lg font-semibold text-gray-900">${total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const QuantitySelector: Story = {
|
||||
render: () => <QuantitySelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Timer settings
|
||||
const TimerSettingsDemo = () => {
|
||||
const [hours, setHours] = useState(0)
|
||||
const [minutes, setMinutes] = useState(15)
|
||||
const [seconds, setSeconds] = useState(30)
|
||||
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Hours</label>
|
||||
<InputNumber
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
min={0}
|
||||
max={23}
|
||||
unit="h"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Minutes</label>
|
||||
<InputNumber
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="m"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Seconds</label>
|
||||
<InputNumber
|
||||
value={seconds}
|
||||
onChange={setSeconds}
|
||||
min={0}
|
||||
max={59}
|
||||
unit="s"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total duration: <span className="font-semibold">{totalSeconds} seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TimerSettings: Story = {
|
||||
render: () => <TimerSettingsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Animation settings
|
||||
const AnimationSettingsDemo = () => {
|
||||
const [duration, setDuration] = useState(300)
|
||||
const [delay, setDelay] = useState(0)
|
||||
const [iterations, setIterations] = useState(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Duration</label>
|
||||
<InputNumber
|
||||
value={duration}
|
||||
onChange={setDuration}
|
||||
min={0}
|
||||
max={5000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Delay</label>
|
||||
<InputNumber
|
||||
value={delay}
|
||||
onChange={setDelay}
|
||||
min={0}
|
||||
max={2000}
|
||||
amount={50}
|
||||
unit="ms"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Iterations</label>
|
||||
<InputNumber
|
||||
value={iterations}
|
||||
onChange={setIterations}
|
||||
min={1}
|
||||
max={10}
|
||||
amount={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-gray-50 p-4">
|
||||
<div className="font-mono text-xs text-gray-700">
|
||||
animation: {duration}ms {delay}ms {iterations}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AnimationSettings: Story = {
|
||||
render: () => <AnimationSettingsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Temperature control
|
||||
const TemperatureControlDemo = () => {
|
||||
const [temperature, setTemperature] = useState(20)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
|
||||
<InputNumber
|
||||
size="large"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={16}
|
||||
max={30}
|
||||
amount={0.5}
|
||||
unit="°C"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Celsius</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{temperature}°C</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Fahrenheit</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{fahrenheit}°F</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureControl: Story = {
|
||||
render: () => <TemperatureControlDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputNumberDemo {...args} />,
|
||||
args: {
|
||||
value: 10,
|
||||
size: 'regular',
|
||||
min: 0,
|
||||
max: 100,
|
||||
amount: 1,
|
||||
unit: '',
|
||||
disabled: false,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}
|
||||
424
web/app/components/base/input/index.stories.tsx
Normal file
424
web/app/components/base/input/index.stories.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Input from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Input',
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['regular', 'large'],
|
||||
description: 'Input size',
|
||||
},
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['text', 'number', 'email', 'password', 'url', 'tel'],
|
||||
description: 'Input type',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
destructive: {
|
||||
control: 'boolean',
|
||||
description: 'Error/destructive state',
|
||||
},
|
||||
showLeftIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Show search icon on left',
|
||||
},
|
||||
showClearIcon: {
|
||||
control: 'boolean',
|
||||
description: 'Show clear button when input has value',
|
||||
},
|
||||
unit: {
|
||||
control: 'text',
|
||||
description: 'Unit text displayed on right (e.g., "px", "ms")',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const InputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Input
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Input changed:', e.target.value)
|
||||
}}
|
||||
onClear={() => {
|
||||
setValue('')
|
||||
console.log('Input cleared')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'large',
|
||||
placeholder: 'Enter text...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// With search icon
|
||||
export const WithSearchIcon: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showLeftIcon: true,
|
||||
placeholder: 'Search...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// With clear button
|
||||
export const WithClearButton: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showClearIcon: true,
|
||||
value: 'Some text to clear',
|
||||
placeholder: 'Type something...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Search input (icon + clear)
|
||||
export const SearchInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
showLeftIcon: true,
|
||||
showClearIcon: true,
|
||||
value: '',
|
||||
placeholder: 'Search...',
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'Disabled input',
|
||||
disabled: true,
|
||||
type: 'text',
|
||||
},
|
||||
}
|
||||
|
||||
// Destructive/error state
|
||||
export const DestructiveState: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'invalid@email',
|
||||
destructive: true,
|
||||
placeholder: 'Enter email...',
|
||||
type: 'email',
|
||||
},
|
||||
}
|
||||
|
||||
// Number input
|
||||
export const NumberInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'number',
|
||||
placeholder: 'Enter a number...',
|
||||
value: '0',
|
||||
},
|
||||
}
|
||||
|
||||
// With unit
|
||||
export const WithUnit: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'number',
|
||||
value: '100',
|
||||
unit: 'px',
|
||||
placeholder: 'Enter value...',
|
||||
},
|
||||
}
|
||||
|
||||
// Email input
|
||||
export const EmailInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'email',
|
||||
placeholder: 'Enter your email...',
|
||||
showClearIcon: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Password input
|
||||
export const PasswordInput: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'password',
|
||||
placeholder: 'Enter password...',
|
||||
value: 'secret123',
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [regularValue, setRegularValue] = useState('')
|
||||
const [largeValue, setLargeValue] = useState('')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Regular Size</label>
|
||||
<Input
|
||||
size="regular"
|
||||
value={regularValue}
|
||||
onChange={e => setRegularValue(e.target.value)}
|
||||
placeholder="Regular input..."
|
||||
showClearIcon
|
||||
onClear={() => setRegularValue('')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Large Size</label>
|
||||
<Input
|
||||
size="large"
|
||||
value={largeValue}
|
||||
onChange={e => setLargeValue(e.target.value)}
|
||||
placeholder="Large input..."
|
||||
showClearIcon
|
||||
onClear={() => setLargeValue('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// State comparison
|
||||
const StateComparisonDemo = () => {
|
||||
const [normalValue, setNormalValue] = useState('Normal state')
|
||||
const [errorValue, setErrorValue] = useState('Error state')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Normal</label>
|
||||
<Input
|
||||
value={normalValue}
|
||||
onChange={e => setNormalValue(e.target.value)}
|
||||
showClearIcon
|
||||
onClear={() => setNormalValue('')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Destructive</label>
|
||||
<Input
|
||||
value={errorValue}
|
||||
onChange={e => setErrorValue(e.target.value)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Disabled</label>
|
||||
<Input
|
||||
value="Disabled input"
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => <StateComparisonDemo />,
|
||||
}
|
||||
|
||||
// Form example
|
||||
const FormExampleDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
age: '',
|
||||
website: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({
|
||||
email: false,
|
||||
age: false,
|
||||
})
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Profile</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Name</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Enter your name..."
|
||||
showClearIcon
|
||||
onClear={() => setFormData({ ...formData, name: '' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false })
|
||||
}}
|
||||
placeholder="Enter your email..."
|
||||
destructive={errors.email}
|
||||
showClearIcon
|
||||
onClear={() => {
|
||||
setFormData({ ...formData, email: '' })
|
||||
setErrors({ ...errors, email: false })
|
||||
}}
|
||||
/>
|
||||
{errors.email && (
|
||||
<span className="text-xs text-red-600">Please enter a valid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Age</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, age: e.target.value })
|
||||
setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false })
|
||||
}}
|
||||
placeholder="Enter your age..."
|
||||
destructive={errors.age}
|
||||
unit="years"
|
||||
/>
|
||||
{errors.age && (
|
||||
<span className="text-xs text-red-600">Must be 18 or older</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Website</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={e => setFormData({ ...formData, website: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
showClearIcon
|
||||
onClear={() => setFormData({ ...formData, website: '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormExample: Story = {
|
||||
render: () => <FormExampleDemo />,
|
||||
}
|
||||
|
||||
// Search example
|
||||
const SearchExampleDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
|
||||
const filteredItems = items.filter(item =>
|
||||
item.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="flex flex-col gap-4">
|
||||
<Input
|
||||
size="large"
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onClear={() => setSearchQuery('')}
|
||||
placeholder="Search fruits..."
|
||||
/>
|
||||
{searchQuery && (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs text-gray-500">
|
||||
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredItems.map(item => (
|
||||
<div key={item} className="text-sm text-gray-700">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchExample: Story = {
|
||||
render: () => <SearchExampleDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <InputDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
type: 'text',
|
||||
placeholder: 'Type something...',
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
showLeftIcon: false,
|
||||
showClearIcon: true,
|
||||
unit: '',
|
||||
},
|
||||
}
|
||||
360
web/app/components/base/prompt-editor/index.stories.tsx
Normal file
360
web/app/components/base/prompt-editor/index.stories.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Mock component to avoid complex initialization issues
|
||||
const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => {
|
||||
const [content, setContent] = useState(value || '')
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
onChange?.(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<textarea
|
||||
className={`w-full resize-none outline-none ${compact ? 'text-[13px] leading-5' : 'text-sm leading-6'} ${className}`}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={!editable}
|
||||
style={{ minHeight: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/PromptEditor',
|
||||
component: PromptEditorMock,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Editor content',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
editable: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the editor is editable',
|
||||
},
|
||||
compact: {
|
||||
control: 'boolean',
|
||||
description: 'Compact mode with smaller text',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'CSS class for editor content',
|
||||
},
|
||||
wrapperClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for editor wrapper',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PromptEditorMock>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const PromptEditorDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<div className="min-h-[120px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(text: string) => {
|
||||
setValue(text)
|
||||
console.log('Content changed:', text)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{value && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Current Value:</div>
|
||||
<div className="whitespace-pre-wrap font-mono text-sm text-gray-800">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'Write a summary about the following topic:\n\nPlease include key points and examples.',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
export const CompactMode: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a compact editor with smaller text size.',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.',
|
||||
editable: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With variables example
|
||||
export const WithVariablesExample: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello, please analyze the following data and provide insights.',
|
||||
placeholder: 'Type / to insert variables...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content example
|
||||
export const LongContent: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: `You are a helpful AI assistant. Your task is to provide accurate, helpful, and friendly responses.
|
||||
|
||||
Guidelines:
|
||||
1. Be clear and concise
|
||||
2. Provide examples when helpful
|
||||
3. Ask clarifying questions if needed
|
||||
4. Maintain a professional yet friendly tone
|
||||
|
||||
Please analyze the user's request and provide a comprehensive response.`,
|
||||
placeholder: 'Enter your prompt...',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom placeholder
|
||||
export const CustomPlaceholder: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Describe the task you want the AI to perform... (Press / for variables)',
|
||||
editable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Multiple editors
|
||||
const MultipleEditorsDemo = () => {
|
||||
const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.')
|
||||
const [userPrompt, setUserPrompt] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">System Prompt</label>
|
||||
<div className="min-h-[100px] rounded-lg border border-gray-300 bg-blue-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={systemPrompt}
|
||||
onChange={setSystemPrompt}
|
||||
placeholder="Enter system instructions..."
|
||||
editable={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">User Prompt</label>
|
||||
<div className="min-h-[100px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
value={userPrompt}
|
||||
onChange={setUserPrompt}
|
||||
placeholder="Enter user message template..."
|
||||
editable={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(systemPrompt || userPrompt) && (
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Combined Output:</div>
|
||||
<div className="whitespace-pre-wrap text-sm text-gray-800">
|
||||
{systemPrompt && (
|
||||
<>
|
||||
<strong>System:</strong> {systemPrompt}
|
||||
{userPrompt && '\n\n'}
|
||||
</>
|
||||
)}
|
||||
{userPrompt && (
|
||||
<>
|
||||
<strong>User:</strong> {userPrompt}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleEditors: Story = {
|
||||
render: () => <MultipleEditorsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
const EmailTemplateDemo = () => {
|
||||
const [subject, setSubject] = useState('Welcome to our platform!')
|
||||
const [body, setBody] = useState(`Hi,
|
||||
|
||||
Thank you for signing up! We're excited to have you on board.
|
||||
|
||||
To get started, please verify your email address by clicking the button below.
|
||||
|
||||
Best regards,
|
||||
The Team`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Email Template Editor</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Subject Line</label>
|
||||
<div className="rounded-lg border border-gray-300 p-3">
|
||||
<PromptEditorMock
|
||||
value={subject}
|
||||
onChange={setSubject}
|
||||
placeholder="Enter email subject..."
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Email Body</label>
|
||||
<div className="min-h-[200px] rounded-lg border border-gray-300 p-4">
|
||||
<PromptEditorMock
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Type your email content... Use / to insert variables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailTemplate: Story = {
|
||||
render: () => <EmailTemplateDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Chat prompt builder
|
||||
const ChatPromptBuilderDemo = () => {
|
||||
const [prompt, setPrompt] = useState(`Analyze the following conversation and provide insights:
|
||||
|
||||
1. Identify the main topics discussed
|
||||
2. Detect the sentiment and tone
|
||||
3. Summarize key points
|
||||
4. Suggest follow-up questions`)
|
||||
|
||||
const [characterCount, setCharacterCount] = useState(prompt.length)
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setPrompt(text)
|
||||
setCharacterCount(text.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Chat Prompt Builder</h3>
|
||||
<span className="text-xs text-gray-500">{characterCount} characters</span>
|
||||
</div>
|
||||
<div className="min-h-[200px] rounded-lg border border-gray-300 bg-gray-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
placeholder="Design your chat prompt... Use / for templates"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
|
||||
💡 <strong>Tip:</strong> Type <code className="rounded bg-blue-100 px-1 py-0.5">/</code> to insert variables or templates
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChatPromptBuilder: Story = {
|
||||
render: () => <ChatPromptBuilderDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API instruction editor
|
||||
const APIInstructionEditorDemo = () => {
|
||||
const [instructions, setInstructions] = useState(`Process the incoming API request and:
|
||||
|
||||
1. Validate all required fields are present
|
||||
2. Transform the data according to the schema
|
||||
3. Apply business logic rules
|
||||
4. Return the formatted response`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Processing Instructions</h3>
|
||||
<div className="min-h-[180px] rounded-lg border-2 border-indigo-300 bg-indigo-50 p-4">
|
||||
<PromptEditorMock
|
||||
value={instructions}
|
||||
onChange={setInstructions}
|
||||
placeholder="Enter processing instructions..."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
|
||||
Save Instructions
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIInstructionEditor: Story = {
|
||||
render: () => <APIInstructionEditorDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <PromptEditorDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
placeholder: 'Type / for commands...',
|
||||
editable: true,
|
||||
compact: false,
|
||||
},
|
||||
}
|
||||
504
web/app/components/base/radio-card/index.stories.tsx
Normal file
504
web/app/components/base/radio-card/index.stories.tsx
Normal file
@ -0,0 +1,504 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
|
||||
import RadioCard from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/RadioCard',
|
||||
component: RadioCard,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
icon: {
|
||||
description: 'Icon element to display',
|
||||
},
|
||||
iconBgClassName: {
|
||||
control: 'text',
|
||||
description: 'Background color class for icon container',
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
description: 'Card title',
|
||||
},
|
||||
description: {
|
||||
control: 'text',
|
||||
description: 'Card description',
|
||||
},
|
||||
isChosen: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the card is selected',
|
||||
},
|
||||
noRadio: {
|
||||
control: 'boolean',
|
||||
description: 'Hide the radio button indicator',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof RadioCard>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Single card demo
|
||||
const RadioCardDemo = (args: any) => {
|
||||
const [isChosen, setIsChosen] = useState(args.isChosen || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
{...args}
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: false,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Selected state
|
||||
export const Selected: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Quick Start',
|
||||
description: 'Get started quickly with default settings',
|
||||
isChosen: true,
|
||||
noRadio: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Without radio indicator
|
||||
export const NoRadio: Story = {
|
||||
render: args => <RadioCardDemo {...args} />,
|
||||
args: {
|
||||
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
|
||||
iconBgClassName: 'bg-purple-100',
|
||||
title: 'Information Card',
|
||||
description: 'Card without radio indicator',
|
||||
noRadio: true,
|
||||
},
|
||||
}
|
||||
|
||||
// With configuration panel
|
||||
const WithConfigurationDemo = () => {
|
||||
const [isChosen, setIsChosen] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Database Storage"
|
||||
description="Store data in a managed database"
|
||||
isChosen={isChosen}
|
||||
onChosen={() => setIsChosen(!isChosen)}
|
||||
chosenConfig={
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Region:</label>
|
||||
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>US East</option>
|
||||
<option>EU West</option>
|
||||
<option>Asia Pacific</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Size:</label>
|
||||
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
|
||||
<option>Small (10GB)</option>
|
||||
<option>Medium (50GB)</option>
|
||||
<option>Large (100GB)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithConfiguration: Story = {
|
||||
render: () => <WithConfigurationDemo />,
|
||||
}
|
||||
|
||||
// Multiple cards selection
|
||||
const MultipleCardsDemo = () => {
|
||||
const [selected, setSelected] = useState('standard')
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'standard',
|
||||
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'Standard',
|
||||
description: 'Perfect for most use cases',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
icon: <RiCpuLine className="h-5 w-5 text-blue-600" />,
|
||||
iconBg: 'bg-blue-100',
|
||||
title: 'Advanced',
|
||||
description: 'More features and customization',
|
||||
},
|
||||
{
|
||||
value: 'enterprise',
|
||||
icon: <RiShieldLine className="h-5 w-5 text-green-600" />,
|
||||
iconBg: 'bg-green-100',
|
||||
title: 'Enterprise',
|
||||
description: 'Full features with premium support',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
{options.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
isChosen={selected === option.value}
|
||||
onChosen={() => setSelected(option.value)}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleCards: Story = {
|
||||
render: () => <MultipleCardsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Cloud provider selection
|
||||
const CloudProviderSelectionDemo = () => {
|
||||
const [provider, setProvider] = useState('aws')
|
||||
const [region, setRegion] = useState('us-east-1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="h-5 w-5 text-orange-600" />}
|
||||
iconBgClassName="bg-orange-100"
|
||||
title="Amazon Web Services"
|
||||
description="Industry-leading cloud infrastructure"
|
||||
isChosen={provider === 'aws'}
|
||||
onChosen={() => setProvider('aws')}
|
||||
chosenConfig={
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Region</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={region}
|
||||
onChange={e => setRegion(e.target.value)}
|
||||
>
|
||||
<option value="us-east-1">US East (N. Virginia)</option>
|
||||
<option value="us-west-2">US West (Oregon)</option>
|
||||
<option value="eu-west-1">EU (Ireland)</option>
|
||||
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="h-5 w-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Microsoft Azure"
|
||||
description="Enterprise-grade cloud platform"
|
||||
isChosen={provider === 'azure'}
|
||||
onChosen={() => setProvider('azure')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="h-5 w-5 text-red-600" />}
|
||||
iconBgClassName="bg-red-100"
|
||||
title="Google Cloud Platform"
|
||||
description="Scalable and reliable infrastructure"
|
||||
isChosen={provider === 'gcp'}
|
||||
onChosen={() => setProvider('gcp')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CloudProviderSelection: Story = {
|
||||
render: () => <CloudProviderSelectionDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Deployment strategy
|
||||
const DeploymentStrategyDemo = () => {
|
||||
const [strategy, setStrategy] = useState('rolling')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="h-5 w-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Rolling Deployment"
|
||||
description="Gradually replace instances with zero downtime"
|
||||
isChosen={strategy === 'rolling'}
|
||||
onChosen={() => setStrategy('rolling')}
|
||||
chosenConfig={
|
||||
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
|
||||
✓ Recommended for production environments<br />
|
||||
✓ Minimal risk with automatic rollback<br />
|
||||
✓ Takes 5-10 minutes
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCpuLine className="h-5 w-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Blue-Green Deployment"
|
||||
description="Switch between two identical environments"
|
||||
isChosen={strategy === 'blue-green'}
|
||||
onChosen={() => setStrategy('blue-green')}
|
||||
chosenConfig={
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
|
||||
✓ Instant rollback capability<br />
|
||||
✓ Requires double the resources<br />
|
||||
✓ Takes 2-5 minutes
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiLightbulbLine className="h-5 w-5 text-yellow-600" />}
|
||||
iconBgClassName="bg-yellow-100"
|
||||
title="Canary Deployment"
|
||||
description="Test with a small subset of users first"
|
||||
isChosen={strategy === 'canary'}
|
||||
onChosen={() => setStrategy('canary')}
|
||||
chosenConfig={
|
||||
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
|
||||
✓ Test changes with real traffic<br />
|
||||
✓ Gradual rollout reduces risk<br />
|
||||
✓ Takes 15-30 minutes
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Deploy with {strategy} strategy
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DeploymentStrategy: Story = {
|
||||
render: () => <DeploymentStrategyDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Storage options
|
||||
const StorageOptionsDemo = () => {
|
||||
const [storage, setStorage] = useState('ssd')
|
||||
|
||||
const storageOptions = [
|
||||
{
|
||||
value: 'ssd',
|
||||
icon: <RiDatabase2Line className="h-5 w-5 text-purple-600" />,
|
||||
iconBg: 'bg-purple-100',
|
||||
title: 'SSD Storage',
|
||||
description: 'Fast and reliable solid state drives',
|
||||
price: '$0.10/GB/month',
|
||||
speed: 'Up to 3000 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'hdd',
|
||||
icon: <RiDatabase2Line className="h-5 w-5 text-gray-600" />,
|
||||
iconBg: 'bg-gray-100',
|
||||
title: 'HDD Storage',
|
||||
description: 'Cost-effective magnetic disk storage',
|
||||
price: '$0.05/GB/month',
|
||||
speed: 'Up to 500 IOPS',
|
||||
},
|
||||
{
|
||||
value: 'nvme',
|
||||
icon: <RiDatabase2Line className="h-5 w-5 text-red-600" />,
|
||||
iconBg: 'bg-red-100',
|
||||
title: 'NVMe Storage',
|
||||
description: 'Ultra-fast PCIe-based storage',
|
||||
price: '$0.20/GB/month',
|
||||
speed: 'Up to 10000 IOPS',
|
||||
},
|
||||
]
|
||||
|
||||
const selectedOption = storageOptions.find(opt => opt.value === storage)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
|
||||
<div className="space-y-3">
|
||||
{storageOptions.map(option => (
|
||||
<RadioCard
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
iconBgClassName={option.iconBg}
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{option.title}</span>
|
||||
<span className="text-xs font-normal text-gray-500">{option.price}</span>
|
||||
</div>
|
||||
}
|
||||
description={`${option.description} - ${option.speed}`}
|
||||
isChosen={storage === option.value}
|
||||
onChosen={() => setStorage(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selectedOption && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>Selected:</strong> {selectedOption.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{selectedOption.price} • {selectedOption.speed}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StorageOptions: Story = {
|
||||
render: () => <StorageOptionsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API authentication method
|
||||
const APIAuthMethodDemo = () => {
|
||||
const [authMethod, setAuthMethod] = useState('api_key')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
|
||||
<div className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="h-5 w-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="API Key"
|
||||
description="Simple authentication using a secret key"
|
||||
isChosen={authMethod === 'api_key'}
|
||||
onChosen={() => setAuthMethod('api_key')}
|
||||
chosenConfig={
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-gray-700">Your API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="h-5 w-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="OAuth 2.0"
|
||||
description="Industry-standard authorization protocol"
|
||||
isChosen={authMethod === 'oauth'}
|
||||
onChosen={() => setAuthMethod('oauth')}
|
||||
chosenConfig={
|
||||
<div className="rounded-lg bg-green-50 p-3">
|
||||
<p className="mb-2 text-xs text-gray-700">
|
||||
Configure OAuth 2.0 authentication for secure access
|
||||
</p>
|
||||
<button className="text-xs font-medium text-green-600 hover:underline">
|
||||
Configure OAuth Settings →
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiShieldLine className="h-5 w-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="JWT Token"
|
||||
description="JSON Web Token based authentication"
|
||||
isChosen={authMethod === 'jwt'}
|
||||
onChosen={() => setAuthMethod('jwt')}
|
||||
chosenConfig={
|
||||
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
|
||||
JWT tokens provide stateless authentication with expiration and refresh capabilities
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIAuthMethod: Story = {
|
||||
render: () => <APIAuthMethodDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="space-y-3">
|
||||
<RadioCard
|
||||
icon={<RiRocketLine className="h-5 w-5 text-purple-600" />}
|
||||
iconBgClassName="bg-purple-100"
|
||||
title="Option 1"
|
||||
description="First option with icon and description"
|
||||
isChosen={selected === 'option1'}
|
||||
onChosen={() => setSelected('option1')}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
|
||||
iconBgClassName="bg-blue-100"
|
||||
title="Option 2"
|
||||
description="Second option with different styling"
|
||||
isChosen={selected === 'option2'}
|
||||
onChosen={() => setSelected('option2')}
|
||||
chosenConfig={
|
||||
<div className="rounded bg-blue-50 p-2 text-xs text-gray-600">
|
||||
Additional configuration appears when selected
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<RadioCard
|
||||
icon={<RiCloudLine className="h-5 w-5 text-green-600" />}
|
||||
iconBgClassName="bg-green-100"
|
||||
title="Option 3"
|
||||
description="Third option to demonstrate selection"
|
||||
isChosen={selected === 'option3'}
|
||||
onChosen={() => setSelected('option3')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
421
web/app/components/base/radio/index.stories.tsx
Normal file
421
web/app/components/base/radio/index.stories.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Radio from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Radio',
|
||||
component: Radio,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: 'boolean',
|
||||
description: 'Checked state (for standalone radio)',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Value of the radio option',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Label content',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Radio>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Single radio demo
|
||||
const SingleRadioDemo = (args: any) => {
|
||||
const [checked, setChecked] = useState(args.checked || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Radio
|
||||
{...args}
|
||||
checked={checked}
|
||||
onChange={() => setChecked(!checked)}
|
||||
>
|
||||
{args.children || 'Radio option'}
|
||||
</Radio>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default single radio
|
||||
export const Default: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
children: 'Single radio option',
|
||||
},
|
||||
}
|
||||
|
||||
// Checked state
|
||||
export const Checked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: false,
|
||||
children: 'Selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
disabled: true,
|
||||
children: 'Disabled option',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled and checked
|
||||
export const DisabledChecked: Story = {
|
||||
render: args => <SingleRadioDemo {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
disabled: true,
|
||||
children: 'Disabled selected option',
|
||||
},
|
||||
}
|
||||
|
||||
// Radio Group - Basic
|
||||
const RadioGroupDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroup: Story = {
|
||||
render: () => <RadioGroupDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With descriptions
|
||||
const RadioGroupWithDescriptionsDemo = () => {
|
||||
const [value, setValue] = useState('basic')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="basic">
|
||||
<div>
|
||||
<div className="font-medium">Basic Plan</div>
|
||||
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="pro">
|
||||
<div>
|
||||
<div className="font-medium">Pro Plan</div>
|
||||
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="enterprise">
|
||||
<div>
|
||||
<div className="font-medium">Enterprise Plan</div>
|
||||
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDescriptions: Story = {
|
||||
render: () => <RadioGroupWithDescriptionsDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - With disabled option
|
||||
const RadioGroupWithDisabledDemo = () => {
|
||||
const [value, setValue] = useState('available')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="available">Available option</Radio>
|
||||
<Radio value="disabled" disabled>Disabled option</Radio>
|
||||
<Radio value="another">Another available option</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RadioGroupWithDisabled: Story = {
|
||||
render: () => <RadioGroupWithDisabledDemo />,
|
||||
}
|
||||
|
||||
// Radio Group - Vertical layout
|
||||
const VerticalLayoutDemo = () => {
|
||||
const [value, setValue] = useState('email')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
|
||||
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
|
||||
<Radio value="email">Email notifications</Radio>
|
||||
<Radio value="sms">SMS notifications</Radio>
|
||||
<Radio value="push">Push notifications</Radio>
|
||||
<Radio value="none">No notifications</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalLayout: Story = {
|
||||
render: () => <VerticalLayoutDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings panel
|
||||
const SettingsPanelDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [language, setLanguage] = useState('en')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
|
||||
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
|
||||
<Radio value="light">Light mode</Radio>
|
||||
<Radio value="dark">Dark mode</Radio>
|
||||
<Radio value="auto">Auto (system preference)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
|
||||
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
|
||||
<Radio value="en">English</Radio>
|
||||
<Radio value="zh">中文 (Chinese)</Radio>
|
||||
<Radio value="es">Español (Spanish)</Radio>
|
||||
<Radio value="fr">Français (French)</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>Current settings:</strong> Theme: {theme}, Language: {language}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Payment method selector
|
||||
const PaymentMethodSelectorDemo = () => {
|
||||
const [paymentMethod, setPaymentMethod] = useState('credit_card')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
|
||||
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
|
||||
<Radio value="credit_card">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Credit Card</div>
|
||||
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">💳</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="paypal">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">PayPal</div>
|
||||
<div className="text-xs text-gray-500">Fast and secure</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🅿️</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="bank_transfer">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Bank Transfer</div>
|
||||
<div className="text-xs text-gray-500">1-3 business days</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">🏦</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Continue with {paymentMethod.replace('_', ' ')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PaymentMethodSelector: Story = {
|
||||
render: () => <PaymentMethodSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Shipping options
|
||||
const ShippingOptionsDemo = () => {
|
||||
const [shipping, setShipping] = useState('standard')
|
||||
|
||||
const shippingCosts = {
|
||||
standard: 5.99,
|
||||
express: 14.99,
|
||||
overnight: 29.99,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
|
||||
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
|
||||
<Radio value="standard">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Standard Shipping</div>
|
||||
<div className="text-xs text-gray-500">5-7 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">${shippingCosts.standard}</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="express">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Express Shipping</div>
|
||||
<div className="text-xs text-gray-500">2-3 business days</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">${shippingCosts.express}</div>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="overnight">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Overnight Shipping</div>
|
||||
<div className="text-xs text-gray-500">Next business day</div>
|
||||
</div>
|
||||
<div className="font-semibold text-gray-700">${shippingCosts.overnight}</div>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Shipping cost:</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
${shippingCosts[shipping as keyof typeof shippingCosts]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShippingOptions: Story = {
|
||||
render: () => <ShippingOptionsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Survey question
|
||||
const SurveyQuestionDemo = () => {
|
||||
const [satisfaction, setSatisfaction] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
|
||||
|
||||
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
|
||||
<Radio value="very_satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😄</span>
|
||||
<span>Very satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="satisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>🙂</span>
|
||||
<span>Satisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="neutral">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😐</span>
|
||||
<span>Neutral</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😟</span>
|
||||
<span>Dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="very_dissatisfied">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>😢</span>
|
||||
<span>Very dissatisfied</span>
|
||||
</div>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!satisfaction}
|
||||
>
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SurveyQuestion: Story = {
|
||||
render: () => <SurveyQuestionDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [value, setValue] = useState('option1')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Radio.Group value={value} onChange={setValue}>
|
||||
<Radio value="option1">Option 1</Radio>
|
||||
<Radio value="option2">Option 2</Radio>
|
||||
<Radio value="option3">Option 3</Radio>
|
||||
<Radio value="option4" disabled>Disabled option</Radio>
|
||||
</Radio.Group>
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
435
web/app/components/base/search-input/index.stories.tsx
Normal file
435
web/app/components/base/search-input/index.stories.tsx
Normal file
@ -0,0 +1,435 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import SearchInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/SearchInput',
|
||||
component: SearchInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Search input component with search icon, clear button, and IME composition support for Asian languages.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Search input value',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
white: {
|
||||
control: 'boolean',
|
||||
description: 'White background variant',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SearchInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SearchInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<SearchInput
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
console.log('Search value changed:', v)
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Searching for: <span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// White variant
|
||||
export const WhiteBackground: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search...',
|
||||
white: true,
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Initial search query',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom placeholder
|
||||
export const CustomPlaceholder: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search documents, files, and more...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - User list search
|
||||
const UserListSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const users = [
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
|
||||
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
|
||||
{ id: 3, name: 'Charlie Brown', email: 'charlie@example.com', role: 'User' },
|
||||
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Editor' },
|
||||
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'User' },
|
||||
]
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| user.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| user.role.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Team Members</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search by name, email, or role..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{user.name}</div>
|
||||
<div className="text-xs text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700">
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No users found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-gray-500">
|
||||
Showing {filteredUsers.length} of {users.length} members
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserListSearch: Story = {
|
||||
render: () => <UserListSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Product search
|
||||
const ProductSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const products = [
|
||||
{ id: 1, name: 'Laptop Pro 15"', category: 'Electronics', price: 1299 },
|
||||
{ id: 2, name: 'Wireless Mouse', category: 'Accessories', price: 29 },
|
||||
{ id: 3, name: 'Mechanical Keyboard', category: 'Accessories', price: 89 },
|
||||
{ id: 4, name: 'Monitor 27" 4K', category: 'Electronics', price: 499 },
|
||||
{ id: 5, name: 'USB-C Hub', category: 'Accessories', price: 49 },
|
||||
{ id: 6, name: 'Laptop Stand', category: 'Accessories', price: 39 },
|
||||
]
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| product.category.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search products..."
|
||||
white
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{filteredProducts.length > 0 ? (
|
||||
filteredProducts.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="mb-1 text-sm font-medium">{product.name}</div>
|
||||
<div className="mb-2 text-xs text-gray-500">{product.category}</div>
|
||||
<div className="text-lg font-semibold text-blue-600">${product.price}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 py-8 text-center text-sm text-gray-500">
|
||||
No products found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductSearch: Story = {
|
||||
render: () => <ProductSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Documentation search
|
||||
const DocumentationSearchDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const docs = [
|
||||
{ id: 1, title: 'Getting Started', category: 'Introduction', excerpt: 'Learn the basics of our platform' },
|
||||
{ id: 2, title: 'API Reference', category: 'Developers', excerpt: 'Complete API documentation and examples' },
|
||||
{ id: 3, title: 'Authentication Guide', category: 'Security', excerpt: 'Set up OAuth and API key authentication' },
|
||||
{ id: 4, title: 'Best Practices', category: 'Guides', excerpt: 'Tips for optimal performance and security' },
|
||||
{ id: 5, title: 'Troubleshooting', category: 'Support', excerpt: 'Common issues and their solutions' },
|
||||
]
|
||||
|
||||
const filteredDocs = docs.filter(doc =>
|
||||
doc.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| doc.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|| doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg bg-gray-50 p-6">
|
||||
<h3 className="mb-2 text-xl font-bold">Documentation</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search documentation..."
|
||||
white
|
||||
className="!h-10"
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filteredDocs.length > 0 ? (
|
||||
filteredDocs.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<h4 className="text-base font-semibold">{doc.title}</h4>
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600">
|
||||
{doc.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{doc.excerpt}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-2 text-4xl">🔍</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
No documentation found for "{searchQuery}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DocumentationSearch: Story = {
|
||||
render: () => <DocumentationSearchDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Command palette
|
||||
const CommandPaletteDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const commands = [
|
||||
{ id: 1, name: 'Create new document', icon: '📄', shortcut: '⌘N' },
|
||||
{ id: 2, name: 'Open settings', icon: '⚙️', shortcut: '⌘,' },
|
||||
{ id: 3, name: 'Search everywhere', icon: '🔍', shortcut: '⌘K' },
|
||||
{ id: 4, name: 'Toggle sidebar', icon: '📁', shortcut: '⌘B' },
|
||||
{ id: 5, name: 'Save changes', icon: '💾', shortcut: '⌘S' },
|
||||
{ id: 6, name: 'Undo last action', icon: '↩️', shortcut: '⌘Z' },
|
||||
{ id: 7, name: 'Redo last action', icon: '↪️', shortcut: '⌘⇧Z' },
|
||||
]
|
||||
|
||||
const filteredCommands = commands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg">
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Type a command or search..."
|
||||
white
|
||||
className="!h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{filteredCommands.length > 0 ? (
|
||||
filteredCommands.map(cmd => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{cmd.icon}</span>
|
||||
<span className="text-sm">{cmd.name}</span>
|
||||
</div>
|
||||
<kbd className="rounded bg-gray-200 px-2 py-1 font-mono text-xs">
|
||||
{cmd.shortcut}
|
||||
</kbd>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No commands found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommandPalette: Story = {
|
||||
render: () => <CommandPaletteDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Live search with results count
|
||||
const LiveSearchWithCountDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const items = [
|
||||
'React Documentation',
|
||||
'React Hooks',
|
||||
'React Router',
|
||||
'Redux Toolkit',
|
||||
'TypeScript Guide',
|
||||
'JavaScript Basics',
|
||||
'CSS Grid Layout',
|
||||
'Flexbox Tutorial',
|
||||
'Node.js Express',
|
||||
'MongoDB Guide',
|
||||
]
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Learning Resources</h3>
|
||||
{searchQuery && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search resources..."
|
||||
/>
|
||||
<div className="mt-4 space-y-2">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<div className="text-sm font-medium">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LiveSearchWithCount: Story = {
|
||||
render: () => <LiveSearchWithCountDemo />,
|
||||
}
|
||||
|
||||
// Size variations
|
||||
const SizeVariationsDemo = () => {
|
||||
const [value1, setValue1] = useState('')
|
||||
const [value2, setValue2] = useState('')
|
||||
const [value3, setValue3] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
|
||||
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
|
||||
<SearchInput
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
placeholder="Search..."
|
||||
className="!h-10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
|
||||
<SearchInput
|
||||
value={value3}
|
||||
onChange={setValue3}
|
||||
placeholder="Search..."
|
||||
className="!h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeVariations: Story = {
|
||||
render: () => <SizeVariationsDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SearchInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
placeholder: 'Search...',
|
||||
white: false,
|
||||
},
|
||||
}
|
||||
527
web/app/components/base/select/index.stories.tsx
Normal file
527
web/app/components/base/select/index.stories.tsx
Normal file
@ -0,0 +1,527 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Select, { PortalSelect, SimpleSelect } from '.'
|
||||
import type { Item } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Select',
|
||||
component: SimpleSelect,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
notClearable: {
|
||||
control: 'boolean',
|
||||
description: 'Hide clear button',
|
||||
},
|
||||
hideChecked: {
|
||||
control: 'boolean',
|
||||
description: 'Hide check icon on selected item',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SimpleSelect>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const fruits: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
{ value: 'cherry', name: 'Cherry' },
|
||||
{ value: 'date', name: 'Date' },
|
||||
{ value: 'elderberry', name: 'Elderberry' },
|
||||
]
|
||||
|
||||
const countries: Item[] = [
|
||||
{ value: 'us', name: 'United States' },
|
||||
{ value: 'uk', name: 'United Kingdom' },
|
||||
{ value: 'ca', name: 'Canada' },
|
||||
{ value: 'au', name: 'Australia' },
|
||||
{ value: 'de', name: 'Germany' },
|
||||
{ value: 'fr', name: 'France' },
|
||||
{ value: 'jp', name: 'Japan' },
|
||||
{ value: 'cn', name: 'China' },
|
||||
]
|
||||
|
||||
// SimpleSelect Demo
|
||||
const SimpleSelectDemo = (args: any) => {
|
||||
const [selected, setSelected] = useState(args.defaultValue || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
{...args}
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default SimpleSelect
|
||||
export const Default: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
},
|
||||
}
|
||||
|
||||
// With placeholder (no selection)
|
||||
export const WithPlaceholder: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Choose an option...',
|
||||
defaultValue: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'banana',
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Not clearable
|
||||
export const NotClearable: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'cherry',
|
||||
notClearable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Hide checked icon
|
||||
export const HideChecked: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
hideChecked: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Select with search
|
||||
const WithSearchDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
allowSearch={true}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithSearch: Story = {
|
||||
render: () => <WithSearchDemo />,
|
||||
}
|
||||
|
||||
// PortalSelect
|
||||
const PortalSelectVariantDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={selected}
|
||||
items={fruits}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
placeholder="Select a fruit..."
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PortalSelectVariant: Story = {
|
||||
render: () => <PortalSelectVariantDemo />,
|
||||
}
|
||||
|
||||
// Custom render option
|
||||
const CustomRenderOptionDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
const countriesWithFlags = [
|
||||
{ value: 'us', name: 'United States', flag: '🇺🇸' },
|
||||
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
|
||||
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
|
||||
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
|
||||
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={countriesWithFlags}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
renderOption={({ item, selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.flag}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{selected && <span className="text-blue-600">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomRenderOption: Story = {
|
||||
render: () => <CustomRenderOptionDemo />,
|
||||
}
|
||||
|
||||
// Loading state
|
||||
export const LoadingState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={[]}
|
||||
defaultValue=""
|
||||
onSelect={() => undefined}
|
||||
placeholder="Loading options..."
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Form field
|
||||
const FormFieldDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
country: 'us',
|
||||
language: 'en',
|
||||
timezone: 'pst',
|
||||
})
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', name: 'English' },
|
||||
{ value: 'es', name: 'Spanish' },
|
||||
{ value: 'fr', name: 'French' },
|
||||
{ value: 'de', name: 'German' },
|
||||
{ value: 'zh', name: 'Chinese' },
|
||||
]
|
||||
|
||||
const timezones = [
|
||||
{ value: 'pst', name: 'Pacific Time (PST)' },
|
||||
{ value: 'mst', name: 'Mountain Time (MST)' },
|
||||
{ value: 'cst', name: 'Central Time (CST)' },
|
||||
{ value: 'est', name: 'Eastern Time (EST)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
|
||||
<SimpleSelect
|
||||
items={countries}
|
||||
defaultValue={formData.country}
|
||||
onSelect={item => setFormData({ ...formData, country: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
|
||||
<SimpleSelect
|
||||
items={languages}
|
||||
defaultValue={formData.language}
|
||||
onSelect={item => setFormData({ ...formData, language: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
|
||||
<SimpleSelect
|
||||
items={timezones}
|
||||
defaultValue={formData.timezone}
|
||||
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
|
||||
<div><strong>Country:</strong> {formData.country}</div>
|
||||
<div><strong>Language:</strong> {formData.language}</div>
|
||||
<div><strong>Timezone:</strong> {formData.timezone}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormField: Story = {
|
||||
render: () => <FormFieldDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Filter selector
|
||||
const FilterSelectorDemo = () => {
|
||||
const [status, setStatus] = useState('all')
|
||||
const [priority, setPriority] = useState('all')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', name: 'All Status' },
|
||||
{ value: 'active', name: 'Active' },
|
||||
{ value: 'pending', name: 'Pending' },
|
||||
{ value: 'completed', name: 'Completed' },
|
||||
{ value: 'cancelled', name: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'all', name: 'All Priorities' },
|
||||
{ value: 'high', name: 'High Priority' },
|
||||
{ value: 'medium', name: 'Medium Priority' },
|
||||
{ value: 'low', name: 'Low Priority' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
|
||||
<SimpleSelect
|
||||
items={statusOptions}
|
||||
defaultValue={status}
|
||||
onSelect={item => setStatus(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
|
||||
<SimpleSelect
|
||||
items={priorityOptions}
|
||||
defaultValue={priority}
|
||||
onSelect={item => setPriority(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Status: {status}
|
||||
</span>
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Priority: {priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterSelector: Story = {
|
||||
render: () => <FilterSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Version selector with badge
|
||||
const VersionSelectorDemo = () => {
|
||||
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
|
||||
|
||||
const versions = [
|
||||
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
|
||||
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
|
||||
{ value: '2.0.5', name: 'v2.0.5' },
|
||||
{ value: '2.0.4', name: 'v2.0.4' },
|
||||
{ value: '1.9.8', name: 'v1.9.8' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
items={versions}
|
||||
onSelect={item => setSelectedVersion(item.value as string)}
|
||||
installedValue="2.0.5"
|
||||
placeholder="Choose version..."
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
|
||||
{selectedVersion !== '2.0.5' && (
|
||||
<div className="mb-2 text-yellow-600">
|
||||
⚠️ Version change detected
|
||||
</div>
|
||||
)}
|
||||
<div>Current: <strong>{selectedVersion}</strong></div>
|
||||
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VersionSelector: Story = {
|
||||
render: () => <VersionSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings dropdown
|
||||
const SettingsDropdownDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', name: '☀️ Light Mode' },
|
||||
{ value: 'dark', name: '🌙 Dark Mode' },
|
||||
{ value: 'auto', name: '🔄 Auto (System)' },
|
||||
]
|
||||
|
||||
const fontSizeOptions = [
|
||||
{ value: 'small', name: 'Small (12px)' },
|
||||
{ value: 'medium', name: 'Medium (14px)' },
|
||||
{ value: 'large', name: 'Large (16px)' },
|
||||
{ value: 'xlarge', name: 'Extra Large (18px)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
|
||||
<SimpleSelect
|
||||
items={themeOptions}
|
||||
defaultValue={theme}
|
||||
onSelect={item => setTheme(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
|
||||
<SimpleSelect
|
||||
items={fontSizeOptions}
|
||||
defaultValue={fontSize}
|
||||
onSelect={item => setFontSize(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsDropdown: Story = {
|
||||
render: () => <SettingsDropdownDemo />,
|
||||
}
|
||||
|
||||
// Comparison of variants
|
||||
const VariantComparisonDemo = () => {
|
||||
const [simple, setSimple] = useState('apple')
|
||||
const [withSearch, setWithSearch] = useState('us')
|
||||
const [portal, setPortal] = useState('banana')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={simple}
|
||||
onSelect={item => setSimple(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={withSearch}
|
||||
onSelect={item => setWithSearch(item.value as string)}
|
||||
allowSearch={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={portal}
|
||||
items={fruits}
|
||||
onSelect={item => setPortal(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariantComparison: Story = {
|
||||
render: () => <VariantComparisonDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
placeholder="Select an option..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
}
|
||||
560
web/app/components/base/slider/index.stories.tsx
Normal file
560
web/app/components/base/slider/index.stories.tsx
Normal file
@ -0,0 +1,560 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Slider from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current slider value',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value (default: 0)',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value (default: 100)',
|
||||
},
|
||||
step: {
|
||||
control: 'number',
|
||||
description: 'Step increment (default: 1)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Slider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SliderDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || 50)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Slider
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
console.log('Slider value:', v)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
Value: <span className="text-lg font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With custom range
|
||||
export const CustomRange: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 25,
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With step increment
|
||||
export const WithStepIncrement: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 75,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Volume control
|
||||
const VolumeControlDemo = () => {
|
||||
const [volume, setVolume] = useState(70)
|
||||
|
||||
const getVolumeIcon = (vol: number) => {
|
||||
if (vol === 0) return '🔇'
|
||||
if (vol < 33) return '🔈'
|
||||
if (vol < 66) return '🔉'
|
||||
return '🔊'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Volume Control</h3>
|
||||
<span className="text-2xl">{getVolumeIcon(volume)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={setVolume}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Mute</span>
|
||||
<span className="text-lg font-semibold">{volume}%</span>
|
||||
<span>Max</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VolumeControl: Story = {
|
||||
render: () => <VolumeControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Brightness control
|
||||
const BrightnessControlDemo = () => {
|
||||
const [brightness, setBrightness] = useState(80)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Screen Brightness</h3>
|
||||
<span className="text-2xl">☀️</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={brightness}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setBrightness}
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
|
||||
<div className="text-sm text-gray-700">
|
||||
Preview at {brightness}% brightness
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrightnessControl: Story = {
|
||||
render: () => <BrightnessControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Price range filter
|
||||
const PriceRangeFilterDemo = () => {
|
||||
const [maxPrice, setMaxPrice] = useState(500)
|
||||
const minPrice = 0
|
||||
|
||||
const products = [
|
||||
{ name: 'Product A', price: 150 },
|
||||
{ name: 'Product B', price: 350 },
|
||||
{ name: 'Product C', price: 600 },
|
||||
{ name: 'Product D', price: 250 },
|
||||
{ name: 'Product E', price: 450 },
|
||||
]
|
||||
|
||||
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Maximum Price</span>
|
||||
<span className="font-semibold text-gray-900">${maxPrice}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxPrice}
|
||||
min={0}
|
||||
max={1000}
|
||||
step={50}
|
||||
onChange={setMaxPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-3 text-sm font-medium text-gray-700">
|
||||
Showing {filteredProducts.length} of {products.length} products
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredProducts.map(product => (
|
||||
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<span className="text-sm">{product.name}</span>
|
||||
<span className="font-semibold text-gray-900">${product.price}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PriceRangeFilter: Story = {
|
||||
render: () => <PriceRangeFilterDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Temperature selector
|
||||
const TemperatureSelectorDemo = () => {
|
||||
const [temperature, setTemperature] = useState(22)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
|
||||
<div className="mb-6">
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={16}
|
||||
max={30}
|
||||
step={0.5}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Celsius</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{temperature}°C</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-orange-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{fahrenheit}°F</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-xs text-gray-500">
|
||||
{temperature < 18 && '🥶 Too cold'}
|
||||
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
|
||||
{temperature > 24 && '🥵 Too warm'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureSelector: Story = {
|
||||
render: () => <TemperatureSelectorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Progress/completion slider
|
||||
const ProgressSliderDemo = () => {
|
||||
const [progress, setProgress] = useState(65)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
|
||||
<Slider
|
||||
value={progress}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setProgress}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Progress</span>
|
||||
<span className="text-lg font-bold text-blue-600">{progress}%</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
|
||||
<span className="text-xs text-gray-500">25%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
|
||||
<span className="text-xs text-gray-500">50%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
|
||||
<span className="text-xs text-gray-500">75%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
|
||||
<span className="text-xs text-gray-500">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProgressSlider: Story = {
|
||||
render: () => <ProgressSliderDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Zoom control
|
||||
const ZoomControlDemo = () => {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.max(50, zoom - 10))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
onChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.min(200, zoom + 10))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>50%</span>
|
||||
<span className="text-lg font-semibold">{zoom}%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
|
||||
<div className="text-sm">Preview content</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ZoomControl: Story = {
|
||||
render: () => <ZoomControlDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - AI model parameters
|
||||
const AIModelParametersDemo = () => {
|
||||
const [temperature, setTemperature] = useState(0.7)
|
||||
const [maxTokens, setMaxTokens] = useState(2000)
|
||||
const [topP, setTopP] = useState(0.9)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Temperature</label>
|
||||
<span className="text-sm font-semibold">{temperature}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Controls randomness. Lower is more focused, higher is more creative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
|
||||
<span className="text-sm font-semibold">{maxTokens}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxTokens}
|
||||
min={100}
|
||||
max={4000}
|
||||
step={100}
|
||||
onChange={setMaxTokens}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Maximum length of generated response.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Top P</label>
|
||||
<span className="text-sm font-semibold">{topP}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={topP}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={setTopP}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Nucleus sampling threshold.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
|
||||
<div><strong>Temperature:</strong> {temperature}</div>
|
||||
<div><strong>Max Tokens:</strong> {maxTokens}</div>
|
||||
<div><strong>Top P:</strong> {topP}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIModelParameters: Story = {
|
||||
render: () => <AIModelParametersDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Image quality selector
|
||||
const ImageQualitySelectorDemo = () => {
|
||||
const [quality, setQuality] = useState(80)
|
||||
|
||||
const getQualityLabel = (q: number) => {
|
||||
if (q < 50) return 'Low'
|
||||
if (q < 70) return 'Medium'
|
||||
if (q < 90) return 'High'
|
||||
return 'Maximum'
|
||||
}
|
||||
|
||||
const estimatedSize = Math.round((quality / 100) * 5)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
|
||||
<Slider
|
||||
value={quality}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
onChange={setQuality}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">Quality</div>
|
||||
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
|
||||
<div className="text-xs text-gray-500">{quality}%</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">File Size</div>
|
||||
<div className="text-lg font-semibold">~{estimatedSize} MB</div>
|
||||
<div className="text-xs text-gray-500">Estimated</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageQualitySelector: Story = {
|
||||
render: () => <ImageQualitySelectorDemo />,
|
||||
}
|
||||
|
||||
// Multiple sliders
|
||||
const MultipleSlidersDemo = () => {
|
||||
const [red, setRed] = useState(128)
|
||||
const [green, setGreen] = useState(128)
|
||||
const [blue, setBlue] = useState(128)
|
||||
|
||||
const rgbColor = `rgb(${red}, ${green}, ${blue})`
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-red-600">Red</label>
|
||||
<span className="text-sm font-semibold">{red}</span>
|
||||
</div>
|
||||
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-green-600">Green</label>
|
||||
<span className="text-sm font-semibold">{green}</span>
|
||||
</div>
|
||||
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-blue-600">Blue</label>
|
||||
<span className="text-sm font-semibold">{blue}</span>
|
||||
</div>
|
||||
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div
|
||||
className="h-24 w-24 rounded-lg border-2 border-gray-300"
|
||||
style={{ backgroundColor: rgbColor }}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<div className="mb-1 text-xs text-gray-600">Color Value</div>
|
||||
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-500">
|
||||
#{red.toString(16).padStart(2, '0')}
|
||||
{green.toString(16).padStart(2, '0')}
|
||||
{blue.toString(16).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleSliders: Story = {
|
||||
render: () => <MultipleSlidersDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
626
web/app/components/base/switch/index.stories.tsx
Normal file
626
web/app/components/base/switch/index.stories.tsx
Normal file
@ -0,0 +1,626 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Switch from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Switch',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggle switch component with multiple sizes (xs, sm, md, lg, l). Built on Headless UI Switch with smooth animations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'l'],
|
||||
description: 'Switch size',
|
||||
},
|
||||
defaultValue: {
|
||||
control: 'boolean',
|
||||
description: 'Default checked state',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Switch>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SwitchDemo = (args: any) => {
|
||||
const [enabled, setEnabled] = useState(args.defaultValue || false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
defaultValue={enabled}
|
||||
onChange={(value) => {
|
||||
setEnabled(value)
|
||||
console.log('Switch toggled:', value)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state (off)
|
||||
export const Default: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Default on
|
||||
export const DefaultOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled off
|
||||
export const DisabledOff: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled on
|
||||
export const DisabledOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
const SizeComparisonDemo = () => {
|
||||
const [states, setStates] = useState({
|
||||
xs: false,
|
||||
sm: false,
|
||||
md: true,
|
||||
lg: true,
|
||||
l: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" defaultValue={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" defaultValue={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" defaultValue={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Medium (md)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="l" defaultValue={states.l} onChange={v => setStates({ ...states, l: v })} />
|
||||
<span className="text-sm text-gray-700">Large (l)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" defaultValue={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Large (lg)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// With labels
|
||||
const WithLabelsDemo = () => {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive email updates about your account</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithLabels: Story = {
|
||||
render: () => <WithLabelsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Settings panel
|
||||
const SettingsPanelDemo = () => {
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
autoSave: true,
|
||||
darkMode: false,
|
||||
analytics: false,
|
||||
emailUpdates: true,
|
||||
})
|
||||
|
||||
const updateSetting = (key: string, value: boolean) => {
|
||||
setSettings({ ...settings, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Application Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Receive push notifications on your device</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.notifications}
|
||||
onChange={v => updateSetting('notifications', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Auto-Save</div>
|
||||
<div className="text-xs text-gray-500">Automatically save changes as you work</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.autoSave}
|
||||
onChange={v => updateSetting('autoSave', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Dark Mode</div>
|
||||
<div className="text-xs text-gray-500">Use dark theme for the interface</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.darkMode}
|
||||
onChange={v => updateSetting('darkMode', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Analytics</div>
|
||||
<div className="text-xs text-gray-500">Help us improve by sharing usage data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.analytics}
|
||||
onChange={v => updateSetting('analytics', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email Updates</div>
|
||||
<div className="text-xs text-gray-500">Receive product updates via email</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.emailUpdates}
|
||||
onChange={v => updateSetting('emailUpdates', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsPanel: Story = {
|
||||
render: () => <SettingsPanelDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Privacy controls
|
||||
const PrivacyControlsDemo = () => {
|
||||
const [privacy, setPrivacy] = useState({
|
||||
profilePublic: false,
|
||||
showEmail: false,
|
||||
allowMessages: true,
|
||||
shareActivity: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Privacy Settings</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Control who can see your information</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Public Profile</div>
|
||||
<div className="text-xs text-gray-500">Make your profile visible to everyone</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.profilePublic}
|
||||
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Show Email Address</div>
|
||||
<div className="text-xs text-gray-500">Display your email on your profile</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.showEmail}
|
||||
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Allow Direct Messages</div>
|
||||
<div className="text-xs text-gray-500">Let others send you private messages</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.allowMessages}
|
||||
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Share Activity</div>
|
||||
<div className="text-xs text-gray-500">Show your recent activity to connections</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.shareActivity}
|
||||
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PrivacyControls: Story = {
|
||||
render: () => <PrivacyControlsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Feature toggles
|
||||
const FeatureTogglesDemo = () => {
|
||||
const [features, setFeatures] = useState({
|
||||
betaFeatures: false,
|
||||
experimentalUI: false,
|
||||
advancedMode: true,
|
||||
developerTools: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Feature Flags</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🧪</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Beta Features</div>
|
||||
<div className="text-xs text-gray-500">Access experimental functionality</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.betaFeatures}
|
||||
onChange={v => setFeatures({ ...features, betaFeatures: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🎨</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Experimental UI</div>
|
||||
<div className="text-xs text-gray-500">Try the new interface design</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.experimentalUI}
|
||||
onChange={v => setFeatures({ ...features, experimentalUI: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">⚡</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Advanced Mode</div>
|
||||
<div className="text-xs text-gray-500">Show advanced configuration options</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.advancedMode}
|
||||
onChange={v => setFeatures({ ...features, advancedMode: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🔧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Developer Tools</div>
|
||||
<div className="text-xs text-gray-500">Enable debugging and inspection tools</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.developerTools}
|
||||
onChange={v => setFeatures({ ...features, developerTools: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeatureToggles: Story = {
|
||||
render: () => <FeatureTogglesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Notification preferences
|
||||
const NotificationPreferencesDemo = () => {
|
||||
const [notifications, setNotifications] = useState({
|
||||
email: true,
|
||||
push: true,
|
||||
sms: false,
|
||||
desktop: true,
|
||||
})
|
||||
|
||||
const allEnabled = Object.values(notifications).every(v => v)
|
||||
const someEnabled = Object.values(notifications).some(v => v)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Notification Channels</h3>
|
||||
<div className="text-xs text-gray-500">
|
||||
{allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Email</div>
|
||||
<div className="text-xs text-gray-500">Receive notifications via email</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.email}
|
||||
onChange={v => setNotifications({ ...notifications, email: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🔔</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
|
||||
<div className="text-xs text-gray-500">Mobile and browser push notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.push}
|
||||
onChange={v => setNotifications({ ...notifications, push: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💬</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">SMS Messages</div>
|
||||
<div className="text-xs text-gray-500">Receive text message notifications</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.sms}
|
||||
onChange={v => setNotifications({ ...notifications, sms: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">💻</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">Desktop Alerts</div>
|
||||
<div className="text-xs text-gray-500">Show desktop notification popups</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.desktop}
|
||||
onChange={v => setNotifications({ ...notifications, desktop: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotificationPreferences: Story = {
|
||||
render: () => <NotificationPreferencesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - API access control
|
||||
const APIAccessControlDemo = () => {
|
||||
const [access, setAccess] = useState({
|
||||
readAccess: true,
|
||||
writeAccess: true,
|
||||
deleteAccess: false,
|
||||
adminAccess: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">API Permissions</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Configure access levels for API key</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-green-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-green-600">✓</span> Read Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">View resources and data</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.readAccess}
|
||||
onChange={v => setAccess({ ...access, readAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-blue-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-blue-600">✎</span> Write Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Create and update resources</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.writeAccess}
|
||||
onChange={v => setAccess({ ...access, writeAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-red-600">🗑</span> Delete Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Remove resources permanently</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.deleteAccess}
|
||||
onChange={v => setAccess({ ...access, deleteAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg bg-purple-50 p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||
<span className="text-purple-600">⚡</span> Admin Access
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Full administrative privileges</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.adminAccess}
|
||||
onChange={v => setAccess({ ...access, adminAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const APIAccessControl: Story = {
|
||||
render: () => <APIAccessControlDemo />,
|
||||
}
|
||||
|
||||
// Compact list with switches
|
||||
const CompactListDemo = () => {
|
||||
const [items, setItems] = useState([
|
||||
{ id: 1, name: 'Feature A', enabled: true },
|
||||
{ id: 2, name: 'Feature B', enabled: false },
|
||||
{ id: 3, name: 'Feature C', enabled: true },
|
||||
{ id: 4, name: 'Feature D', enabled: false },
|
||||
{ id: 5, name: 'Feature E', enabled: true },
|
||||
])
|
||||
|
||||
const toggleItem = (id: number) => {
|
||||
setItems(items.map(item =>
|
||||
item.id === id ? { ...item, enabled: !item.enabled } : item,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Quick Toggles</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-700">{item.name}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
defaultValue={item.enabled}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompactList: Story = {
|
||||
render: () => <CompactListDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
516
web/app/components/base/tag-input/index.stories.tsx
Normal file
516
web/app/components/base/tag-input/index.stories.tsx
Normal file
@ -0,0 +1,516 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import TagInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/TagInput',
|
||||
component: TagInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Tag input component for managing a list of string tags. Features auto-sizing input, duplicate detection, length validation (max 20 chars), and customizable confirm key (Enter or Tab).',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
items: {
|
||||
control: 'object',
|
||||
description: 'Array of tag strings',
|
||||
},
|
||||
disableAdd: {
|
||||
control: 'boolean',
|
||||
description: 'Disable adding new tags',
|
||||
},
|
||||
disableRemove: {
|
||||
control: 'boolean',
|
||||
description: 'Disable removing tags',
|
||||
},
|
||||
customizedConfirmKey: {
|
||||
control: 'select',
|
||||
options: ['Enter', 'Tab'],
|
||||
description: 'Key to confirm tag creation',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Input placeholder text',
|
||||
},
|
||||
required: {
|
||||
control: 'boolean',
|
||||
description: 'Require non-empty tags',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof TagInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const TagInputDemo = (args: any) => {
|
||||
const [items, setItems] = useState(args.items || [])
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<TagInput
|
||||
{...args}
|
||||
items={items}
|
||||
onChange={(newItems) => {
|
||||
setItems(newItems)
|
||||
console.log('Tags updated:', newItems)
|
||||
}}
|
||||
/>
|
||||
{items.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Current Tags ({items.length}):</div>
|
||||
<div className="font-mono text-sm text-gray-800">
|
||||
{JSON.stringify(items, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state (empty)
|
||||
export const Default: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: [],
|
||||
placeholder: 'Add a tag...',
|
||||
customizedConfirmKey: 'Enter',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial tags
|
||||
export const WithInitialTags: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: ['React', 'TypeScript', 'Next.js'],
|
||||
placeholder: 'Add more tags...',
|
||||
customizedConfirmKey: 'Enter',
|
||||
},
|
||||
}
|
||||
|
||||
// Tab to confirm
|
||||
export const TabToConfirm: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: ['keyword1', 'keyword2'],
|
||||
placeholder: 'Press Tab to add...',
|
||||
customizedConfirmKey: 'Tab',
|
||||
},
|
||||
}
|
||||
|
||||
// Disable remove
|
||||
export const DisableRemove: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: ['Permanent', 'Tags', 'Cannot be removed'],
|
||||
disableRemove: true,
|
||||
customizedConfirmKey: 'Enter',
|
||||
},
|
||||
}
|
||||
|
||||
// Disable add
|
||||
export const DisableAdd: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: ['Read', 'Only', 'Mode'],
|
||||
disableAdd: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Required tags
|
||||
export const RequiredTags: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: [],
|
||||
placeholder: 'Add required tags...',
|
||||
required: true,
|
||||
customizedConfirmKey: 'Enter',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Skill tags
|
||||
const SkillTagsDemo = () => {
|
||||
const [skills, setSkills] = useState(['JavaScript', 'React', 'Node.js'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Your Skills</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Add skills to your profile</p>
|
||||
<TagInput
|
||||
items={skills}
|
||||
onChange={setSkills}
|
||||
placeholder="Add a skill..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
<div className="mt-4 text-xs text-gray-500">
|
||||
💡 Press Enter to add a tag. Max 20 characters. No duplicates allowed.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SkillTags: Story = {
|
||||
render: () => <SkillTagsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Email tags
|
||||
const EmailTagsDemo = () => {
|
||||
const [recipients, setRecipients] = useState(['john@example.com', 'jane@example.com'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Send Email</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">To:</label>
|
||||
<TagInput
|
||||
items={recipients}
|
||||
onChange={setRecipients}
|
||||
placeholder="Add recipient email..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Subject:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Enter subject..."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-gray-700">
|
||||
<strong>Recipients ({recipients.length}):</strong> {recipients.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EmailTags: Story = {
|
||||
render: () => <EmailTagsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Search filters
|
||||
const SearchFiltersDemo = () => {
|
||||
const [filters, setFilters] = useState(['urgent', 'pending'])
|
||||
|
||||
const mockResults = [
|
||||
{ id: 1, title: 'Task 1', tags: ['urgent', 'pending'] },
|
||||
{ id: 2, title: 'Task 2', tags: ['urgent'] },
|
||||
{ id: 3, title: 'Task 3', tags: ['pending', 'review'] },
|
||||
{ id: 4, title: 'Task 4', tags: ['completed'] },
|
||||
]
|
||||
|
||||
const filteredResults = filters.length > 0
|
||||
? mockResults.filter(item => filters.some(filter => item.tags.includes(filter)))
|
||||
: mockResults
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Filter Tasks</h3>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Active Filters:</label>
|
||||
<TagInput
|
||||
items={filters}
|
||||
onChange={setFilters}
|
||||
placeholder="Add filter tag..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-3 text-sm font-medium text-gray-700">
|
||||
Results ({filteredResults.length} of {mockResults.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredResults.map(item => (
|
||||
<div key={item.id} className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-sm font-medium">{item.title}</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
{item.tags.map(tag => (
|
||||
<span key={tag} className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchFilters: Story = {
|
||||
render: () => <SearchFiltersDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Product categories
|
||||
const ProductCategoriesDemo = () => {
|
||||
const [categories, setCategories] = useState(['Electronics', 'Computers'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Product Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Product Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Enter product name..."
|
||||
defaultValue="Laptop Pro 15"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Categories</label>
|
||||
<TagInput
|
||||
items={categories}
|
||||
onChange={setCategories}
|
||||
placeholder="Add category..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add relevant categories to help users find this product
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="Enter product description..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCategories: Story = {
|
||||
render: () => <ProductCategoriesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Keyword extraction
|
||||
const KeywordExtractionDemo = () => {
|
||||
const [keywords, setKeywords] = useState(['AI', 'machine learning', 'automation'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">SEO Keywords</h3>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Meta Keywords
|
||||
</label>
|
||||
<TagInput
|
||||
items={keywords}
|
||||
onChange={setKeywords}
|
||||
placeholder="Add keyword..."
|
||||
customizedConfirmKey="Enter"
|
||||
required
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Add relevant keywords for search engine optimization (max 20 characters each)
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Meta Tag Preview:</div>
|
||||
<code className="text-xs text-gray-700">
|
||||
<meta name="keywords" content="{keywords.join(', ')}" />
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const KeywordExtraction: Story = {
|
||||
render: () => <KeywordExtractionDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Tags with suggestions
|
||||
const TagsWithSuggestionsDemo = () => {
|
||||
const [tags, setTags] = useState(['design', 'frontend'])
|
||||
const suggestions = ['backend', 'devops', 'mobile', 'testing', 'security']
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Project Tags</h3>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Add Tags
|
||||
</label>
|
||||
<TagInput
|
||||
items={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Type or select..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Suggestions:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions
|
||||
.filter(s => !tags.includes(s))
|
||||
.map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
className="cursor-pointer rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
|
||||
onClick={() => setTags([...tags, suggestion])}
|
||||
>
|
||||
+ {suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TagsWithSuggestions: Story = {
|
||||
render: () => <TagsWithSuggestionsDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Stop sequences (Tab mode)
|
||||
const StopSequencesDemo = () => {
|
||||
const [stopSequences, setStopSequences] = useState(['Human:', 'AI:'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">AI Model Configuration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
defaultValue="0.7"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Stop Sequences
|
||||
</label>
|
||||
<TagInput
|
||||
items={stopSequences}
|
||||
onChange={setStopSequences}
|
||||
placeholder="Press Tab to add..."
|
||||
customizedConfirmKey="Tab"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
💡 Press Tab to add. Press Enter to insert ↵ (newline) in sequence.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue="2000"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StopSequences: Story = {
|
||||
render: () => <StopSequencesDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Multi-language tags
|
||||
const MultiLanguageTagsDemo = () => {
|
||||
const [tags, setTags] = useState(['Hello', '你好', 'Bonjour', 'Hola'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Internationalization</h3>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Greeting Translations
|
||||
</label>
|
||||
<TagInput
|
||||
items={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Add translation..."
|
||||
customizedConfirmKey="Enter"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Supports multi-language characters (max 20 characters)
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className="rounded bg-gray-50 p-2 text-sm">
|
||||
<span className="font-mono">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultiLanguageTags: Story = {
|
||||
render: () => <MultiLanguageTagsDemo />,
|
||||
}
|
||||
|
||||
// Validation showcase
|
||||
const ValidationShowcaseDemo = () => {
|
||||
const [tags, setTags] = useState(['valid-tag'])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Validation Rules</h3>
|
||||
<TagInput
|
||||
items={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Try adding tags..."
|
||||
customizedConfirmKey="Enter"
|
||||
required
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-blue-900">Validation Rules:</div>
|
||||
<ul className="space-y-1 text-xs text-blue-800">
|
||||
<li>✓ Maximum 20 characters per tag</li>
|
||||
<li>✓ No duplicate tags allowed</li>
|
||||
<li>✓ Cannot add empty tags (when required=true)</li>
|
||||
<li>✓ Whitespace is automatically trimmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-yellow-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-yellow-900">Try these:</div>
|
||||
<ul className="space-y-1 text-xs text-yellow-800">
|
||||
<li>• Add "valid-tag" → Shows duplicate error</li>
|
||||
<li>• Add empty string → Shows empty error</li>
|
||||
<li>• Add "this-is-a-very-long-tag-name" → Shows length error</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ValidationShowcase: Story = {
|
||||
render: () => <ValidationShowcaseDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <TagInputDemo {...args} />,
|
||||
args: {
|
||||
items: ['tag1', 'tag2'],
|
||||
placeholder: 'Add a tag...',
|
||||
customizedConfirmKey: 'Enter',
|
||||
disableAdd: false,
|
||||
disableRemove: false,
|
||||
required: false,
|
||||
},
|
||||
}
|
||||
535
web/app/components/base/textarea/index.stories.tsx
Normal file
535
web/app/components/base/textarea/index.stories.tsx
Normal file
@ -0,0 +1,535 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Textarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'regular', 'large'],
|
||||
description: 'Textarea size',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
destructive: {
|
||||
control: 'boolean',
|
||||
description: 'Error/destructive state',
|
||||
},
|
||||
rows: {
|
||||
control: 'number',
|
||||
description: 'Number of visible text rows',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Textarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const TextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<Textarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Textarea changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Character count: <span className="font-semibold">{value.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
// Small size
|
||||
export const SmallSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'small',
|
||||
placeholder: 'Small textarea...',
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'large',
|
||||
placeholder: 'Large textarea...',
|
||||
rows: 5,
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This is some initial text content.\n\nIt spans multiple lines.',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This textarea is disabled and cannot be edited.',
|
||||
disabled: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Destructive/error state
|
||||
export const DestructiveState: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This content has an error.',
|
||||
destructive: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [small, setSmall] = useState('')
|
||||
const [regular, setRegular] = useState('')
|
||||
const [large, setLarge] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={small}
|
||||
onChange={e => setSmall(e.target.value)}
|
||||
placeholder="Small textarea..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
|
||||
<Textarea
|
||||
size="regular"
|
||||
value={regular}
|
||||
onChange={e => setRegular(e.target.value)}
|
||||
placeholder="Regular textarea..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
|
||||
<Textarea
|
||||
size="large"
|
||||
value={large}
|
||||
onChange={e => setLarge(e.target.value)}
|
||||
placeholder="Large textarea..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
}
|
||||
|
||||
// State comparison
|
||||
const StateComparisonDemo = () => {
|
||||
const [normal, setNormal] = useState('Normal state')
|
||||
const [error, setError] = useState('Error state')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
|
||||
<Textarea
|
||||
value={normal}
|
||||
onChange={e => setNormal(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
|
||||
<Textarea
|
||||
value={error}
|
||||
onChange={e => setError(e.target.value)}
|
||||
destructive
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
|
||||
<Textarea
|
||||
value="Disabled state"
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => <StateComparisonDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Comment form
|
||||
const CommentFormDemo = () => {
|
||||
const [comment, setComment] = useState('')
|
||||
const maxLength = 500
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
rows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
{comment.length} / {maxLength} characters
|
||||
</span>
|
||||
<button
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={comment.trim().length === 0}
|
||||
>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentForm: Story = {
|
||||
render: () => <CommentFormDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Feedback form
|
||||
const FeedbackFormDemo = () => {
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
|
||||
<Textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
placeholder="Tell us what you think..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeedbackForm: Story = {
|
||||
render: () => <FeedbackFormDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Code snippet
|
||||
const CodeSnippetDemo = () => {
|
||||
const [code, setCode] = useState(`function hello() {
|
||||
console.log("Hello, world!");
|
||||
}`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
|
||||
<Textarea
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="font-mono"
|
||||
rows={8}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Run Code
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeSnippet: Story = {
|
||||
render: () => <CodeSnippetDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Message composer
|
||||
const MessageComposerDemo = () => {
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Recipient name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Message subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Type your message here..."
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Send Message
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Save Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageComposer: Story = {
|
||||
render: () => <MessageComposerDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Bio editor
|
||||
const BioEditorDemo = () => {
|
||||
const [bio, setBio] = useState('Software developer passionate about building great products.')
|
||||
const maxLength = 200
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
|
||||
<Textarea
|
||||
value={bio}
|
||||
onChange={e => setBio(e.target.value.slice(0, maxLength))}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
|
||||
{bio.length} / {maxLength} characters
|
||||
</span>
|
||||
{bio.length > maxLength * 0.9 && (
|
||||
<span className="text-orange-600">
|
||||
{maxLength - bio.length} characters remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
|
||||
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BioEditor: Story = {
|
||||
render: () => <BioEditorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - JSON editor
|
||||
const JSONEditorDemo = () => {
|
||||
const [json, setJson] = useState(`{
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"email": "john@example.com"
|
||||
}`)
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
const validateJSON = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
setIsValid(true)
|
||||
}
|
||||
catch {
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">JSON Editor</h3>
|
||||
<span className={`rounded px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{isValid ? '✓ Valid' : '✗ Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={json}
|
||||
onChange={(e) => {
|
||||
setJson(e.target.value)
|
||||
validateJSON(e.target.value)
|
||||
}}
|
||||
className="font-mono"
|
||||
destructive={!isValid}
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
|
||||
Save JSON
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(json), null, 2)
|
||||
setJson(formatted)
|
||||
}
|
||||
catch {
|
||||
// Invalid JSON, do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JSONEditor: Story = {
|
||||
render: () => <JSONEditorDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Task description
|
||||
const TaskDescriptionDemo = () => {
|
||||
const [title, setTitle] = useState('Implement user authentication')
|
||||
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe the task in detail..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
|
||||
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
|
||||
<option>Low</option>
|
||||
<option>Medium</option>
|
||||
<option>High</option>
|
||||
<option>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskDescription: Story = {
|
||||
render: () => <TaskDescriptionDemo />,
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
},
|
||||
}
|
||||
499
web/app/components/base/voice-input/index.stories.tsx
Normal file
499
web/app/components/base/voice-input/index.stories.tsx
Normal file
@ -0,0 +1,499 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Mock component since VoiceInput requires browser APIs and service dependencies
|
||||
const VoiceInputMock = ({ onConverted, onCancel }: any) => {
|
||||
const [state, setState] = useState<'idle' | 'recording' | 'converting'>('recording')
|
||||
const [duration, setDuration] = useState(0)
|
||||
|
||||
// Simulate recording
|
||||
useState(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDuration(d => d + 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const handleStop = () => {
|
||||
setState('converting')
|
||||
setTimeout(() => {
|
||||
onConverted('This is simulated transcribed text from voice input.')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const minutes = Math.floor(duration / 60)
|
||||
const seconds = duration % 60
|
||||
|
||||
return (
|
||||
<div className="relative h-16 w-full overflow-hidden rounded-xl border-2 border-primary-600">
|
||||
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
|
||||
{/* Waveform visualization placeholder */}
|
||||
<div className="absolute bottom-0 left-0 flex h-4 w-full items-end gap-[3px] px-2">
|
||||
{new Array(40).fill().map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[2px] rounded-t bg-blue-200"
|
||||
style={{
|
||||
height: `${Math.random() * 100}%`,
|
||||
animation: state === 'recording' ? 'pulse 1s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state === 'converting' && (
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary-700 border-t-transparent" />
|
||||
)}
|
||||
|
||||
<div className="z-10 grow">
|
||||
{state === 'recording' && (
|
||||
<div className="text-sm text-gray-500">Speaking...</div>
|
||||
)}
|
||||
{state === 'converting' && (
|
||||
<div className="text-sm text-gray-500">Converting to text...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state === 'recording' && (
|
||||
<div
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<div className="h-5 w-5 rounded bg-primary-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'converting' && (
|
||||
<div
|
||||
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<span className="text-lg text-gray-500">×</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`w-[45px] pl-1 text-xs font-medium ${duration > 500 ? 'text-red-600' : 'text-gray-700'}`}>
|
||||
{`0${minutes}:${seconds >= 10 ? seconds : `0${seconds}`}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/VoiceInput',
|
||||
component: VoiceInputMock,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof VoiceInputMock>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Basic demo
|
||||
const VoiceInputDemo = () => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [transcription, setTranscription] = useState('')
|
||||
|
||||
const handleStartRecording = () => {
|
||||
setIsRecording(true)
|
||||
setTranscription('')
|
||||
}
|
||||
|
||||
const handleConverted = (text: string) => {
|
||||
setTranscription(text)
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRecording(false)
|
||||
setTranscription('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
{!isRecording && (
|
||||
<button
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white hover:bg-blue-700"
|
||||
onClick={handleStartRecording}
|
||||
>
|
||||
🎤 Start Voice Recording
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<VoiceInputMock
|
||||
onConverted={handleConverted}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{transcription && (
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Transcription:</div>
|
||||
<div className="text-sm text-gray-800">{transcription}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: () => <VoiceInputDemo />,
|
||||
}
|
||||
|
||||
// Recording state
|
||||
export const RecordingState: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '600px' }}>
|
||||
<VoiceInputMock
|
||||
onConverted={() => console.log('Converted')}
|
||||
onCancel={() => console.log('Cancelled')}
|
||||
/>
|
||||
<div className="mt-3 text-xs text-gray-500">
|
||||
Recording in progress with live waveform visualization
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Real-world example - Chat input with voice
|
||||
const ChatInputWithVoiceDemo = () => {
|
||||
const [message, setMessage] = useState('')
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Chat Interface</h3>
|
||||
|
||||
{/* Existing messages */}
|
||||
<div className="mb-4 h-64 space-y-3 overflow-y-auto">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-sm text-white">
|
||||
U
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="rounded-lg bg-gray-100 p-3 text-sm">
|
||||
Hello! How can I help you today?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500 text-sm text-white">
|
||||
A
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-sm">
|
||||
I can assist you with various tasks. What would you like to know?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="space-y-3">
|
||||
{!isRecording ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-3 text-sm"
|
||||
placeholder="Type a message..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded-lg bg-gray-100 px-4 py-3 hover:bg-gray-200"
|
||||
onClick={() => setIsRecording(true)}
|
||||
title="Voice input"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
<button className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<VoiceInputMock
|
||||
onConverted={(text: string) => {
|
||||
setMessage(text)
|
||||
setIsRecording(false)
|
||||
}}
|
||||
onCancel={() => setIsRecording(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ChatInputWithVoice: Story = {
|
||||
render: () => <ChatInputWithVoiceDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Search with voice
|
||||
const SearchWithVoiceDemo = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Voice Search</h3>
|
||||
|
||||
{!isRecording ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-sm"
|
||||
placeholder="Search or use voice..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
🔍
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg bg-blue-600 px-4 py-3 text-white hover:bg-blue-700"
|
||||
onClick={() => setIsRecording(true)}
|
||||
>
|
||||
🎤 Voice Search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<VoiceInputMock
|
||||
onConverted={(text: string) => {
|
||||
setSearchQuery(text)
|
||||
setIsRecording(false)
|
||||
}}
|
||||
onCancel={() => setIsRecording(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchQuery && !isRecording && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-blue-900">
|
||||
Searching for: <strong>{searchQuery}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SearchWithVoice: Story = {
|
||||
render: () => <SearchWithVoiceDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Note taking
|
||||
const NoteTakingDemo = () => {
|
||||
const [notes, setNotes] = useState<string[]>([])
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Voice Notes</h3>
|
||||
<span className="text-sm text-gray-500">{notes.length} notes</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-3 font-medium text-white hover:bg-red-600"
|
||||
onClick={() => setIsRecording(true)}
|
||||
>
|
||||
<span className="text-xl">🎤</span>
|
||||
Record Voice Note
|
||||
</button>
|
||||
) : (
|
||||
<VoiceInputMock
|
||||
onConverted={(text: string) => {
|
||||
setNotes([...notes, text])
|
||||
setIsRecording(false)
|
||||
}}
|
||||
onCancel={() => setIsRecording(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
No notes yet. Click the button above to start recording.
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note, index) => (
|
||||
<div key={index} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 text-xs text-gray-500">Note {index + 1}</div>
|
||||
<div className="text-sm text-gray-800">{note}</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
onClick={() => setNotes(notes.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NoteTaking: Story = {
|
||||
render: () => <NoteTakingDemo />,
|
||||
}
|
||||
|
||||
// Real-world example - Form with voice
|
||||
const FormWithVoiceDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
const [activeField, setActiveField] = useState<'name' | 'description' | null>(null)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Create Product</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Product Name
|
||||
</label>
|
||||
{activeField === 'name' ? (
|
||||
<VoiceInputMock
|
||||
onConverted={(text: string) => {
|
||||
setFormData({ ...formData, name: text })
|
||||
setActiveField(null)
|
||||
}}
|
||||
onCancel={() => setActiveField(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Enter product name..."
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="rounded-lg bg-gray-100 px-3 py-2 hover:bg-gray-200"
|
||||
onClick={() => setActiveField('name')}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
Description
|
||||
</label>
|
||||
{activeField === 'description' ? (
|
||||
<VoiceInputMock
|
||||
onConverted={(text: string) => {
|
||||
setFormData({ ...formData, description: text })
|
||||
setActiveField(null)
|
||||
}}
|
||||
onCancel={() => setActiveField(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
rows={4}
|
||||
placeholder="Enter product description..."
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="w-full rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200"
|
||||
onClick={() => setActiveField('description')}
|
||||
>
|
||||
🎤 Use Voice Input
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
|
||||
Create Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormWithVoice: Story = {
|
||||
render: () => <FormWithVoiceDemo />,
|
||||
}
|
||||
|
||||
// Features showcase
|
||||
export const FeaturesShowcase: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Voice Input Features</h3>
|
||||
|
||||
<div className="mb-6">
|
||||
<VoiceInputMock
|
||||
onConverted={() => undefined}
|
||||
onCancel={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-blue-900">🎤 Audio Recording</div>
|
||||
<ul className="space-y-1 text-xs text-blue-800">
|
||||
<li>• Uses js-audio-recorder for browser-based recording</li>
|
||||
<li>• 16kHz sample rate, 16-bit, mono channel</li>
|
||||
<li>• Converts to MP3 format for transmission</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-green-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-green-900">📊 Waveform Visualization</div>
|
||||
<ul className="space-y-1 text-xs text-green-800">
|
||||
<li>• Real-time audio level display using Canvas API</li>
|
||||
<li>• Animated bars showing voice amplitude</li>
|
||||
<li>• Visual feedback during recording</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-purple-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-purple-900">⏱️ Time Limits</div>
|
||||
<ul className="space-y-1 text-xs text-purple-800">
|
||||
<li>• Maximum recording duration: 10 minutes (600 seconds)</li>
|
||||
<li>• Timer turns red after 8:20 (500 seconds)</li>
|
||||
<li>• Automatic stop at max duration</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-orange-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-orange-900">🔄 Audio-to-Text Conversion</div>
|
||||
<ul className="space-y-1 text-xs text-orange-800">
|
||||
<li>• Server-side speech-to-text processing</li>
|
||||
<li>• Optional word timestamps support</li>
|
||||
<li>• Loading state during conversion</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
491
web/app/components/base/with-input-validation/index.stories.tsx
Normal file
491
web/app/components/base/with-input-validation/index.stories.tsx
Normal file
@ -0,0 +1,491 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { z } from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
// Sample components to wrap with validation
|
||||
type UserCardProps = {
|
||||
name: string
|
||||
email: string
|
||||
age: number
|
||||
role?: string
|
||||
}
|
||||
|
||||
const UserCard = ({ name, email, age, role }: UserCardProps) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>Email: {email}</div>
|
||||
<div>Age: {age}</div>
|
||||
{role && <div>Role: {role}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProductCardProps = {
|
||||
name: string
|
||||
price: number
|
||||
category: string
|
||||
inStock: boolean
|
||||
}
|
||||
|
||||
const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-xl font-bold text-green-600">${price}</div>
|
||||
<div className="text-gray-600">Category: {category}</div>
|
||||
<div className={inStock ? 'text-green-600' : 'text-red-600'}>
|
||||
{inStock ? '✓ In Stock' : '✗ Out of Stock'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create validated versions
|
||||
const userSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
age: z.number().min(0).max(150),
|
||||
})
|
||||
|
||||
const productSchema = z.object({
|
||||
name: z.string().min(1, 'Product name required'),
|
||||
price: z.number().positive('Price must be positive'),
|
||||
category: z.string().min(1, 'Category required'),
|
||||
inStock: z.boolean(),
|
||||
})
|
||||
|
||||
const ValidatedUserCard = withValidation(UserCard, userSchema)
|
||||
const ValidatedProductCard = withValidation(ProductCard, productSchema)
|
||||
|
||||
const meta = {
|
||||
title: 'Base/WithInputValidation',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Higher-order component (HOC) for wrapping components with Zod schema validation. Validates props before rendering and returns null if validation fails, logging errors to console.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Valid data example
|
||||
export const ValidData: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-4 text-lg font-semibold">Valid Props (Renders Successfully)</h3>
|
||||
<ValidatedUserCard
|
||||
name="John Doe"
|
||||
email="john@example.com"
|
||||
age={30}
|
||||
role="Developer"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Invalid email
|
||||
export const InvalidEmail: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-4 text-lg font-semibold">Invalid Email (Returns null)</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Check console for validation error. Component won't render.
|
||||
</p>
|
||||
<ValidatedUserCard
|
||||
name="John Doe"
|
||||
email="invalid-email"
|
||||
age={30}
|
||||
role="Developer"
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
⚠️ Validation failed: Invalid email format
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Invalid age
|
||||
export const InvalidAge: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-4 text-lg font-semibold">Invalid Age (Returns null)</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Age must be between 0 and 150. Check console.
|
||||
</p>
|
||||
<ValidatedUserCard
|
||||
name="John Doe"
|
||||
email="john@example.com"
|
||||
age={200}
|
||||
role="Developer"
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
⚠️ Validation failed: Age must be ≤ 150
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Product validation - valid
|
||||
export const ValidProduct: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-4 text-lg font-semibold">Valid Product</h3>
|
||||
<ValidatedProductCard
|
||||
name="Laptop Pro"
|
||||
price={1299}
|
||||
category="Electronics"
|
||||
inStock={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Product validation - invalid price
|
||||
export const InvalidPrice: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '400px' }}>
|
||||
<h3 className="mb-4 text-lg font-semibold">Invalid Price (Returns null)</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Price must be positive. Check console.
|
||||
</p>
|
||||
<ValidatedProductCard
|
||||
name="Laptop Pro"
|
||||
price={-100}
|
||||
category="Electronics"
|
||||
inStock={true}
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||
⚠️ Validation failed: Price must be positive
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Comparison: validated vs unvalidated
|
||||
export const ValidationComparison: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '700px' }} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Without Validation</h3>
|
||||
<div className="space-y-3">
|
||||
<UserCard
|
||||
name="John Doe"
|
||||
email="invalid-email"
|
||||
age={200}
|
||||
role="Developer"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
⚠️ Renders with invalid data (no validation)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">With Validation (HOC)</h3>
|
||||
<div className="space-y-3">
|
||||
<ValidatedUserCard
|
||||
name="John Doe"
|
||||
email="invalid-email"
|
||||
age={200}
|
||||
role="Developer"
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
✓ Returns null when validation fails (check console)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Real-world example - Form submission
|
||||
export const FormSubmission: Story = {
|
||||
render: () => {
|
||||
const handleSubmit = (data: UserCardProps) => {
|
||||
console.log('Submitting:', data)
|
||||
}
|
||||
|
||||
const validData: UserCardProps = {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
age: 28,
|
||||
role: 'Designer',
|
||||
}
|
||||
|
||||
const invalidData: UserCardProps = {
|
||||
name: '',
|
||||
email: 'not-an-email',
|
||||
age: -5,
|
||||
role: 'Designer',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Form Submission with Validation</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Data</h4>
|
||||
<ValidatedUserCard {...validData} />
|
||||
<button
|
||||
className="mt-3 w-full rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
onClick={() => handleSubmit(validData)}
|
||||
>
|
||||
Submit Valid Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Data</h4>
|
||||
<ValidatedUserCard {...invalidData} />
|
||||
<button
|
||||
className="mt-3 w-full rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700"
|
||||
onClick={() => handleSubmit(invalidData)}
|
||||
>
|
||||
Try to Submit Invalid Data
|
||||
</button>
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
Component returns null, preventing invalid data rendering
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - API response validation
|
||||
export const APIResponseValidation: Story = {
|
||||
render: () => {
|
||||
const mockAPIResponses = [
|
||||
{
|
||||
name: 'Laptop',
|
||||
price: 999,
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
name: 'Invalid Product',
|
||||
price: -50, // Invalid: negative price
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
name: '', // Invalid: empty name
|
||||
price: 100,
|
||||
category: 'Electronics',
|
||||
inStock: false,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">API Response Validation</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Only valid products render. Invalid ones return null (check console).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{mockAPIResponses.map((product, index) => (
|
||||
<div key={index}>
|
||||
<ValidatedProductCard {...product} />
|
||||
{!product.name || product.price <= 0 ? (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
⚠️ Validation failed for product {index + 1}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Configuration validation
|
||||
export const ConfigurationValidation: Story = {
|
||||
render: () => {
|
||||
type ConfigPanelProps = {
|
||||
apiUrl: string
|
||||
timeout: number
|
||||
retries: number
|
||||
debug: boolean
|
||||
}
|
||||
|
||||
const ConfigPanel = ({ apiUrl, timeout, retries, debug }: ConfigPanelProps) => (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-base font-semibold">Configuration</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">API URL:</span>
|
||||
<span className="font-mono">{apiUrl}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Timeout:</span>
|
||||
<span>{timeout}ms</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Retries:</span>
|
||||
<span>{retries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Debug Mode:</span>
|
||||
<span>{debug ? '✓ Enabled' : '✗ Disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const configSchema = z.object({
|
||||
apiUrl: z.string().url('Must be valid URL'),
|
||||
timeout: z.number().min(0).max(30000),
|
||||
retries: z.number().min(0).max(5),
|
||||
debug: z.boolean(),
|
||||
})
|
||||
|
||||
const ValidatedConfigPanel = withValidation(ConfigPanel, configSchema)
|
||||
|
||||
const validConfig = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
debug: true,
|
||||
}
|
||||
|
||||
const invalidConfig = {
|
||||
apiUrl: 'not-a-url',
|
||||
timeout: 50000, // Too high
|
||||
retries: 10, // Too many
|
||||
debug: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Configuration</h4>
|
||||
<ValidatedConfigPanel {...validConfig} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Configuration</h4>
|
||||
<ValidatedConfigPanel {...invalidConfig} />
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
⚠️ Validation errors: Invalid URL, timeout too high, too many retries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// Usage documentation
|
||||
export const UsageDocumentation: Story = {
|
||||
render: () => (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-xl font-bold">withValidation HOC</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Purpose</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wraps React components with Zod schema validation for their props.
|
||||
Returns null and logs errors if validation fails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||||
{`import { z } from 'zod'
|
||||
import withValidation from './withValidation'
|
||||
|
||||
// Define your component
|
||||
const UserCard = ({ name, email, age }) => (
|
||||
<div>{name} - {email} - {age}</div>
|
||||
)
|
||||
|
||||
// Define validation schema
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
age: z.number().min(0).max(150),
|
||||
})
|
||||
|
||||
// Wrap with validation
|
||||
const ValidatedUserCard = withValidation(UserCard, schema)
|
||||
|
||||
// Use validated component
|
||||
<ValidatedUserCard
|
||||
name="John"
|
||||
email="john@example.com"
|
||||
age={30}
|
||||
/>`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Key Features</h4>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
|
||||
<li>Type-safe validation using Zod schemas</li>
|
||||
<li>Returns null on validation failure</li>
|
||||
<li>Logs validation errors to console</li>
|
||||
<li>Only validates props defined in schema</li>
|
||||
<li>Preserves all original props</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-900">Use Cases</h4>
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
|
||||
<li>API response validation before rendering</li>
|
||||
<li>Form data validation</li>
|
||||
<li>Configuration panel validation</li>
|
||||
<li>Preventing invalid data from reaching components</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Valid Data</h4>
|
||||
<ValidatedUserCard
|
||||
name="Alice Johnson"
|
||||
email="alice@example.com"
|
||||
age={25}
|
||||
role="Engineer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Invalid Data</h4>
|
||||
<ValidatedUserCard
|
||||
name="Bob"
|
||||
email="invalid-email"
|
||||
age={-10}
|
||||
role="Manager"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Open browser console to see validation errors
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@ -10,7 +10,7 @@ export enum Priority {
|
||||
topPriority = 'top-priority',
|
||||
}
|
||||
|
||||
export type BasicPlan = Plan.sandbox | Plan.professional | Plan.team
|
||||
export type BasicPlan = Plan.sandbox | Plan.professional | Plan.team | Plan.enterprise
|
||||
|
||||
export type PlanInfo = {
|
||||
level: number
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.18055 6.45828C7.52291 6.45828 8.61111 5.37008 8.61111 4.02772C8.61111 2.68536 7.52291 1.59717 6.18055 1.59717C4.8382 1.59717 3.75 2.68536 3.75 4.02772C3.75 5.37008 4.8382 6.45828 6.18055 6.45828Z" fill="#EF6820"/>
|
||||
<path d="M13.8192 6.45828C15.1616 6.45828 16.2498 5.37008 16.2498 4.02772C16.2498 2.68536 15.1616 1.59717 13.8192 1.59717C12.4769 1.59717 11.3887 2.68536 11.3887 4.02772C11.3887 5.37008 12.4769 6.45828 13.8192 6.45828Z" fill="#EF6820"/>
|
||||
<path d="M13.8193 7.84719C13.0627 7.84805 12.3185 8.03933 11.6552 8.40341C10.992 8.7675 10.4311 9.29267 10.0241 9.93053C10.5745 9.93695 11.1 10.1609 11.4858 10.5535C11.8716 10.9461 12.0864 11.4755 12.0831 12.0259C12.0799 12.5763 11.859 13.1031 11.4687 13.4911C11.0783 13.8792 10.5503 14.097 9.99984 14.097C9.44942 14.097 8.92135 13.8792 8.53101 13.4911C8.14066 13.1031 7.91976 12.5763 7.91655 12.0259C7.91334 11.4755 8.12808 10.9461 8.51387 10.5535C8.89966 10.1609 9.42515 9.93695 9.97554 9.93053C9.45127 9.10686 8.67371 8.47572 7.75983 8.13205C6.84596 7.78839 5.84519 7.75078 4.9081 8.0249C3.97101 8.29902 3.14828 8.87003 2.56368 9.65203C1.97908 10.434 1.66424 11.3847 1.66652 12.3611V16.875C1.66652 17.0591 1.73968 17.2358 1.86991 17.366C2.00015 17.4962 2.17678 17.5694 2.36096 17.5694H7.22207V15.8333L4.72207 13.9583C4.64911 13.9036 4.58765 13.835 4.54118 13.7566C4.49472 13.6781 4.46417 13.5912 4.45127 13.501C4.42522 13.3186 4.47267 13.1334 4.58318 12.9861C4.69369 12.8387 4.8582 12.7413 5.04053 12.7153C5.22285 12.6892 5.40806 12.7367 5.5554 12.8472L8.14776 14.7916H11.8519L14.4443 12.8472C14.5916 12.7367 14.7768 12.6892 14.9592 12.7153C15.1415 12.7413 15.306 12.8387 15.4165 12.9861C15.527 13.1334 15.5745 13.3186 15.5484 13.501C15.5224 13.6833 15.425 13.8478 15.2776 13.9583L12.7776 15.8333V17.5694H17.6387C17.8229 17.5694 17.9995 17.4962 18.1298 17.366C18.26 17.2358 18.3332 17.0591 18.3332 16.875V12.3611C18.3317 11.1644 17.8557 10.0171 17.0095 9.17091C16.1633 8.32471 15.016 7.84867 13.8193 7.84719Z" fill="#EF6820"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@ -1,12 +0,0 @@
|
||||
<svg width="220" height="220" viewBox="0 0 220 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Effect" opacity="0.8" filter="url(#filter0_f_481_16338)">
|
||||
<circle cx="32" cy="32" r="28" fill="#EF6820"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_481_16338" x="-156" y="-156" width="376" height="376" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="80" result="effect1_foregroundBlur_481_16338"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 610 B |
@ -13,7 +13,7 @@ import { DataSourceProvider, type NotionPage } from '@/models/common'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource'
|
||||
import produce from 'immer'
|
||||
import { produce } from 'immer'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user