From a63346387a42f0f8ce48e40f5d0452daa9276b2b Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Tue, 23 Jun 2026 22:58:00 -0700 Subject: [PATCH] BF-015-BF-023: standardize tool actions and JSON/JWT/HTTP flows --- docs/specs/design-system.md | 12 ++ src/core/i18n/translations/de.json | 12 +- src/core/i18n/translations/en.json | 12 +- src/core/i18n/translations/fr.json | 12 +- src/core/i18n/translations/ja.json | 12 +- src/core/i18n/translations/ko.json | 12 +- src/core/i18n/translations/zh-CN.json | 12 +- src/core/i18n/translations/zh-TW.json | 12 +- .../top-templates.ts | 1 + src/features/tool-shell/tool-action-bar.tsx | 146 +++++++++++++----- .../tools/base64-encode-decode/page.tsx | 37 +++-- .../tools/http-request-builder/page.tsx | 42 ++++- src/features/tools/image-resizer/page.tsx | 22 ++- .../tools/json-formatter/browser-actions.ts | 12 ++ .../tools/json-formatter/constants.ts | 15 ++ .../json-formatter/json-output-toolbar.tsx | 26 +++- src/features/tools/json-formatter/page.tsx | 118 ++++++++------ src/features/tools/jwt-decoder/page.tsx | 35 ++++- src/features/tools/regex-generator/page.tsx | 3 + src/features/tools/regex-tester/page.tsx | 49 ++++-- src/features/tools/uuid-generator/page.tsx | 33 ++-- .../tools/vimeo-thumbnail-grabber/page.tsx | 26 +++- .../tools/youtube-thumbnail-grabber/page.tsx | 26 +++- .../http-request-builder-page.test.tsx | 68 ++++++++ .../component/json-formatter-actions.test.tsx | 123 +++++++++++++++ tests/component/jwt-decoder-actions.test.tsx | 60 +++++++ tests/component/tool-action-bar.test.tsx | 63 ++++++++ tests/guards/sensitive-storage-audit.test.ts | 1 + .../tool-action-consistency-guard.test.ts | 69 +++++++++ .../unit/http-request-builder-codegen.test.ts | 28 ++++ 30 files changed, 945 insertions(+), 154 deletions(-) create mode 100644 src/features/tools/json-formatter/browser-actions.ts create mode 100644 tests/component/http-request-builder-page.test.tsx create mode 100644 tests/component/json-formatter-actions.test.tsx create mode 100644 tests/component/jwt-decoder-actions.test.tsx create mode 100644 tests/component/tool-action-bar.test.tsx create mode 100644 tests/guards/tool-action-consistency-guard.test.ts diff --git a/docs/specs/design-system.md b/docs/specs/design-system.md index 1c58d981..d0dfec4c 100644 --- a/docs/specs/design-system.md +++ b/docs/specs/design-system.md @@ -56,6 +56,18 @@ Large tools should split orchestration, pure logic, browser effects, samples, ty - Errors should be specific and local to the affected input or output area. - Toasts are useful for command confirmation, but validation errors should not rely on toast-only feedback. +## Tool Action Semantics + +Shared tool actions should appear in this order: Sample, Import or Upload, Clear, Reset, Preview, Run or task-specific primary actions, Copy, Download or Export, Share, then Send to. + +- Sample loads safe example input and expected example settings without persisting user data. +- Clear removes current input, output, transient errors, selected files, and sensitive fields; it should not unexpectedly change durable settings unless the tool documents that behavior. +- Reset restores documented defaults and may also clear current input/output when returning the tool to its initial state. +- Run, Format, Minify, Validate, Convert, Generate, Decode, Encode, and Hash create or refresh output and should be disabled with a reason when required input is missing. +- Copy, Download, and Export operate only on current valid output, provide visible feedback, and remain disabled with an accessible reason when output is empty, stale, or invalid. +- Share and Send to must not include sensitive payloads unless the flow explicitly uses the approved sensitive handoff or share preview. +- Destructive Clear and Reset actions use destructive styling in the shared action bar. + ## Accessibility - Preserve semantic HTML before adding custom roles. diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index b53417a9..ed214884 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -108,6 +108,10 @@ "barcode_generated": "Barcode wurde erstellt", "csv_downloaded": "Prognose-CSV heruntergeladen", "downloaded_file": "{filename} heruntergeladen", + "action_disabled_no_output": "Führen Sie das Tool zuerst aus, um eine Ausgabe zu erzeugen.", + "action_disabled_input_required": "Fügen Sie zuerst eine Eingabe hinzu.", + "action_disabled_preview_required": "Zeigen Sie das externe Asset vor dem Download in der Vorschau an.", + "action_disabled_unavailable": "Diese Aktion ist derzeit nicht verfügbar.", "direct_download_blocked_opened_new_tab": "Direkter Download wurde vom Remote-Host blockiert. Medien wurden in einem neuen Tab geöffnet.", "all_families": "Alle Familien", "filter_by_family": "Nach Familie filtern", @@ -460,6 +464,8 @@ "import_file": "Datei importieren", "file_imported": "Datei importiert.", "file_import_failed": "Dateiimport fehlgeschlagen.", + "download_json": "JSON herunterladen", + "download_disabled_invalid": "Korrigieren Sie ungültiges JSON vor dem Download.", "drag_drop_import_hint": "Text/JSON-Datei hier ablegen oder eine Datei zum Import auswaehlen.", "handoff_json_to_typescript": "Zu JSON->TS", "tree_edit_value_title": "JSON-Wert bearbeiten", @@ -723,6 +729,8 @@ "payload_label": "Nutzdaten", "header_copied": "Header kopiert", "payload_copied": "Nutzdaten kopiert", + "copy_claim_value": "{claim}-Claim-Wert kopieren", + "claim_copied": "{claim}-Claim kopiert", "claims_summary_title": "Claims und Zeitsemantik", "claim_time_note": "Zeit-Claims sind Unix-Sekunden. UTC ist massgeblich; lokale Zeit dient nur der Lesbarkeit.", "algorithm_label": "Algorithmus", @@ -777,7 +785,7 @@ "md5_quick_link": "MD5-Schnellseite", "sha224_quick_link": "SHA-224-Schnellseite", "sha384_quick_link": "SHA-384-Schnellseite", - "hmac_secret_hint": "HMAC-Geheimnisse bleiben nur im Arbeitsspeicher und werden nicht gespeichert.", + "hmac_secret_hint": "HMAC-Geheimnisse bleiben nur im Arbeitsspeicher und werden nicht gespeichert. Teilen Sie keine Screenshots oder Support-Tickets, die echte Produktionsgeheimnisse zeigen.", "show_secret": "HMAC-Geheimnis anzeigen", "hide_secret": "HMAC-Geheimnis ausblenden", "file_processing": "Datei-Hash wird lokal berechnet...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "HTTP-Anfrage-Builder", "description": "HTTP-Anfragen visuell erstellen und cURL-, JavaScript-Fetch- oder Python-Code generieren.", + "no_send_title": "Nur Codegenerierung", + "no_send_desc": "Das Bearbeiten dieses Builders sendet keine Anfrage. Netzwerkzugriff entsteht nur, wenn Sie das Snippet kopieren und außerhalb von byteflow.tools selbst ausführen.", "url_label": "Anfrage-URL", "headers": "Header", "add_header": "Header hinzufügen", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index cf3a2580..ff8519dc 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -108,6 +108,10 @@ "barcode_generated": "Barcode generated", "csv_downloaded": "Forecast CSV downloaded", "downloaded_file": "Downloaded {filename}", + "action_disabled_no_output": "Run the tool first to create output.", + "action_disabled_input_required": "Add input before running this action.", + "action_disabled_preview_required": "Preview the external asset before downloading.", + "action_disabled_unavailable": "This action is unavailable right now.", "direct_download_blocked_opened_new_tab": "Direct download was blocked by the remote host. Opened media in a new tab instead.", "all_families": "All families", "filter_by_family": "Filter by family", @@ -480,6 +484,8 @@ "import_file": "Import File", "file_imported": "File imported.", "file_import_failed": "Failed to import file.", + "download_json": "Download JSON", + "download_disabled_invalid": "Fix invalid JSON before downloading.", "drag_drop_import_hint": "Drag and drop a text/JSON file here, or choose a file to import.", "handoff_json_to_typescript": "To JSON->TS", "tree_edit_value_title": "Edit JSON Value", @@ -751,6 +757,8 @@ "payload_label": "Payload", "header_copied": "Header copied", "payload_copied": "Payload copied", + "copy_claim_value": "Copy {claim} claim value", + "claim_copied": "{claim} claim copied", "claims_summary_title": "Claims and time semantics", "claim_time_note": "Time claims are Unix seconds. UTC is authoritative; local time is shown only as a convenience.", "algorithm_label": "Algorithm", @@ -805,7 +813,7 @@ "md5_quick_link": "MD5 Quick Page", "sha224_quick_link": "SHA-224 Quick Page", "sha384_quick_link": "SHA-384 Quick Page", - "hmac_secret_hint": "HMAC secrets stay in memory only and are not saved.", + "hmac_secret_hint": "HMAC secrets stay in memory only and are not saved. Avoid sharing screenshots or support tickets that show real production secrets.", "show_secret": "Show HMAC secret", "hide_secret": "Hide HMAC secret", "file_processing": "Hashing file locally...", @@ -1305,6 +1313,8 @@ "http_request_builder": { "title": "HTTP Request Builder", "description": "Build HTTP requests visually and generate cURL, JavaScript fetch, or Python code.", + "no_send_title": "Code generation only", + "no_send_desc": "Editing this builder does not send the request. Network access happens only if you copy the snippet and run it yourself outside byteflow.tools.", "url_label": "Request URL", "headers": "Headers", "add_header": "Add Header", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 54faa6e3..d65ef8c7 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -112,6 +112,10 @@ "barcode_generated": "Code-barres généré", "csv_downloaded": "CSV de prévision téléchargé", "downloaded_file": "{filename} téléchargé", + "action_disabled_no_output": "Exécutez d'abord l'outil pour générer une sortie.", + "action_disabled_input_required": "Ajoutez une entrée avant d'exécuter cette action.", + "action_disabled_preview_required": "Prévisualisez la ressource externe avant de la télécharger.", + "action_disabled_unavailable": "Cette action est indisponible pour le moment.", "direct_download_blocked_opened_new_tab": "Le téléchargement direct a été bloqué par l'hôte distant. Le média a été ouvert dans un nouvel onglet.", "all_families": "Toutes les familles", "filter_by_family": "Filtrer par famille", @@ -460,6 +464,8 @@ "import_file": "Importer un fichier", "file_imported": "Fichier importe.", "file_import_failed": "Échec de l'import du fichier.", + "download_json": "Télécharger JSON", + "download_disabled_invalid": "Corrigez le JSON invalide avant de télécharger.", "drag_drop_import_hint": "Glissez-déposez un fichier texte/JSON ici, ou sélectionnez un fichier à importer.", "handoff_json_to_typescript": "Vers JSON->TS", "tree_edit_value_title": "Modifier la valeur JSON", @@ -723,6 +729,8 @@ "payload_label": "Charge utile", "header_copied": "En-tête copié", "payload_copied": "Charge utile copiée", + "copy_claim_value": "Copier la valeur du claim {claim}", + "claim_copied": "Claim {claim} copié", "claims_summary_title": "Claims et semantique temporelle", "claim_time_note": "Les claims de temps sont en secondes Unix. UTC fait reference ; l'heure locale est seulement une aide de lecture.", "algorithm_label": "Algorithme", @@ -777,7 +785,7 @@ "md5_quick_link": "Page rapide MD5", "sha224_quick_link": "Page rapide SHA-224", "sha384_quick_link": "Page rapide SHA-384", - "hmac_secret_hint": "Les secrets HMAC restent uniquement en memoire et ne sont pas enregistres.", + "hmac_secret_hint": "Les secrets HMAC restent uniquement en mémoire et ne sont pas enregistrés. Évitez de partager des captures d'écran ou tickets de support montrant de vrais secrets de production.", "show_secret": "Afficher le secret HMAC", "hide_secret": "Masquer le secret HMAC", "file_processing": "Calcul local du hash du fichier...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "Constructeur de requêtes HTTP", "description": "Construisez visuellement des requêtes HTTP et générez du code cURL, JavaScript fetch ou Python.", + "no_send_title": "Génération de code uniquement", + "no_send_desc": "Modifier ce constructeur n'envoie pas la requête. L'accès réseau n'a lieu que si vous copiez l'extrait et l'exécutez vous-même hors de byteflow.tools.", "url_label": "URL de requête", "headers": "En-têtes", "add_header": "Ajouter un en-tête", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 51ae68cc..95ae6d8f 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -108,6 +108,10 @@ "barcode_generated": "バーコードを生成しました", "csv_downloaded": "予測CSVをダウンロードしました", "downloaded_file": "{filename} をダウンロードしました", + "action_disabled_no_output": "先にツールを実行して出力を生成してください。", + "action_disabled_input_required": "この操作を実行する前に入力を追加してください。", + "action_disabled_preview_required": "ダウンロードする前に外部アセットをプレビューしてください。", + "action_disabled_unavailable": "この操作は現在利用できません。", "direct_download_blocked_opened_new_tab": "リモートホストにより直接ダウンロードがブロックされました。新しいタブでメディアを開きました。", "all_families": "すべてのファミリー", "filter_by_family": "ファミリーで絞り込み", @@ -460,6 +464,8 @@ "import_file": "ファイルをインポート", "file_imported": "ファイルをインポートしました。", "file_import_failed": "ファイルのインポートに失敗しました。", + "download_json": "JSON をダウンロード", + "download_disabled_invalid": "無効な JSON を修正してからダウンロードしてください。", "drag_drop_import_hint": "テキスト/JSONファイルをここにドラッグ&ドロップするか、ファイルを選択してインポートしてください。", "handoff_json_to_typescript": "JSON->TS へ", "tree_edit_value_title": "JSON 値を編集", @@ -723,6 +729,8 @@ "payload_label": "ペイロード", "header_copied": "ヘッダーをコピーしました", "payload_copied": "ペイロードをコピーしました", + "copy_claim_value": "{claim} クレーム値をコピー", + "claim_copied": "{claim} クレームをコピーしました", "claims_summary_title": "クレームと時間の意味", "claim_time_note": "時間クレームは Unix 秒です。UTC が基準で、ローカル時刻は読みやすさのためだけに表示します。", "algorithm_label": "アルゴリズム", @@ -777,7 +785,7 @@ "md5_quick_link": "MD5 クイックページ", "sha224_quick_link": "SHA-224 クイックページ", "sha384_quick_link": "SHA-384 クイックページ", - "hmac_secret_hint": "HMAC シークレットはメモリ内のみで扱い、保存しません。", + "hmac_secret_hint": "HMAC シークレットはメモリ内のみで扱い、保存しません。実際の本番シークレットが写るスクリーンショットやサポートチケットは共有しないでください。", "show_secret": "HMAC シークレットを表示", "hide_secret": "HMAC シークレットを非表示", "file_processing": "ファイルのハッシュをローカルで計算しています...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "HTTPリクエストビルダー", "description": "HTTPリクエストをビジュアルに構築し、cURL、JavaScript fetch、Pythonコードを生成。", + "no_send_title": "コード生成のみ", + "no_send_desc": "このビルダーを編集してもリクエストは送信されません。ネットワークアクセスは、スニペットをコピーして byteflow.tools の外で自分で実行した場合にのみ発生します。", "url_label": "リクエスト URL", "headers": "ヘッダー", "add_header": "ヘッダーを追加", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index bf589d40..b101b2f6 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -108,6 +108,10 @@ "barcode_generated": "바코드를 생성했습니다", "csv_downloaded": "예측 CSV를 다운로드했습니다", "downloaded_file": "{filename} 다운로드 완료", + "action_disabled_no_output": "먼저 도구를 실행해 출력을 생성하세요.", + "action_disabled_input_required": "이 작업을 실행하기 전에 입력을 추가하세요.", + "action_disabled_preview_required": "다운로드하기 전에 외부 자산을 미리 보세요.", + "action_disabled_unavailable": "이 작업은 현재 사용할 수 없습니다.", "direct_download_blocked_opened_new_tab": "원격 호스트에서 직접 다운로드를 차단했습니다. 새 탭에서 미디어를 열었습니다.", "all_families": "모든 패밀리", "filter_by_family": "패밀리로 필터", @@ -460,6 +464,8 @@ "import_file": "파일 가져오기", "file_imported": "파일을 가져왔습니다.", "file_import_failed": "파일 가져오기에 실패했습니다.", + "download_json": "JSON 다운로드", + "download_disabled_invalid": "잘못된 JSON을 수정한 뒤 다운로드하세요.", "drag_drop_import_hint": "텍스트/JSON 파일을 여기에 끌어다 놓거나 파일을 선택해 가져오세요.", "handoff_json_to_typescript": "JSON->TS로 이동", "tree_edit_value_title": "JSON 값 편집", @@ -723,6 +729,8 @@ "payload_label": "페이로드", "header_copied": "헤더 복사됨", "payload_copied": "페이로드 복사됨", + "copy_claim_value": "{claim} 클레임 값 복사", + "claim_copied": "{claim} 클레임을 복사했습니다", "claims_summary_title": "클레임 및 시간 의미", "claim_time_note": "시간 클레임은 Unix 초입니다. UTC가 기준이며 로컬 시간은 읽기 편의를 위해서만 표시됩니다.", "algorithm_label": "알고리즘", @@ -777,7 +785,7 @@ "md5_quick_link": "MD5 빠른 페이지", "sha224_quick_link": "SHA-224 빠른 페이지", "sha384_quick_link": "SHA-384 빠른 페이지", - "hmac_secret_hint": "HMAC 비밀값은 메모리에만 머물며 저장되지 않습니다.", + "hmac_secret_hint": "HMAC 비밀값은 메모리에만 머물며 저장되지 않습니다. 실제 운영 비밀값이 보이는 스크린샷이나 지원 티켓은 공유하지 마세요.", "show_secret": "HMAC 비밀값 표시", "hide_secret": "HMAC 비밀값 숨기기", "file_processing": "파일 해시를 로컬에서 계산 중...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "HTTP 요청 빌더", "description": "시각적으로 HTTP 요청을 구성하고 cURL, JavaScript fetch 또는 Python 코드를 생성합니다.", + "no_send_title": "코드 생성 전용", + "no_send_desc": "이 빌더를 편집해도 요청은 전송되지 않습니다. 네트워크 접근은 스니펫을 복사해 byteflow.tools 외부에서 직접 실행할 때만 발생합니다.", "url_label": "요청 URL", "headers": "헤더", "add_header": "헤더 추가", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index c1d2ef9f..ff5b2fe5 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -108,6 +108,10 @@ "barcode_generated": "条码已生成", "csv_downloaded": "预测 CSV 已下载", "downloaded_file": "已下载 {filename}", + "action_disabled_no_output": "请先运行工具生成输出。", + "action_disabled_input_required": "请先添加输入再执行此操作。", + "action_disabled_preview_required": "请先预览外部资源再下载。", + "action_disabled_unavailable": "此操作当前不可用。", "direct_download_blocked_opened_new_tab": "目标站点阻止了直接下载,已在新标签页打开媒体。", "all_families": "全部细分", "filter_by_family": "按细分筛选", @@ -460,6 +464,8 @@ "import_file": "导入文件", "file_imported": "文件已导入。", "file_import_failed": "导入文件失败。", + "download_json": "下载 JSON", + "download_disabled_invalid": "请先修复无效 JSON 再下载。", "drag_drop_import_hint": "将文本/JSON 文件拖放到这里,或选择文件导入。", "handoff_json_to_typescript": "转到 JSON→TS", "tree_edit_value_title": "编辑 JSON 值", @@ -723,6 +729,8 @@ "payload_label": "载荷", "header_copied": "头部已复制", "payload_copied": "载荷已复制", + "copy_claim_value": "复制 {claim} 声明值", + "claim_copied": "{claim} 声明已复制", "claims_summary_title": "Claims 与时间语义", "claim_time_note": "时间 claim 使用 Unix 秒。UTC 是权威时间,本地时间仅供阅读参考。", "algorithm_label": "算法", @@ -777,7 +785,7 @@ "md5_quick_link": "MD5 快速页面", "sha224_quick_link": "SHA-224 快速页面", "sha384_quick_link": "SHA-384 快速页面", - "hmac_secret_hint": "HMAC 密钥只保存在内存中,不会被保存。", + "hmac_secret_hint": "HMAC 密钥只保存在内存中,不会被保存。避免在截图或支持工单中展示真实生产密钥。", "show_secret": "显示 HMAC 密钥", "hide_secret": "隐藏 HMAC 密钥", "file_processing": "正在本地计算文件哈希...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "HTTP 请求构建器", "description": "可视化构建 HTTP 请求,生成 cURL、JavaScript fetch 或 Python 代码。", + "no_send_title": "仅生成代码", + "no_send_desc": "编辑此构建器不会发送请求。只有在你复制代码并在 byteflow.tools 之外自行运行时才会发生网络访问。", "url_label": "请求 URL", "headers": "请求头", "add_header": "添加请求头", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 7b148333..cdd018d0 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -108,6 +108,10 @@ "barcode_generated": "條碼已生成", "csv_downloaded": "預測 CSV 已下載", "downloaded_file": "已下載 {filename}", + "action_disabled_no_output": "請先執行工具產生輸出。", + "action_disabled_input_required": "請先新增輸入再執行此操作。", + "action_disabled_preview_required": "請先預覽外部資源再下載。", + "action_disabled_unavailable": "此操作目前不可用。", "direct_download_blocked_opened_new_tab": "目標站點阻止了直接下載,已在新分頁開啟媒體。", "all_families": "全部細分", "filter_by_family": "依細分篩選", @@ -460,6 +464,8 @@ "import_file": "匯入檔案", "file_imported": "檔案已匯入。", "file_import_failed": "匯入檔案失敗。", + "download_json": "下載 JSON", + "download_disabled_invalid": "請先修正無效 JSON 再下載。", "drag_drop_import_hint": "將文字/JSON 檔案拖放到這裡,或選擇檔案匯入。", "handoff_json_to_typescript": "轉到 JSON->TS", "tree_edit_value_title": "編輯 JSON 值", @@ -723,6 +729,8 @@ "payload_label": "負載", "header_copied": "標頭已複製", "payload_copied": "負載已複製", + "copy_claim_value": "複製 {claim} 宣告值", + "claim_copied": "{claim} 宣告已複製", "claims_summary_title": "Claims 與時間語意", "claim_time_note": "時間 claim 使用 Unix 秒。UTC 是權威時間,本地時間僅供閱讀參考。", "algorithm_label": "演算法", @@ -777,7 +785,7 @@ "md5_quick_link": "MD5 快速頁面", "sha224_quick_link": "SHA-224 快速頁面", "sha384_quick_link": "SHA-384 快速頁面", - "hmac_secret_hint": "HMAC 密鑰只保留在記憶體中,不會被儲存。", + "hmac_secret_hint": "HMAC 密鑰只保留在記憶體中,不會被儲存。避免在截圖或支援工單中顯示真實生產密鑰。", "show_secret": "顯示 HMAC 密鑰", "hide_secret": "隱藏 HMAC 密鑰", "file_processing": "正在本機計算檔案雜湊...", @@ -1277,6 +1285,8 @@ "http_request_builder": { "title": "HTTP 請求建構器", "description": "視覺化建構 HTTP 請求,產生 cURL、JavaScript fetch 或 Python 程式碼。", + "no_send_title": "僅產生程式碼", + "no_send_desc": "編輯此建構器不會送出請求。只有當你複製程式碼並在 byteflow.tools 之外自行執行時,才會發生網路存取。", "url_label": "請求 URL", "headers": "標頭", "add_header": "新增標頭", diff --git a/src/core/seo/components/tool-content-template-modules/top-templates.ts b/src/core/seo/components/tool-content-template-modules/top-templates.ts index c0949327..71c3cfd4 100644 --- a/src/core/seo/components/tool-content-template-modules/top-templates.ts +++ b/src/core/seo/components/tool-content-template-modules/top-templates.ts @@ -4038,6 +4038,7 @@ export const TOP_TOOL_CONTENT_TEMPLATES: Record "Mask authorization tokens before sharing generated snippets.", ], faqs: [ + { q: "Does this builder send the HTTP request?", a: "No. It only generates cURL, fetch, and Python request snippets in your browser. Network access happens only after you copy generated code and run it yourself outside byteflow.tools." }, { q: "Can I generate code for multiple clients?", a: "Yes, cURL, fetch, and Python requests outputs are available." }, { q: "Does it support custom headers?", a: "Yes, headers can be added, edited, toggled, and removed." }, { q: "Can I send form-urlencoded bodies?", a: "Yes, form-urlencoded mode is supported." }, diff --git a/src/features/tool-shell/tool-action-bar.tsx b/src/features/tool-shell/tool-action-bar.tsx index 9c510589..24b5fd71 100644 --- a/src/features/tool-shell/tool-action-bar.tsx +++ b/src/features/tool-shell/tool-action-bar.tsx @@ -33,6 +33,8 @@ export type ToolAction = { variant?: ActionVariant size?: ActionSize disabled?: boolean + disabledReason?: string + destructive?: boolean href?: string } @@ -52,6 +54,28 @@ const ACTION_VARIANT_CLASS: Record = { ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", } +const ACTION_ORDER_PREFIXES = [ + "sample", + "import", + "upload", + "clear", + "reset", + "preview", + "format", + "minify", + "run", + "validate", + "convert", + "generate", + "decode", + "encode", + "hash", + "copy", + "download", + "export", + "share", +] + function joinClasses(...values: Array) { return values.filter(Boolean).join(" ") } @@ -67,6 +91,28 @@ function classifyAnalyticsAction(actionId: string): AnalyticsAction { return "tool_run" } +function getActionOrder(action: ToolAction): number { + const normalized = action.id.trim().toLowerCase() + const index = ACTION_ORDER_PREFIXES.findIndex((prefix) => normalized === prefix || normalized.startsWith(`${prefix}_`)) + return index === -1 ? ACTION_ORDER_PREFIXES.length : index +} + +function getActionLabel(action: ToolAction, fallbackDisabledReason: string): string { + const disabledReason = action.disabled ? action.disabledReason || fallbackDisabledReason : "" + return disabledReason + ? `${action.label}: ${disabledReason}` + : action.label +} + +function getActionDescriptionId(actionId: string): string { + return `tool-action-${actionId.replace(/[^a-zA-Z0-9_-]/g, "-")}-disabled-reason` +} + +function isDestructiveAction(action: ToolAction): boolean { + const normalized = action.id.trim().toLowerCase() + return action.destructive === true || normalized === "clear" || normalized === "reset" || normalized.startsWith("clear_") || normalized.startsWith("reset_") +} + export function ToolActionBar({ actions, className, @@ -86,19 +132,20 @@ export function ToolActionBar({ const { handoffActions, primaryActions } = React.useMemo(() => { const handoffs: ToolAction[] = [] - const primary: ToolAction[] = [] + const primary: Array = [] - actions.forEach((action) => { + actions.forEach((action, orderIndex) => { const isHandoff = action.href?.startsWith("/") && (action.id.startsWith("to_") || action.id.includes("handoff")) if (isHandoff) { handoffs.push(action) } else { - primary.push(action) + primary.push({ ...action, orderIndex }) } }) + primary.sort((left, right) => getActionOrder(left) - getActionOrder(right) || left.orderIndex - right.orderIndex) return { handoffActions: handoffs, primaryActions: primary } }, [actions]) @@ -125,55 +172,74 @@ export function ToolActionBar({ {primaryActions.map((action) => { const Icon = action.icon const analyticsAction = classifyAnalyticsAction(action.id) + const disabledReason = action.disabled ? action.disabledReason || t.common.action_disabled_unavailable : undefined + const accessibleLabel = getActionLabel(action, t.common.action_disabled_unavailable) + const disabledDescriptionId = disabledReason ? getActionDescriptionId(action.id) : undefined + const isDestructive = isDestructiveAction(action) + const variantClass = isDestructive + ? "border border-destructive/35 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:bg-input/30" + : ACTION_VARIANT_CLASS[action.variant ?? "outline"] if (action.href) { return ( - + { + if (action.disabled) { + event.preventDefault() + return + } + triggerAction(action) + }} + > + {Icon ? : null} + {action.label} + + {disabledReason ? ( + : {disabledReason} + ) : null} + + ) + } + + return ( + + + + {disabledReason ? ( + : {disabledReason} + ) : null} + ) })} @@ -189,6 +255,8 @@ export function ToolActionBar({ "max-w-full", )} disabled={!handoffPayload?.trim()} + title={!handoffPayload?.trim() ? t.common.action_disabled_no_output : undefined} + aria-label={!handoffPayload?.trim() ? `${t.common.send_to || "Send to..."}: ${t.common.action_disabled_no_output}` : undefined} > {t.common.send_to || "Send to..."} diff --git a/src/features/tools/base64-encode-decode/page.tsx b/src/features/tools/base64-encode-decode/page.tsx index 65a6e6a2..a7631158 100644 --- a/src/features/tools/base64-encode-decode/page.tsx +++ b/src/features/tools/base64-encode-decode/page.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { ArrowLeft, ArrowRight, Binary, Download, FileUp, Share2, TestTube2, RotateCcw, Workflow } from "lucide-react" +import { Binary, Copy, Download, Eraser, FileUp, Play, Share2, TestTube2, Workflow } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { ModeSelector } from "@/features/tool-shell/mode-selector" @@ -301,16 +301,30 @@ export function Base64Page() { onClick: handleUseSample, }, { - id: "reset", - label: t.common.reset, - icon: RotateCcw, + id: "clear", + label: t.common.clear, + icon: Eraser, onClick: handleClear, + destructive: true, + }, + { + id: operation === "encode" ? "encode" : "decode", + label: operation === "encode" ? text("encode_action") : t.common.decode_base64, + icon: Play, + onClick: handleExecute, + variant: "default", + disabled: isBusy || (mode === "file" ? operation === "encode" ? !sourceFile : !input.trim() : !input.trim()), + disabledReason: mode === "file" && operation === "encode" && !sourceFile + ? text("error_file_required") + : t.common.action_disabled_input_required, }, { id: "copy", label: t.common.copy, + icon: Copy, onClick: handleCopyOutput, disabled: !output, + disabledReason: t.common.action_disabled_no_output, }, { id: "download", @@ -318,6 +332,7 @@ export function Base64Page() { icon: Download, onClick: handleDownload, disabled: !canDownload || isBusy, + disabledReason: t.common.action_disabled_no_output, }, { id: "share", @@ -332,6 +347,7 @@ export function Base64Page() { href: pipelineHandoff.href, onClick: pipelineHandoff.prime, disabled: !handoffPayload.trim(), + disabledReason: t.common.action_disabled_no_output, }, ] @@ -368,19 +384,6 @@ export function Base64Page() {
- {mode === "file" ? ( <>
-
- -
+ +
+
+ +
+

{toolT.no_send_title}

+

{toolT.no_send_desc}

+
+
+
+ {/* Method + URL */}
-
diff --git a/src/features/tools/image-resizer/page.tsx b/src/features/tools/image-resizer/page.tsx index 122832aa..f61ef564 100644 --- a/src/features/tools/image-resizer/page.tsx +++ b/src/features/tools/image-resizer/page.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Copy, Download, Eraser, Image as ImageIcon, Lock, LockOpen, TestTube2, Upload } from "lucide-react" +import { Copy, Download, Eraser, Image as ImageIcon, Lock, LockOpen, RotateCcw, TestTube2, Upload } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" @@ -202,7 +202,7 @@ export function ImageResizerPage() { setLockAspect(true) } - const handleReset = () => { + const handleClear = () => { replaceObjectUrl(imageObjectUrlRef, null) setImageSrc("") setImageBytes(null) @@ -210,6 +210,11 @@ export function ImageResizerPage() { setFileName("") setSourceWidth(0) setSourceHeight(0) + setOutputDataUrl("") + } + + const handleReset = () => { + handleClear() setTargetWidth(DEFAULT_STATE.width) setTargetHeight(DEFAULT_STATE.height) setFitMode(DEFAULT_STATE.fitMode) @@ -234,13 +239,22 @@ export function ImageResizerPage() { anchor.href = outputDataUrl anchor.download = `resized-image.${extension}` anchor.click() + toast.success(t.common.downloaded_file.replace("{filename}", `resized-image.${extension}`)) } const actions: ToolAction[] = [ { id: "sample", label: t.common.sample, icon: TestTube2, onClick: () => void handleSample() }, - { id: "reset", label: t.common.reset, icon: Eraser, onClick: handleReset }, + { id: "clear", label: t.common.clear, icon: Eraser, onClick: handleClear, destructive: true }, + { id: "reset", label: t.common.reset, icon: RotateCcw, onClick: handleReset, destructive: true }, { id: "copy", label: t.common.copy, icon: Copy, onClick: () => void handleCopy() }, - { id: "download", label: t.common.download, icon: Download, onClick: handleDownload }, + { + id: "download", + label: t.common.download, + icon: Download, + onClick: handleDownload, + disabled: !outputDataUrl, + disabledReason: t.common.action_disabled_no_output, + }, ] return ( diff --git a/src/features/tools/json-formatter/browser-actions.ts b/src/features/tools/json-formatter/browser-actions.ts new file mode 100644 index 00000000..7d8703dc --- /dev/null +++ b/src/features/tools/json-formatter/browser-actions.ts @@ -0,0 +1,12 @@ +export function downloadJsonOutput(content: string, filename: string) { + const blob = new Blob([content], { type: "application/json;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.style.display = "none" + document.body.append(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} diff --git a/src/features/tools/json-formatter/constants.ts b/src/features/tools/json-formatter/constants.ts index d017704e..8a044143 100644 --- a/src/features/tools/json-formatter/constants.ts +++ b/src/features/tools/json-formatter/constants.ts @@ -7,3 +7,18 @@ export const JSON_FORMATTER_PERSISTENCE_POLICY = { inputStorageKey: INPUT_STORAGE_KEY, maxInputChars: INPUT_STORAGE_MAX_CHARS, } as const + +export const JSON_EDITOR_OPTIONS = { + minimap: { enabled: false }, + fontSize: 14, + fontFamily: "var(--font-mono)", + lineHeight: 24, + padding: { top: 16 }, + scrollBeyondLastLine: false, + wordWrap: "on", +} as const + +export const JSON_OUTPUT_EDITOR_OPTIONS = { + ...JSON_EDITOR_OPTIONS, + readOnly: true, +} as const diff --git a/src/features/tools/json-formatter/json-output-toolbar.tsx b/src/features/tools/json-formatter/json-output-toolbar.tsx index 4b0afdad..c00a4df4 100644 --- a/src/features/tools/json-formatter/json-output-toolbar.tsx +++ b/src/features/tools/json-formatter/json-output-toolbar.tsx @@ -1,4 +1,4 @@ -import { Copy, ListTree, Maximize2, Minimize2, Search } from "lucide-react" +import { Copy, Download, ListTree, Maximize2, Minimize2, Search } from "lucide-react" import { Button } from "@/components/ui/button" import type { ViewMode } from "./types" @@ -8,7 +8,9 @@ type JsonOutputToolbarProps = { isSearchOpen: boolean labels: { collapseAll: string + downloadJson: string copyOutput: string + disabledNoOutput: string expandAll: string output: string search: string @@ -17,6 +19,7 @@ type JsonOutputToolbarProps = { } onCollapseAll: () => void onCopy: () => void + onDownload: () => void onExpandAll: () => void onToggleSearch: () => void onViewModeChange: (mode: ViewMode) => void @@ -30,6 +33,7 @@ export function JsonOutputToolbar({ labels, onCollapseAll, onCopy, + onDownload, onExpandAll, onToggleSearch, onViewModeChange, @@ -97,10 +101,28 @@ export function JsonOutputToolbar({ ) : null} - + ) diff --git a/src/features/tools/json-formatter/page.tsx b/src/features/tools/json-formatter/page.tsx index 4168cecc..d5d29d17 100644 --- a/src/features/tools/json-formatter/page.tsx +++ b/src/features/tools/json-formatter/page.tsx @@ -11,6 +11,7 @@ import { ArrowRightLeft, TestTube2, Workflow, + Download, } from "lucide-react" import { toast } from "sonner" import { useLang } from "@/core/i18n/lang-provider" @@ -32,7 +33,7 @@ import { JsonImportDropzone } from "./json-import-dropzone" import { JsonOutputToolbar } from "./json-output-toolbar" import { JsonTreeSearch } from "./json-tree-search" import { JsonErrorAlert, JsonInputHeader, JsonTextOutputEmptyState, JsonTreeEditDialog } from "./panels" -import { INPUT_STORAGE_DEBOUNCE_MS, INPUT_STORAGE_KEY, JSON_FORMATTER_PERSISTENCE_POLICY, VIEW_MODE_STORAGE_KEY } from "./constants" +import { INPUT_STORAGE_DEBOUNCE_MS, INPUT_STORAGE_KEY, JSON_EDITOR_OPTIONS, JSON_FORMATTER_PERSISTENCE_POLICY, JSON_OUTPUT_EDITOR_OPTIONS, VIEW_MODE_STORAGE_KEY } from "./constants" import { findMatchingPaths, getAllPaths, @@ -45,6 +46,7 @@ import { } from "./logic" import { JsonTreeNode } from "./components" import { SAMPLE_JSON_SOURCE } from "./samples" +import { downloadJsonOutput } from "./browser-actions" import type { JsonPath, JsonValue, TreeDialogState, ViewMode } from "./types" export function JsonFormatterPage() { @@ -87,6 +89,7 @@ export function JsonFormatterPage() { const appliedHandoffRef = React.useRef(null) const formatRequestIdRef = React.useRef(0) const formatAbortControllerRef = React.useRef(null) + const [lastFormatMode, setLastFormatMode] = React.useState("format") React.useEffect(() => { const savedMode = readStorageString(VIEW_MODE_STORAGE_KEY) if (savedMode === "text" || savedMode === "tree") { @@ -175,6 +178,17 @@ export function JsonFormatterPage() { } }, []) + const handleInputChange = React.useCallback((nextInput: string) => { + setInput(nextInput) + if (output || treeData !== null) { + setOutput("") + setTreeData(null) + setExpanded(new Set(["$"])) + } + setError(null) + setErrorDetails(null) + }, [output, treeData]) + const runJsonFormat = React.useCallback(async (source: string, mode: JsonFormatMode) => { const requestId = formatRequestIdRef.current + 1 formatRequestIdRef.current = requestId @@ -197,12 +211,16 @@ export function JsonFormatterPage() { if (formatRequestIdRef.current !== requestId) return setOutput(result.output) setTreeData(result.parsed) + setLastFormatMode(mode) setError(null) setErrorDetails(null) scrollOutputIntoViewOnMobile() } catch (err) { if (formatRequestIdRef.current !== requestId) return const details = buildJsonParseErrorDetails(source, err, text) + setOutput("") + setTreeData(null) + setExpanded(new Set(["$"])) setError(details.message) setErrorDetails(details) } finally { @@ -241,7 +259,18 @@ export function JsonFormatterPage() { toast.success(t.common.copied, { description: t.common.copied_desc, }) - }, [output, t.common, treeData, viewMode]) + }, [output, t.common, treeData, viewMode]) + + const handleDownload = React.useCallback(() => { + const content = viewMode === "tree" && treeData !== null + ? JSON.stringify(treeData, null, 2) + : output + + if (!content || error) return + const filename = lastFormatMode === "minify" ? "minified.json" : "formatted.json" + downloadJsonOutput(content, filename) + toast.success(t.common.downloaded_file.replace("{filename}", filename)) + }, [error, lastFormatMode, output, t.common.downloaded_file, treeData, viewMode]) const handleClear = () => { setInput("") @@ -350,19 +379,19 @@ export function JsonFormatterPage() { formatJsonSource(SAMPLE_JSON_SOURCE) } - const actions: ToolAction[] = [ - { - id: "sample", - label: t.common.try_example, - icon: TestTube2, - onClick: handleUseSample, - }, - { - id: "import_file", - label: text("import_file"), - icon: Upload, - onClick: openImportPicker, - }, + const actions: ToolAction[] = [ + { + id: "sample", + label: t.common.try_example, + icon: TestTube2, + onClick: handleUseSample, + }, + { + id: "import_file", + label: text("import_file"), + icon: Upload, + onClick: openImportPicker, + }, { id: "to_json_to_typescript", label: text("handoff_json_to_typescript"), @@ -370,6 +399,7 @@ export function JsonFormatterPage() { href: jsonToTypescriptHandoff.href, onClick: jsonToTypescriptHandoff.prime, disabled: !handoffPayload.trim(), + disabledReason: t.common.action_disabled_no_output, }, { id: "to_pipeline_builder", @@ -378,19 +408,22 @@ export function JsonFormatterPage() { href: pipelineHandoff.href, onClick: pipelineHandoff.prime, disabled: !handoffPayload.trim(), + disabledReason: t.common.action_disabled_no_output, + }, + { + id: "clear", + label: t.common.clear, + icon: Eraser, + onClick: handleClear, + destructive: true, }, - { - id: "clear", - label: t.common.clear, - icon: Eraser, - onClick: handleClear, - }, { id: "minify", label: t.common.minify, icon: AlignLeft, onClick: minifyJson, - disabled: isFormatting, + disabled: isFormatting || !input.trim(), + disabledReason: !input.trim() ? t.common.action_disabled_input_required : undefined, }, { id: "format", @@ -398,9 +431,20 @@ export function JsonFormatterPage() { icon: Play, onClick: formatJson, variant: "default", - disabled: isFormatting, + disabled: isFormatting || !input.trim(), + disabledReason: !input.trim() ? t.common.action_disabled_input_required : undefined, + }, + { + id: "download_json", + label: text("download_json"), + icon: Download, + onClick: handleDownload, + disabled: Boolean(error) || !(output || treeData !== null), + disabledReason: Boolean(error) + ? text("download_disabled_invalid") + : t.common.action_disabled_no_output, }, - ] + ] const toggleExpand = (path: JsonPath) => { const key = pathKey(path) @@ -574,16 +618,8 @@ export function JsonFormatterPage() { theme={monacoTheme} beforeMount={(monaco) => ensureByteflowMonacoThemes(monaco)} value={input} - onChange={(val) => setInput(val || "")} - options={{ - minimap: { enabled: false }, - fontSize: 14, - fontFamily: "var(--font-mono)", - lineHeight: 24, - padding: { top: 16 }, - scrollBeyondLastLine: false, - wordWrap: "on", - }} + onChange={(val) => handleInputChange(val || "")} + options={JSON_EDITOR_OPTIONS} /> @@ -595,6 +631,8 @@ export function JsonFormatterPage() { isSearchOpen={isSearchOpen} labels={{ collapseAll: text("tree_collapse_all"), + disabledNoOutput: t.common.action_disabled_no_output, + downloadJson: text("download_json"), copyOutput: t.common.copy_output, expandAll: text("tree_expand_all"), output: t.common.output, @@ -604,6 +642,7 @@ export function JsonFormatterPage() { }} onCollapseAll={handleCollapseAll} onCopy={handleCopy} + onDownload={handleDownload} onExpandAll={handleExpandAll} onToggleSearch={() => setIsSearchOpen(!isSearchOpen)} onViewModeChange={switchViewMode} @@ -619,16 +658,7 @@ export function JsonFormatterPage() { theme={monacoTheme} beforeMount={(monaco) => ensureByteflowMonacoThemes(monaco)} value={output} - options={{ - readOnly: true, - minimap: { enabled: false }, - fontSize: 14, - fontFamily: "var(--font-mono)", - lineHeight: 24, - padding: { top: 16 }, - scrollBeyondLastLine: false, - wordWrap: "on", - }} + options={JSON_OUTPUT_EDITOR_OPTIONS} /> ) : ( diff --git a/src/features/tools/jwt-decoder/page.tsx b/src/features/tools/jwt-decoder/page.tsx index 97bf750d..e5fc3114 100644 --- a/src/features/tools/jwt-decoder/page.tsx +++ b/src/features/tools/jwt-decoder/page.tsx @@ -38,6 +38,11 @@ function getClaimStatusClass(status: JwtClaimSemantic["status"]): string { return "border-border/70 bg-muted/45 text-muted-foreground" } +function getClaimCopyValue(claim: JwtClaimSemantic): string { + if (typeof claim.epochSeconds === "number") return String(claim.epochSeconds) + return claim.valueSummary ?? "" +} + export function JwtDecoderPage() { const { t, lang } = useLang() const toolT = t.tools["jwt_decoder"] as Record @@ -111,6 +116,19 @@ export function JwtDecoderPage() { }) } + const handleCopyClaim = async (claim: JwtClaimSemantic) => { + const value = getClaimCopyValue(claim) + if (!value) return + const result = await safeClipboardWrite(value) + if (!result.ok) { + toast.error(t.common.copy_failed) + return + } + toast.success(t.common.copied, { + description: toolT.claim_copied.replace("{claim}", claim.claim), + }) + } + const actions: ToolAction[] = [ { id: "sample", @@ -123,6 +141,7 @@ export function JwtDecoderPage() { label: t.common.clear, icon: Eraser, onClick: handleClear, + destructive: true, }, ] @@ -310,7 +329,21 @@ export function JwtDecoderPage() {
{claim.claim} - {toolT[`claim_status_${claim.status}`]} +
+ + {toolT[`claim_status_${claim.status}`]} + + +

{toolT[`claim_detail_${claim.detail}`]}

{claim.utc ? ( diff --git a/src/features/tools/regex-generator/page.tsx b/src/features/tools/regex-generator/page.tsx index cee12987..44b65ec6 100644 --- a/src/features/tools/regex-generator/page.tsx +++ b/src/features/tools/regex-generator/page.tsx @@ -163,6 +163,7 @@ export function RegexGeneratorPage() { anchor.download = "regex-pattern.txt" anchor.click() URL.revokeObjectURL(url) + void notifySuccess(t.common.downloaded_file.replace("{filename}", "regex-pattern.txt")) } const actions: ToolAction[] = [ @@ -177,6 +178,7 @@ export function RegexGeneratorPage() { label: t.common.clear, icon: Eraser, onClick: handleClear, + destructive: true, }, { id: "generate", @@ -191,6 +193,7 @@ export function RegexGeneratorPage() { icon: Download, onClick: handleDownload, disabled: !literal, + disabledReason: t.common.action_disabled_no_output, }, { id: "open-tester", diff --git a/src/features/tools/regex-tester/page.tsx b/src/features/tools/regex-tester/page.tsx index cfedd524..ca9113ee 100644 --- a/src/features/tools/regex-tester/page.tsx +++ b/src/features/tools/regex-tester/page.tsx @@ -1,19 +1,23 @@ "use client" import * as React from "react" -import { Regex, Eraser } from "lucide-react" -import { Button } from "@/components/ui/button" +import { Regex, Eraser, TestTube2 } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" +import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { testRegexPattern, type RegexMatchSummary } from "./utils" +const SAMPLE_PATTERN = "[A-Z][a-z]+" +const SAMPLE_FLAGS = "g" +const SAMPLE_TEST_STRING = "Ab1 Cd2 Ef3 Gh4." + export function RegexTesterPage() { const { t } = useLang() const toolT = t.tools["regex_tester"] as Record - const [pattern, setPattern] = React.useState("[A-Z][a-z]+") - const [flags, setFlags] = React.useState("g") - const [testString, setTestString] = React.useState("Ab1 Cd2 Ef3 Gh4.") + const [pattern, setPattern] = React.useState(SAMPLE_PATTERN) + const [flags, setFlags] = React.useState(SAMPLE_FLAGS) + const [testString, setTestString] = React.useState(SAMPLE_TEST_STRING) const [matches, setMatches] = React.useState([]) const [error, setError] = React.useState(null) @@ -36,13 +40,37 @@ export function RegexTesterPage() { const handleClear = () => { setPattern("") - setFlags("g") + setFlags(SAMPLE_FLAGS) setTestString("") + setMatches([]) + setError(null) + } + + const handleSample = () => { + setPattern(SAMPLE_PATTERN) + setFlags(SAMPLE_FLAGS) + setTestString(SAMPLE_TEST_STRING) } + const actions: ToolAction[] = [ + { + id: "sample", + label: t.common.sample, + icon: TestTube2, + onClick: handleSample, + }, + { + id: "clear", + label: t.common.clear, + icon: Eraser, + onClick: handleClear, + destructive: true, + }, + ] + return (
-
+

@@ -51,12 +79,7 @@ export function RegexTesterPage() { {t.tools['regex_tester'].description}

-
- -
+
diff --git a/src/features/tools/uuid-generator/page.tsx b/src/features/tools/uuid-generator/page.tsx index 8813d8e8..2009d177 100644 --- a/src/features/tools/uuid-generator/page.tsx +++ b/src/features/tools/uuid-generator/page.tsx @@ -3,10 +3,10 @@ import * as React from "react" import { Copy, RefreshCw, Hash } from "lucide-react" import { toast } from "sonner" -import { Button } from "@/components/ui/button" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" +import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { v1 as uuidv1, v4 as uuidv4 } from "uuid" import { @@ -62,9 +62,27 @@ export function UuidGeneratorPage() { }) } + const actions: ToolAction[] = [ + { + id: "generate", + label: toolT.regenerate_action, + icon: RefreshCw, + onClick: generateUuids, + variant: "default", + }, + { + id: "copy_all", + label: t.common.copy_all, + icon: Copy, + onClick: () => void handleCopyAll(), + disabled: uuids.length === 0, + disabledReason: t.common.action_disabled_no_output, + }, + ] + return (
-
+

@@ -73,12 +91,7 @@ export function UuidGeneratorPage() { {toolT.description}

-
- -
+
@@ -145,10 +158,6 @@ export function UuidGeneratorPage() {
{toolT.generated_heading} ({uuids.length}) -