diff --git a/.gitignore b/.gitignore
index 45ecfaf8..6f1f542d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,5 +69,6 @@ apps/web/.codex
apps/desktop/src-tauri/target/debug/*
apps/desktop/src-tauri/target/release/
.claude
+/docs
-
+.superpowers
\ No newline at end of file
diff --git a/apps/backend/app/api/routes/api_client/api.py b/apps/backend/app/api/routes/api_client/api.py
index 3684aa21..d6ce504a 100644
--- a/apps/backend/app/api/routes/api_client/api.py
+++ b/apps/backend/app/api/routes/api_client/api.py
@@ -13,9 +13,11 @@
ApiClientHistoryOut,
HISTORY_MAX_ITEMS,
)
+from app.api.routes.api_client import collections_delta
router = APIRouter(prefix="/api-client", tags=["api-client"])
+router.include_router(collections_delta.router)
@router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections")
diff --git a/apps/backend/app/api/routes/api_client/collections_delta.py b/apps/backend/app/api/routes/api_client/collections_delta.py
new file mode 100644
index 00000000..acacf310
--- /dev/null
+++ b/apps/backend/app/api/routes/api_client/collections_delta.py
@@ -0,0 +1,280 @@
+"""Service + route for the collection-items delta endpoint.
+
+POST /api-client/collections/{collection_id}/items:apply-delta
+
+Applies a list of ops (add / update / delete / move) to a collection's items
+tree in-memory, then persists the result in a single atomic update_one call.
+Cache is invalidated via bump_version exactly as the existing patch_collection
+service does.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from bson import ObjectId
+from bson.errors import InvalidId
+from fastapi import APIRouter, Depends, HTTPException, status
+from pymongo import ReturnDocument
+from pymongo.errors import PyMongoError
+
+from app.api.routes.auth.services import get_current_uid
+from app.api.routes.api_client.schema import (
+ AddItemOp,
+ ApplyDeltaRequest,
+ ApplyDeltaResponse,
+ ApiClientCollectionOut,
+ DeleteItemOp,
+ MoveItemOp,
+ Op,
+ UpdateItemOp,
+)
+from app.core.cache import bump_version
+from app.database import db_manager
+from app.utils.collection_name import API_CLIENT_COLLECTIONS
+
+router = APIRouter()
+
+
+def _parse_oid(raw: str, *, kind: str) -> ObjectId:
+ try:
+ return ObjectId(raw)
+ except InvalidId as exc:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Invalid {kind} id.",
+ ) from exc
+
+
+def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut:
+ oid = doc.get("_id")
+ return ApiClientCollectionOut(
+ id=str(oid) if oid is not None else "",
+ name=doc.get("name", ""),
+ items=list(doc.get("items") or []),
+ )
+
+
+# ── In-memory tree walkers ────────────────────────────────────────────────────
+
+def _apply_add(
+ items: list[dict[str, Any]],
+ parent_id: str,
+ collection_id: str,
+ new_item: dict[str, Any],
+ position: int | None,
+) -> list[dict[str, Any]]:
+ """Insert new_item under parent_id. parent_id may be the collection root."""
+ if parent_id == collection_id:
+ # Insert at root level
+ if position is None or position >= len(items):
+ return items + [new_item]
+ result = list(items)
+ result.insert(position, new_item)
+ return result
+
+ return _add_in_children(items, parent_id, new_item, position)
+
+
+def _add_in_children(
+ items: list[dict[str, Any]],
+ parent_id: str,
+ new_item: dict[str, Any],
+ position: int | None,
+) -> list[dict[str, Any]]:
+ result = []
+ for item in items:
+ if item.get("id") == parent_id and item.get("type") == "folder":
+ children = list(item.get("items") or [])
+ if position is None or position >= len(children):
+ children = children + [new_item]
+ else:
+ children.insert(position, new_item)
+ result.append({**item, "items": children})
+ elif item.get("type") == "folder":
+ result.append({**item, "items": _add_in_children(item.get("items") or [], parent_id, new_item, position)})
+ else:
+ result.append(item)
+ return result
+
+
+def _apply_update(
+ items: list[dict[str, Any]],
+ item_id: str,
+ patch: dict[str, Any],
+) -> tuple[list[dict[str, Any]], bool]:
+ """Recursively apply patch to the item with item_id. Returns (new_items, found)."""
+ result = []
+ found = False
+ for item in items:
+ if item.get("id") == item_id:
+ result.append({**item, **patch})
+ found = True
+ elif item.get("type") == "folder":
+ new_children, child_found = _apply_update(item.get("items") or [], item_id, patch)
+ result.append({**item, "items": new_children})
+ if child_found:
+ found = True
+ else:
+ result.append(item)
+ return result, found
+
+
+def _apply_delete(
+ items: list[dict[str, Any]],
+ item_id: str,
+) -> tuple[list[dict[str, Any]], bool]:
+ """Recursively remove item_id. Returns (new_items, found)."""
+ new_items = []
+ found = False
+ for item in items:
+ if item.get("id") == item_id:
+ found = True
+ # skip (delete)
+ elif item.get("type") == "folder":
+ new_children, child_found = _apply_delete(item.get("items") or [], item_id)
+ new_items.append({**item, "items": new_children})
+ if child_found:
+ found = True
+ else:
+ new_items.append(item)
+ return new_items, found
+
+
+def _extract_item(
+ items: list[dict[str, Any]],
+ item_id: str,
+) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
+ """Remove and return item_id from the tree."""
+ new_items = []
+ extracted: dict[str, Any] | None = None
+ for item in items:
+ if item.get("id") == item_id:
+ extracted = item
+ elif item.get("type") == "folder":
+ new_children, child_extracted = _extract_item(item.get("items") or [], item_id)
+ new_items.append({**item, "items": new_children})
+ if child_extracted is not None:
+ extracted = child_extracted
+ else:
+ new_items.append(item)
+ return new_items, extracted
+
+
+def _id_exists_in_tree(items: list[dict[str, Any]], node_id: str) -> bool:
+ """Return True if node_id appears anywhere in the tree (any level)."""
+ for item in items:
+ if item.get("id") == node_id:
+ return True
+ if item.get("type") == "folder":
+ if _id_exists_in_tree(item.get("items") or [], node_id):
+ return True
+ return False
+
+
+def _apply_move(
+ items: list[dict[str, Any]],
+ item_id: str,
+ new_parent_id: str,
+ new_index: int,
+ collection_id: str,
+) -> tuple[list[dict[str, Any]], bool]:
+ """Move item_id to new_parent_id at new_index. Returns (new_items, success)."""
+ # Validate that new_parent_id exists (root or a folder in the tree).
+ if new_parent_id != collection_id and not _id_exists_in_tree(items, new_parent_id):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="move: new_parent_id not found",
+ )
+
+ # Step 1: extract the item
+ items_without, target = _extract_item(items, item_id)
+ if target is None:
+ return items, False
+
+ # Step 2: insert at new parent
+ new_items = _apply_add(items_without, new_parent_id, collection_id, target, new_index)
+ return new_items, True
+
+
+# ── Service ───────────────────────────────────────────────────────────────────
+
+async def apply_collection_delta(
+ uid: str,
+ collection_id: str,
+ ops: list[Op],
+) -> ApiClientCollectionOut:
+ oid = _parse_oid(collection_id, kind="collection")
+
+ # Fetch + ownership check BEFORE any mutation
+ doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
+ if not doc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
+
+ items: list[dict[str, Any]] = list(doc.get("items") or [])
+
+ # Apply all ops in-memory (atomic within a single request)
+ for op in ops:
+ if isinstance(op, AddItemOp):
+ items = _apply_add(items, op.parent_id, collection_id, op.item, op.position)
+
+ elif isinstance(op, UpdateItemOp):
+ items, found = _apply_update(items, op.item_id, op.patch)
+ if not found:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Item {op.item_id!r} not found.",
+ )
+
+ elif isinstance(op, DeleteItemOp):
+ items, found = _apply_delete(items, op.item_id)
+ if not found:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Item {op.item_id!r} not found.",
+ )
+
+ elif isinstance(op, MoveItemOp):
+ items, ok = _apply_move(items, op.item_id, op.new_parent_id, op.new_index, collection_id)
+ if not ok:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Item {op.item_id!r} not found.",
+ )
+
+ # Single atomic update
+ try:
+ updated_doc = await db_manager.find_one_and_update(
+ API_CLIENT_COLLECTIONS,
+ {"_id": oid, "created_by": uid},
+ {"$set": {"items": items}},
+ return_document=ReturnDocument.AFTER,
+ )
+ except PyMongoError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update collection.",
+ ) from exc
+
+ if not updated_doc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")
+
+ # Invalidate cache (same namespace as patch_collection / delete_collection)
+ await bump_version(ns="api_client", uid=uid)
+
+ return _collection_to_out(updated_doc)
+
+
+# ── Route ─────────────────────────────────────────────────────────────────────
+
+@router.post(
+ "/collections/{collection_id}/items:apply-delta",
+ response_model=ApplyDeltaResponse,
+ summary="Apply delta ops to a collection's items tree",
+)
+async def apply_delta(
+ collection_id: str,
+ body: ApplyDeltaRequest,
+ uid: str = Depends(get_current_uid),
+) -> ApplyDeltaResponse:
+ collection = await apply_collection_delta(uid, collection_id, body.ops)
+ return ApplyDeltaResponse(collection=collection)
diff --git a/apps/backend/app/api/routes/api_client/schema.py b/apps/backend/app/api/routes/api_client/schema.py
index d609d84c..58f383a1 100644
--- a/apps/backend/app/api/routes/api_client/schema.py
+++ b/apps/backend/app/api/routes/api_client/schema.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Annotated, Any, Literal, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
@@ -50,6 +50,47 @@ class ApiClientEnvironmentOut(ApiClientEnvironmentBase):
HISTORY_MAX_ITEMS = 100
+# ── Delta ops ────────────────────────────────────────────────────────────────
+
+class AddItemOp(BaseModel):
+ type: Literal["add"]
+ parent_id: str
+ item: dict[str, Any]
+ position: Optional[int] = None
+
+
+class UpdateItemOp(BaseModel):
+ type: Literal["update"]
+ item_id: str
+ patch: dict[str, Any]
+
+
+class DeleteItemOp(BaseModel):
+ type: Literal["delete"]
+ item_id: str
+
+
+class MoveItemOp(BaseModel):
+ type: Literal["move"]
+ item_id: str
+ new_parent_id: str
+ new_index: int
+
+
+Op = Annotated[
+ Union[AddItemOp, UpdateItemOp, DeleteItemOp, MoveItemOp],
+ Field(discriminator="type"),
+]
+
+
+class ApplyDeltaRequest(BaseModel):
+ ops: list[Op]
+
+
+class ApplyDeltaResponse(BaseModel):
+ collection: ApiClientCollectionOut
+
+
class ApiClientHistoryCreate(BaseModel):
"""One sent request snapshot (matches web HistoryRequest minus id)."""
diff --git a/apps/backend/tests/api/routes/api_client/__init__.py b/apps/backend/tests/api/routes/api_client/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/backend/tests/api/routes/api_client/test_collections_delta.py b/apps/backend/tests/api/routes/api_client/test_collections_delta.py
new file mode 100644
index 00000000..bb6472be
--- /dev/null
+++ b/apps/backend/tests/api/routes/api_client/test_collections_delta.py
@@ -0,0 +1,249 @@
+"""Tests for POST /api-client/collections/{id}/items:apply-delta.
+
+Auth pattern: override get_current_uid via FastAPI dependency_overrides
+(same pattern used in test_auth_memo.py). MongoDB is monkeypatched in-process
+so no real DB is needed.
+"""
+from __future__ import annotations
+
+import copy
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from bson import ObjectId
+from httpx import ASGITransport, AsyncClient
+
+from app.api.routes.auth.services import get_current_uid
+from app.main import app
+
+# Fixed IDs used throughout
+OWNER_UID = "test-owner-uid"
+OTHER_UID = "other-uid"
+COLLECTION_OID = ObjectId()
+COLLECTION_ID = str(COLLECTION_OID)
+
+FOLDER_ID = "folder-1"
+REQUEST_ID = "request-1"
+
+BASE_COLLECTION: dict[str, Any] = {
+ "_id": COLLECTION_OID,
+ "created_by": OWNER_UID,
+ "name": "My Collection",
+ "items": [
+ {
+ "id": FOLDER_ID,
+ "name": "Folder 1",
+ "type": "folder",
+ "items": [
+ {
+ "id": REQUEST_ID,
+ "name": "Existing Request",
+ "type": "request",
+ "method": "GET",
+ "url": "https://existing",
+ "params": [],
+ "headers": [],
+ "body": {"type": "none", "content": "", "formData": [], "urlEncoded": []},
+ "auth": {"type": "none"},
+ }
+ ],
+ "isOpen": True,
+ }
+ ],
+}
+
+
+def _make_client(uid: str) -> AsyncClient:
+ """Return an AsyncClient that authenticates as uid."""
+ app.dependency_overrides[get_current_uid] = lambda: uid
+ return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
+
+
+def _mock_find_one(collection_doc: dict | None):
+ """Monkeypatch db_manager.find_one to return collection_doc."""
+ async def _find_one(collection_name, query, projection=None):
+ if collection_doc is None:
+ return None
+ # Honour ownership filter
+ if query.get("created_by") != collection_doc.get("created_by"):
+ return None
+ return copy.deepcopy(collection_doc)
+
+ return _find_one
+
+
+def _mock_find_one_and_update(updated_doc: dict | None):
+ """Monkeypatch db_manager.find_one_and_update to return updated_doc.
+
+ The inner function stores the most-recent ``update_query`` on itself so
+ tests can inspect the ``$set`` payload without resorting to AsyncMock.
+ """
+ async def _find_one_and_update(collection_name, query, update_query, return_document=False):
+ _find_one_and_update.last_update_query = update_query # type: ignore[attr-defined]
+ if updated_doc is None:
+ return None
+ return copy.deepcopy(updated_doc)
+
+ _find_one_and_update.last_update_query = None # type: ignore[attr-defined]
+ return _find_one_and_update
+
+
+@pytest.fixture(autouse=True)
+def cleanup_overrides():
+ yield
+ app.dependency_overrides.pop(get_current_uid, None)
+
+
+# ── Test: add op inserts a new item under parent_id ──────────────────────────
+
+@pytest.mark.asyncio
+async def test_apply_delta_adds_item(monkeypatch):
+ new_item = {
+ "id": "new-req-1",
+ "name": "New Request",
+ "type": "request",
+ "method": "POST",
+ "url": "https://new",
+ "params": [],
+ "headers": [],
+ "body": {"type": "none", "content": "", "formData": [], "urlEncoded": []},
+ "auth": {"type": "none"},
+ }
+
+ # After the update, the collection has the new item under the folder
+ updated_collection = copy.deepcopy(BASE_COLLECTION)
+ updated_collection["items"][0]["items"].append(new_item)
+
+ mock_update = _mock_find_one_and_update(updated_collection)
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION))
+ monkeypatch.setattr(
+ "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update",
+ mock_update,
+ )
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock())
+
+ async with _make_client(OWNER_UID) as ac:
+ resp = await ac.post(
+ f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta",
+ json={"ops": [{"type": "add", "parent_id": FOLDER_ID, "item": new_item}]},
+ )
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert "collection" in body
+ folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID)
+ item_ids = {it["id"] for it in folder["items"]}
+ assert "new-req-1" in item_ids
+
+ # Assert the $set payload sent to MongoDB contains the new item
+ update_query = mock_update.last_update_query
+ assert update_query is not None
+ persisted_items = update_query["$set"]["items"]
+ persisted_folder = next(i for i in persisted_items if i["id"] == FOLDER_ID)
+ persisted_ids = {it["id"] for it in persisted_folder["items"]}
+ assert "new-req-1" in persisted_ids
+
+
+# ── Test: delete op removes an item ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_apply_delta_deletes_item(monkeypatch):
+ # After delete, folder has no children
+ updated_collection = copy.deepcopy(BASE_COLLECTION)
+ updated_collection["items"][0]["items"] = []
+
+ mock_update = _mock_find_one_and_update(updated_collection)
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION))
+ monkeypatch.setattr(
+ "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update",
+ mock_update,
+ )
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock())
+
+ async with _make_client(OWNER_UID) as ac:
+ resp = await ac.post(
+ f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta",
+ json={"ops": [{"type": "delete", "item_id": REQUEST_ID}]},
+ )
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID)
+ assert all(i["id"] != REQUEST_ID for i in folder["items"])
+
+ # Assert the $set payload sent to MongoDB has the item removed
+ update_query = mock_update.last_update_query
+ assert update_query is not None
+ persisted_items = update_query["$set"]["items"]
+ persisted_folder = next(i for i in persisted_items if i["id"] == FOLDER_ID)
+ assert all(it["id"] != REQUEST_ID for it in persisted_folder["items"])
+
+
+# ── Test: update op patches fields ───────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_apply_delta_updates_item(monkeypatch):
+ updated_collection = copy.deepcopy(BASE_COLLECTION)
+ # Patch the nested request's name
+ updated_collection["items"][0]["items"][0]["name"] = "Renamed Request"
+ updated_collection["items"][0]["items"][0]["url"] = "https://updated"
+
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION))
+ monkeypatch.setattr(
+ "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update",
+ _mock_find_one_and_update(updated_collection),
+ )
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock())
+
+ async with _make_client(OWNER_UID) as ac:
+ resp = await ac.post(
+ f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta",
+ json={"ops": [{"type": "update", "item_id": REQUEST_ID, "patch": {"name": "Renamed Request", "url": "https://updated"}}]},
+ )
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID)
+ req = next(i for i in folder["items"] if i["id"] == REQUEST_ID)
+ assert req["name"] == "Renamed Request"
+ assert req["url"] == "https://updated"
+
+
+# ── Test: unauthorized user (different uid) gets 404 ─────────────────────────
+
+@pytest.mark.asyncio
+async def test_apply_delta_wrong_uid_gets_404(monkeypatch):
+ # find_one returns None for OTHER_UID (ownership filter fails)
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION))
+ # bump_version should NOT be called
+ bump_mock = AsyncMock()
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", bump_mock)
+
+ async with _make_client(OTHER_UID) as ac:
+ resp = await ac.post(
+ f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta",
+ json={"ops": [{"type": "delete", "item_id": REQUEST_ID}]},
+ )
+
+ assert resp.status_code == 404
+ bump_mock.assert_not_called()
+
+
+# ── Test: move op with unknown new_parent_id returns 400 ─────────────────────
+
+@pytest.mark.asyncio
+async def test_apply_delta_move_unknown_parent_returns_400(monkeypatch):
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION))
+ bump_mock = AsyncMock()
+ monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", bump_mock)
+
+ async with _make_client(OWNER_UID) as ac:
+ resp = await ac.post(
+ f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta",
+ json={"ops": [{"type": "move", "item_id": REQUEST_ID, "new_parent_id": "does-not-exist", "new_index": 0}]},
+ )
+
+ assert resp.status_code == 400, resp.text
+ assert "new_parent_id" in resp.json().get("detail", "")
+ bump_mock.assert_not_called()
diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json
index 9309f407..010ef05d 100644
--- a/apps/web/messages/af.json
+++ b/apps/web/messages/af.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Versoek het misluk: {message}",
"curlPasted": "cURL geplak en suksesvol ontleed",
"responseCopied": "Antwoord na knipbord gekopieer",
- "codeCopied": "Kode na knipbord gekopieer"
+ "codeCopied": "Kode na knipbord gekopieer",
+ "copyFailed": "Failed to copy to clipboard"
},
"layout": {
"collections": "Versamelings",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metode",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Besig om te stuur...",
- "send": "Stuur"
+ "send": "Stuur",
+ "invalidJsonBodyHelp": "Cannot send: the JSON body is invalid"
},
"requestTabs": {
"params": "Params",
@@ -1528,7 +1530,9 @@
"previewTab": "Voorskou",
"headersTab": "Koptekste",
"binaryRawView": "Binêre data kan nie in rouaansig vertoon word nie.",
- "errorStatusLabel": "Fout"
+ "errorStatusLabel": "Fout",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nuwe oortjie"
@@ -1613,6 +1617,21 @@
"labelFolder": "Vouer",
"placeholderFolder": "Kies 'n vouer",
"save": "Stoor"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json
index 2d52c645..13d6c111 100644
--- a/apps/web/messages/ar.json
+++ b/apps/web/messages/ar.json
@@ -1467,7 +1467,8 @@
"requestFailed": "فشل الطلب: {message}",
"curlPasted": "تم لصق cURL وتحليله بنجاح",
"responseCopied": "تم نسخ الاستجابة",
- "codeCopied": "تم نسخ الكود"
+ "codeCopied": "تم نسخ الكود",
+ "copyFailed": "فشل نسخ المحتوى إلى الحافظة"
},
"layout": {
"collections": "المجموعات",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "الطريقة",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "جاري الإرسال...",
- "send": "إرسال"
+ "send": "إرسال",
+ "invalidJsonBodyHelp": "لا يمكن الإرسال: نص JSON غير صالح"
},
"requestTabs": {
"params": "معاملات",
@@ -1528,7 +1530,9 @@
"previewTab": "معاينة",
"headersTab": "الرؤوس",
"binaryRawView": "لا يمكن عرض البيانات الثنائية في العرض الخام.",
- "errorStatusLabel": "خطأ"
+ "errorStatusLabel": "خطأ",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "تبويب جديد"
@@ -1613,6 +1617,21 @@
"labelFolder": "المجلد",
"placeholderFolder": "اختر مجلداً",
"save": "حفظ"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json
index 7610b53b..f1cbba47 100644
--- a/apps/web/messages/ca.json
+++ b/apps/web/messages/ca.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Petició fallida: {message}",
"curlPasted": "cURL enganxat i analitzat correctament",
"responseCopied": "Resposta copiada al porta-retalls",
- "codeCopied": "Codi copiat al porta-retalls"
+ "codeCopied": "Codi copiat al porta-retalls",
+ "copyFailed": "No s'ha pogut copiar al porta-retalls"
},
"layout": {
"collections": "Col·leccions",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Mètode",
"urlPlaceholder": "https://api.exemple.com/v1/...",
"sending": "S'està enviant...",
- "send": "Envia"
+ "send": "Envia",
+ "invalidJsonBodyHelp": "No es pot enviar: el cos JSON no és vàlid"
},
"requestTabs": {
"params": "Params",
@@ -1528,7 +1530,9 @@
"previewTab": "Vista prèvia",
"headersTab": "Capçaleres",
"binaryRawView": "Les dades binàries no es poden mostrar en vista en brut.",
- "errorStatusLabel": "Error"
+ "errorStatusLabel": "Error",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Pestanya nova"
@@ -1613,6 +1617,21 @@
"labelFolder": "Carpeta",
"placeholderFolder": "Selecciona una carpeta",
"save": "Desa"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json
index fae1d684..bf3d722f 100644
--- a/apps/web/messages/cs.json
+++ b/apps/web/messages/cs.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Požadavek selhal: {message}",
"curlPasted": "cURL vložen a zpracován",
"responseCopied": "Odpověď zkopírována",
- "codeCopied": "Kód zkopírován"
+ "codeCopied": "Kód zkopírován",
+ "copyFailed": "Nepodařilo se zkopírovat do schránky"
},
"layout": {
"collections": "Kolekce",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metoda",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Odesílání...",
- "send": "Odeslat"
+ "send": "Odeslat",
+ "invalidJsonBodyHelp": "Nelze odeslat: tělo JSON není platné"
},
"requestTabs": {
"params": "Parametry",
@@ -1528,7 +1530,9 @@
"previewTab": "Náhled",
"headersTab": "Hlavičky",
"binaryRawView": "Binární data nelze zobrazit v surovém zobrazení.",
- "errorStatusLabel": "Chyba"
+ "errorStatusLabel": "Chyba",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nová karta"
@@ -1613,6 +1617,21 @@
"labelFolder": "Složka",
"placeholderFolder": "Vyberte složku",
"save": "Uložit"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json
index 6f585d63..c23b6f96 100644
--- a/apps/web/messages/da.json
+++ b/apps/web/messages/da.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Forespørgsel mislykkedes: {message}",
"curlPasted": "cURL blev indsat og parset",
"responseCopied": "Svar kopieret til udklipsholder",
- "codeCopied": "Kode kopieret til udklipsholder"
+ "codeCopied": "Kode kopieret til udklipsholder",
+ "copyFailed": "Kopiering til udklipsholder mislykkedes"
},
"layout": {
"collections": "Samlinger",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metode",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Sender...",
- "send": "Sende"
+ "send": "Sende",
+ "invalidJsonBodyHelp": "Kan ikke sende: JSON-brødteksten er ugyldig"
},
"requestTabs": {
"params": "Params",
@@ -1528,7 +1530,9 @@
"previewTab": "Forhåndsvisning",
"headersTab": "Overskrifter",
"binaryRawView": "Binære data kan ikke vises i rå visning.",
- "errorStatusLabel": "Fejl"
+ "errorStatusLabel": "Fejl",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Ny fane"
@@ -1613,6 +1617,21 @@
"labelFolder": "Folder",
"placeholderFolder": "Vælg en mappe",
"save": "Spare"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
index d2c3a8e4..6cc45e90 100644
--- a/apps/web/messages/de.json
+++ b/apps/web/messages/de.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Anfrage fehlgeschlagen: {message}",
"curlPasted": "cURL eingefügt und erfolgreich gelesen",
"responseCopied": "Antwort in die Zwischenablage kopiert",
- "codeCopied": "Code in die Zwischenablage kopiert"
+ "codeCopied": "Code in die Zwischenablage kopiert",
+ "copyFailed": "In die Zwischenablage kopieren fehlgeschlagen"
},
"layout": {
"collections": "Sammlungen",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Methode",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Senden...",
- "send": "Senden"
+ "send": "Senden",
+ "invalidJsonBodyHelp": "Senden nicht möglich: Der JSON-Body ist ungültig"
},
"requestTabs": {
"params": "Parameter",
@@ -1528,7 +1530,9 @@
"previewTab": "Vorschau",
"headersTab": "Header",
"binaryRawView": "Binärdaten können in der Rohansicht nicht angezeigt werden.",
- "errorStatusLabel": "Fehler"
+ "errorStatusLabel": "Fehler",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Neue Registerkarte"
@@ -1613,6 +1617,21 @@
"labelFolder": "Ordner",
"placeholderFolder": "Ordner wählen",
"save": "Speichern"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json
index 3344caa8..b1d4ecf1 100644
--- a/apps/web/messages/el.json
+++ b/apps/web/messages/el.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Το αίτημα απέτυχε: {message}",
"curlPasted": "Το cURL επικολλήθηκε και αναλύθηκε με επιτυχία",
"responseCopied": "Η απάντηση αντιγράφηκε στο πρόχειρο",
- "codeCopied": "Ο κώδικας αντιγράφηκε στο πρόχειρο"
+ "codeCopied": "Ο κώδικας αντιγράφηκε στο πρόχειρο",
+ "copyFailed": "Αποτυχία αντιγραφής στο πρόχειρο"
},
"layout": {
"collections": "Συλλογές",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Μέθοδος",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Αποστολή...",
- "send": "Στέλνω"
+ "send": "Στέλνω",
+ "invalidJsonBodyHelp": "Αδύνατη η αποστολή: το σώμα JSON δεν είναι έγκυρο"
},
"requestTabs": {
"params": "Params",
@@ -1528,7 +1530,9 @@
"previewTab": "Πρεμιέρα",
"headersTab": "Κεφαλίδες",
"binaryRawView": "Τα δυαδικά δεδομένα δεν μπορούν να εμφανιστούν σε μη επεξεργασμένη προβολή.",
- "errorStatusLabel": "Σφάλμα"
+ "errorStatusLabel": "Σφάλμα",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Νέα καρτέλα"
@@ -1613,6 +1617,21 @@
"labelFolder": "Ντοσιέ",
"placeholderFolder": "Επιλέξτε ένα φάκελο",
"save": "Εκτός"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 73bec8a4..839577b7 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -1488,7 +1488,8 @@
"requestFailed": "Request failed: {message}",
"curlPasted": "cURL pasted and parsed successfully",
"responseCopied": "Response copied to clipboard",
- "codeCopied": "Code copied to clipboard"
+ "codeCopied": "Code copied to clipboard",
+ "copyFailed": "Failed to copy to clipboard"
},
"layout": {
"collections": "Collections",
@@ -1501,7 +1502,8 @@
"methodPlaceholder": "Method",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Sending...",
- "send": "Send"
+ "send": "Send",
+ "invalidJsonBodyHelp": "Cannot send: the JSON body is invalid"
},
"requestTabs": {
"params": "Params",
@@ -1549,7 +1551,9 @@
"previewTab": "Preview",
"headersTab": "Headers",
"binaryRawView": "Binary data cannot be displayed in raw view.",
- "errorStatusLabel": "Error"
+ "errorStatusLabel": "Error",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "New tab"
@@ -1637,6 +1641,21 @@
"labelFolder": "Folder",
"placeholderFolder": "Select a folder",
"save": "Save"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index 4c9257d9..b6be8c10 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Error en la solicitud: {message}",
"curlPasted": "cURL pegado y analizado correctamente",
"responseCopied": "Respuesta copiada al portapapeles",
- "codeCopied": "Código copiado al portapapeles"
+ "codeCopied": "Código copiado al portapapeles",
+ "copyFailed": "Error al copiar al portapapeles"
},
"layout": {
"collections": "Colecciones",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Método",
"urlPlaceholder": "https://api.ejemplo.com/v1/...",
"sending": "Enviando...",
- "send": "Enviar"
+ "send": "Enviar",
+ "invalidJsonBodyHelp": "No se puede enviar: el cuerpo JSON no es válido"
},
"requestTabs": {
"params": "Parámetros",
@@ -1528,7 +1530,9 @@
"previewTab": "Vista previa",
"headersTab": "Cabeceras",
"binaryRawView": "Los datos binarios no se pueden mostrar en vista en bruto.",
- "errorStatusLabel": "Error"
+ "errorStatusLabel": "Error",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nueva pestaña"
@@ -1613,6 +1617,21 @@
"labelFolder": "Carpeta",
"placeholderFolder": "Selecciona una carpeta",
"save": "Guardar"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json
index e2b52967..cdb1e4dd 100644
--- a/apps/web/messages/fa.json
+++ b/apps/web/messages/fa.json
@@ -1467,7 +1467,8 @@
"requestFailed": "درخواست ناموفق بود: {message}",
"curlPasted": "cURL با موفقیت جایگذاری و تجزیه شد",
"responseCopied": "پاسخ در کلیپ بورد کپی شد",
- "codeCopied": "کد در کلیپ بورد کپی شد"
+ "codeCopied": "کد در کلیپ بورد کپی شد",
+ "copyFailed": "کپی در کلیپبورد ناموفق بود"
},
"layout": {
"collections": "مجموعه ها",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "روش",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "ارسال...",
- "send": "ارسال کنید"
+ "send": "ارسال کنید",
+ "invalidJsonBodyHelp": "ارسال امکانپذیر نیست: بدنه JSON نامعتبر است"
},
"requestTabs": {
"params": "پارامترها",
@@ -1528,7 +1530,9 @@
"previewTab": "پیش نمایش",
"headersTab": "سرصفحه ها",
"binaryRawView": "داده های باینری را نمی توان در نمای خام نمایش داد.",
- "errorStatusLabel": "خطا"
+ "errorStatusLabel": "خطا",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "برگه جدید"
@@ -1613,6 +1617,21 @@
"labelFolder": "پوشه",
"placeholderFolder": "یک پوشه را انتخاب کنید",
"save": "ذخیره کنید"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 694642da..41199de9 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Échec de la requête : {message}",
"curlPasted": "cURL collé et analysé",
"responseCopied": "Réponse copiée",
- "codeCopied": "Code copié"
+ "codeCopied": "Code copié",
+ "copyFailed": "Impossible de copier dans le presse-papiers"
},
"layout": {
"collections": "Collections",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Méthode",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Envoi…",
- "send": "Envoyer"
+ "send": "Envoyer",
+ "invalidJsonBodyHelp": "Envoi impossible : le corps JSON est invalide"
},
"requestTabs": {
"params": "Paramètres",
@@ -1528,7 +1530,9 @@
"previewTab": "Aperçu",
"headersTab": "En-têtes",
"binaryRawView": "Les données binaires ne peuvent pas s’afficher en brut.",
- "errorStatusLabel": "Erreur"
+ "errorStatusLabel": "Erreur",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nouvel onglet"
@@ -1613,6 +1617,21 @@
"labelFolder": "Dossier",
"placeholderFolder": "Choisir un dossier",
"save": "Enregistrer"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json
index d6ea2183..2e54a9eb 100644
--- a/apps/web/messages/id.json
+++ b/apps/web/messages/id.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Permintaan gagal: {message}",
"curlPasted": "cURL berhasil ditempelkan dan diuraikan",
"responseCopied": "Tanggapan disalin ke clipboard",
- "codeCopied": "Disalin ke papan klip"
+ "codeCopied": "Disalin ke papan klip",
+ "copyFailed": "Gagal menyalin ke papan klip"
},
"layout": {
"collections": "Penagihan",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metode
",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Mengirim...",
- "send": "Kirim"
+ "send": "Kirim",
+ "invalidJsonBodyHelp": "Tidak dapat mengirim: isi JSON tidak valid"
},
"requestTabs": {
"params": "parameter",
@@ -1528,7 +1530,9 @@
"previewTab": "Preview",
"headersTab": "Headers",
"binaryRawView": "Binary data cannot be displayed in raw view.",
- "errorStatusLabel": "Error"
+ "errorStatusLabel": "Error",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "New tab"
@@ -1613,6 +1617,21 @@
"labelFolder": "Folder",
"placeholderFolder": "Select a folder",
"save": "Save"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json
index 5d893705..a02d2c7a 100644
--- a/apps/web/messages/it.json
+++ b/apps/web/messages/it.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Richiesta non riuscita: {message}",
"curlPasted": "cURL incollato e analizzato correttamente",
"responseCopied": "Risposta copiata negli appunti",
- "codeCopied": "Codice copiato negli appunti"
+ "codeCopied": "Codice copiato negli appunti",
+ "copyFailed": "Copia negli appunti non riuscita"
},
"layout": {
"collections": "Collezioni",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metodo",
"urlPlaceholder": "https://api.esempio.com/v1/...",
"sending": "Invio...",
- "send": "Inviare"
+ "send": "Inviare",
+ "invalidJsonBodyHelp": "Impossibile inviare: il corpo JSON non è valido"
},
"requestTabs": {
"params": "Param",
@@ -1528,7 +1530,9 @@
"previewTab": "Anteprima",
"headersTab": "Intestazioni",
"binaryRawView": "I dati binari non possono essere visualizzati nella vista grezza.",
- "errorStatusLabel": "Errore"
+ "errorStatusLabel": "Errore",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nuova scheda"
@@ -1613,6 +1617,21 @@
"labelFolder": "Cartella",
"placeholderFolder": "Seleziona una cartella",
"save": "Salva"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json
index 2651fe1e..11ff90ba 100644
--- a/apps/web/messages/ja.json
+++ b/apps/web/messages/ja.json
@@ -1467,7 +1467,8 @@
"requestFailed": "リクエストに失敗しました:{message}",
"curlPasted": "cURLが貼り付けられ、正常に解析されました",
"responseCopied": "レスポンスをクリップボードにコピーしました",
- "codeCopied": "コードをクリップボードにコピーしました"
+ "codeCopied": "コードをクリップボードにコピーしました",
+ "copyFailed": "クリップボードへのコピーに失敗しました"
},
"layout": {
"collections": "コレクション",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "メソッド",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "送信中...",
- "send": "送信"
+ "send": "送信",
+ "invalidJsonBodyHelp": "送信できません: JSONボディが無効です"
},
"requestTabs": {
"params": "パラメータ",
@@ -1528,7 +1530,9 @@
"previewTab": "プレビュー",
"headersTab": "ヘッダー",
"binaryRawView": "バイナリデータはraw表示できません。",
- "errorStatusLabel": "エラー"
+ "errorStatusLabel": "エラー",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "新しいタブ"
@@ -1613,6 +1617,21 @@
"labelFolder": "フォルダー",
"placeholderFolder": "フォルダーを選択",
"save": "保存"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json
index f28edd5b..89d9983b 100644
--- a/apps/web/messages/ko.json
+++ b/apps/web/messages/ko.json
@@ -1467,7 +1467,8 @@
"requestFailed": "요청 실패: {message}",
"curlPasted": "cURL이 붙여넣기 및 파싱되었습니다",
"responseCopied": "응답이 클립보드에 복사되었습니다",
- "codeCopied": "코드가 클립보드에 복사되었습니다"
+ "codeCopied": "코드가 클립보드에 복사되었습니다",
+ "copyFailed": "클립보드에 복사하지 못했습니다"
},
"layout": {
"collections": "컬렉션",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "메서드",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "전송 중...",
- "send": "전송"
+ "send": "전송",
+ "invalidJsonBodyHelp": "전송 불가: JSON 본문이 유효하지 않습니다"
},
"requestTabs": {
"params": "매개변수",
@@ -1528,7 +1530,9 @@
"previewTab": "미리보기",
"headersTab": "헤더",
"binaryRawView": "이진 데이터는 원시 보기에서 표시할 수 없습니다.",
- "errorStatusLabel": "오류"
+ "errorStatusLabel": "오류",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "새 탭"
@@ -1613,6 +1617,21 @@
"labelFolder": "폴더",
"placeholderFolder": "폴더를 선택하세요",
"save": "저장"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json
index 3c59c6b6..1a24c0bc 100644
--- a/apps/web/messages/ms.json
+++ b/apps/web/messages/ms.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Permintaan gagal: {message}",
"curlPasted": "cURL ditampal dan dihurai dengan berjaya",
"responseCopied": "Respons disalin ke papan klip",
- "codeCopied": "Kod disalin ke papan klip"
+ "codeCopied": "Kod disalin ke papan klip",
+ "copyFailed": "Gagal menyalin ke papan klip"
},
"layout": {
"collections": "Koleksi",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Kaedah",
"urlPlaceholder": "https://api.contoh.com/v1/...",
"sending": "Menghantar...",
- "send": "Hantar"
+ "send": "Hantar",
+ "invalidJsonBodyHelp": "Tidak boleh hantar: kandungan JSON tidak sah"
},
"requestTabs": {
"params": "Parameter",
@@ -1528,7 +1530,9 @@
"previewTab": "Pratonton",
"headersTab": "Pengepala",
"binaryRawView": "Data binari tidak boleh dipaparkan dalam paparan mentah.",
- "errorStatusLabel": "Ralat"
+ "errorStatusLabel": "Ralat",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Tab baharu"
@@ -1613,6 +1617,21 @@
"labelFolder": "Folder",
"placeholderFolder": "Pilih folder",
"save": "Simpan"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json
index 5e324a41..da109173 100644
--- a/apps/web/messages/nb.json
+++ b/apps/web/messages/nb.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Forespørsel mislyktes: {message}",
"curlPasted": "cURL limt inn og analysert",
"responseCopied": "Svar kopiert til utklippstavle",
- "codeCopied": "Kode kopiert til utklippstavle"
+ "codeCopied": "Kode kopiert til utklippstavle",
+ "copyFailed": "Kopiering til utklippstavlen mislyktes"
},
"layout": {
"collections": "Samlinger",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metode",
"urlPlaceholder": "https://api.eksempel.no/v1/...",
"sending": "Sender...",
- "send": "Send"
+ "send": "Send",
+ "invalidJsonBodyHelp": "Kan ikke sende: JSON-kroppen er ugyldig"
},
"requestTabs": {
"params": "Parametere",
@@ -1528,7 +1530,9 @@
"previewTab": "Forhåndsvisning",
"headersTab": "Overskrifter",
"binaryRawView": "Binære data kan ikke vises i råvisning.",
- "errorStatusLabel": "Feil"
+ "errorStatusLabel": "Feil",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Ny fane"
@@ -1613,6 +1617,21 @@
"labelFolder": "Mappe",
"placeholderFolder": "Velg en mappe",
"save": "Lagre"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json
index 7d812b18..d6a8cddd 100644
--- a/apps/web/messages/nl.json
+++ b/apps/web/messages/nl.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Verzoek mislukt: {message}",
"curlPasted": "cURL geplakt en succesvol verwerkt",
"responseCopied": "Antwoord gekopieerd naar klembord",
- "codeCopied": "Code gekopieerd naar klembord"
+ "codeCopied": "Code gekopieerd naar klembord",
+ "copyFailed": "Kopiëren naar klembord mislukt"
},
"layout": {
"collections": "Collecties",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Methode",
"urlPlaceholder": "https://api.voorbeeld.nl/v1/...",
"sending": "Verzenden...",
- "send": "Verzenden"
+ "send": "Verzenden",
+ "invalidJsonBodyHelp": "Kan niet verzenden: de JSON-body is ongeldig"
},
"requestTabs": {
"params": "Parameters",
@@ -1528,7 +1530,9 @@
"previewTab": "Voorbeeld",
"headersTab": "Headers",
"binaryRawView": "Binaire gegevens kunnen niet worden weergegeven in ruwe weergave.",
- "errorStatusLabel": "Fout"
+ "errorStatusLabel": "Fout",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nieuw tabblad"
@@ -1613,6 +1617,21 @@
"labelFolder": "Map",
"placeholderFolder": "Selecteer een map",
"save": "Opslaan"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json
index 5022e838..bf385289 100644
--- a/apps/web/messages/pl.json
+++ b/apps/web/messages/pl.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Request failed: {message}",
"curlPasted": "cURL pasted and parsed successfully",
"responseCopied": "Response copied to clipboard",
- "codeCopied": "Code copied to clipboard"
+ "codeCopied": "Code copied to clipboard",
+ "copyFailed": "Kopiowanie do schowka nie powiodło się"
},
"layout": {
"collections": "Collections",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Method",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Sending...",
- "send": "Send"
+ "send": "Send",
+ "invalidJsonBodyHelp": "Nie można wysłać: treść JSON jest nieprawidłowa"
},
"requestTabs": {
"params": "Params",
@@ -1528,7 +1530,9 @@
"previewTab": "Preview",
"headersTab": "Headers",
"binaryRawView": "Binary data cannot be displayed in raw view.",
- "errorStatusLabel": "Error"
+ "errorStatusLabel": "Error",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "New tab"
@@ -1613,6 +1617,21 @@
"labelFolder": "Folder",
"placeholderFolder": "Select a folder",
"save": "Save"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json
index df10541a..0c4a441c 100644
--- a/apps/web/messages/pt-BR.json
+++ b/apps/web/messages/pt-BR.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Requisição falhou: {message}",
"curlPasted": "cURL colado e analisado com sucesso",
"responseCopied": "Resposta copiada para a área de transferência",
- "codeCopied": "Código copiado para a área de transferência"
+ "codeCopied": "Código copiado para a área de transferência",
+ "copyFailed": "Falha ao copiar para a área de transferência"
},
"layout": {
"collections": "Coleções",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Método",
"urlPlaceholder": "https://api.exemplo.com/v1/...",
"sending": "Enviando...",
- "send": "Enviar"
+ "send": "Enviar",
+ "invalidJsonBodyHelp": "Não é possível enviar: o corpo JSON é inválido"
},
"requestTabs": {
"params": "Parâmetros",
@@ -1528,7 +1530,9 @@
"previewTab": "Pré-visualização",
"headersTab": "Cabeçalhos",
"binaryRawView": "Dados binários não podem ser exibidos na visualização bruta.",
- "errorStatusLabel": "Erro"
+ "errorStatusLabel": "Erro",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Nova aba"
@@ -1613,6 +1617,21 @@
"labelFolder": "Pasta",
"placeholderFolder": "Selecione uma pasta",
"save": "Salvar"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json
index 11d09140..fa6fdad4 100644
--- a/apps/web/messages/pt.json
+++ b/apps/web/messages/pt.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Pedido falhou: {message}",
"curlPasted": "cURL colado e analisado com sucesso",
"responseCopied": "Resposta copiada para a área de transferência",
- "codeCopied": "Código copiado para a área de transferência"
+ "codeCopied": "Código copiado para a área de transferência",
+ "copyFailed": "Falha ao copiar para a área de transferência"
},
"layout": {
"collections": "Coleções",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Método",
"urlPlaceholder": "https://api.exemplo.com/v1/...",
"sending": "A enviar...",
- "send": "Enviar"
+ "send": "Enviar",
+ "invalidJsonBodyHelp": "Não é possível enviar: o corpo JSON é inválido"
},
"requestTabs": {
"params": "Parâmetros",
@@ -1528,7 +1530,9 @@
"previewTab": "Pré-visualização",
"headersTab": "Cabeçalhos",
"binaryRawView": "Dados binários não podem ser apresentados na vista em bruto.",
- "errorStatusLabel": "Erro"
+ "errorStatusLabel": "Erro",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Novo separador"
@@ -1613,6 +1617,21 @@
"labelFolder": "Pasta",
"placeholderFolder": "Seleciona uma pasta",
"save": "Guardar"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json
index 28439ca6..eceb9ac5 100644
--- a/apps/web/messages/ru.json
+++ b/apps/web/messages/ru.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Запрос не выполнен: {message}",
"curlPasted": "cURL вставлен и успешно разобран",
"responseCopied": "Ответ скопирован в буфер обмена",
- "codeCopied": "Код скопирован в буфер обмена"
+ "codeCopied": "Код скопирован в буфер обмена",
+ "copyFailed": "Не удалось скопировать в буфер обмена"
},
"layout": {
"collections": "Коллекции",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Метод",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Отправка...",
- "send": "Отправить"
+ "send": "Отправить",
+ "invalidJsonBodyHelp": "Невозможно отправить: тело JSON недействительно"
},
"requestTabs": {
"params": "Параметры",
@@ -1528,7 +1530,9 @@
"previewTab": "Предпросмотр",
"headersTab": "Заголовки",
"binaryRawView": "Бинарные данные не могут быть отображены в режиме исходного текста.",
- "errorStatusLabel": "Ошибка"
+ "errorStatusLabel": "Ошибка",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Новая вкладка"
@@ -1613,6 +1617,21 @@
"labelFolder": "Папка",
"placeholderFolder": "Выберите папку",
"save": "Сохранить"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json
index b021a7de..4f7a3087 100644
--- a/apps/web/messages/sv.json
+++ b/apps/web/messages/sv.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Förfrågan misslyckades: {message}",
"curlPasted": "cURL inklistrad och tolkad",
"responseCopied": "Svar kopierat till urklipp",
- "codeCopied": "Kod kopierad till urklipp"
+ "codeCopied": "Kod kopierad till urklipp",
+ "copyFailed": "Kopiering till urklipp misslyckades"
},
"layout": {
"collections": "Samlingar",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Metod",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Skickar...",
- "send": "Skicka"
+ "send": "Skicka",
+ "invalidJsonBodyHelp": "Kan inte skicka: JSON-kroppen är ogiltig"
},
"requestTabs": {
"params": "Parametrar",
@@ -1528,7 +1530,9 @@
"previewTab": "Förhandsgranskning",
"headersTab": "Rubriker",
"binaryRawView": "Binärdata kan inte visas i råvy.",
- "errorStatusLabel": "Fel"
+ "errorStatusLabel": "Fel",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Ny flik"
@@ -1613,6 +1617,21 @@
"labelFolder": "Mapp",
"placeholderFolder": "Välj en mapp",
"save": "Spara"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json
index a7f0ea6e..8eda2c1f 100644
--- a/apps/web/messages/tr.json
+++ b/apps/web/messages/tr.json
@@ -1467,7 +1467,8 @@
"requestFailed": "İstek başarısız: {message}",
"curlPasted": "cURL yapıştırıldı ve başarıyla ayrıştırıldı",
"responseCopied": "Yanıt panoya kopyalandı",
- "codeCopied": "Kod panoya kopyalandı"
+ "codeCopied": "Kod panoya kopyalandı",
+ "copyFailed": "Panoya kopyalanamadı"
},
"layout": {
"collections": "Koleksiyonlar",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Yöntem",
"urlPlaceholder": "https://api.ornek.com/v1/...",
"sending": "Gönderiliyor...",
- "send": "Gönder"
+ "send": "Gönder",
+ "invalidJsonBodyHelp": "Gönderilemedi: JSON gövdesi geçersiz"
},
"requestTabs": {
"params": "Parametreler",
@@ -1528,7 +1530,9 @@
"previewTab": "Önizleme",
"headersTab": "Başlıklar",
"binaryRawView": "İkili veriler ham görünümde gösterilemiyor.",
- "errorStatusLabel": "Hata"
+ "errorStatusLabel": "Hata",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Yeni sekme"
@@ -1613,6 +1617,21 @@
"labelFolder": "Klasör",
"placeholderFolder": "Bir klasör seçin",
"save": "Kaydet"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json
index 9617ba93..00a24344 100644
--- a/apps/web/messages/uk.json
+++ b/apps/web/messages/uk.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Запит не вдався: {message}",
"curlPasted": "cURL вставлено та розібрано успішно",
"responseCopied": "Відповідь скопійовано до буфера обміну",
- "codeCopied": "Код скопійовано до буфера обміну"
+ "codeCopied": "Код скопійовано до буфера обміну",
+ "copyFailed": "Не вдалося скопіювати до буфера обміну"
},
"layout": {
"collections": "Колекції",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Метод",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Надсилання...",
- "send": "Надіслати"
+ "send": "Надіслати",
+ "invalidJsonBodyHelp": "Неможливо надіслати: тіло JSON недійсне"
},
"requestTabs": {
"params": "Параметри",
@@ -1528,7 +1530,9 @@
"previewTab": "Перегляд",
"headersTab": "Заголовки",
"binaryRawView": "Двійкові дані не можна відобразити у вигляді тексту.",
- "errorStatusLabel": "Помилка"
+ "errorStatusLabel": "Помилка",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Нова вкладка"
@@ -1613,6 +1617,21 @@
"labelFolder": "Тека",
"placeholderFolder": "Обрати теку",
"save": "Зберегти"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json
index f70c457b..6620d695 100644
--- a/apps/web/messages/vi.json
+++ b/apps/web/messages/vi.json
@@ -1467,7 +1467,8 @@
"requestFailed": "Yêu cầu thất bại: {message}",
"curlPasted": "Đã dán và phân tích cURL thành công",
"responseCopied": "Đã sao chép phản hồi vào bộ nhớ tạm",
- "codeCopied": "Đã sao chép mã vào bộ nhớ tạm"
+ "codeCopied": "Đã sao chép mã vào bộ nhớ tạm",
+ "copyFailed": "Sao chép vào clipboard không thành công"
},
"layout": {
"collections": "Bộ sưu tập",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "Phương thức",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "Đang gửi...",
- "send": "Gửi"
+ "send": "Gửi",
+ "invalidJsonBodyHelp": "Không thể gửi: nội dung JSON không hợp lệ"
},
"requestTabs": {
"params": "Tham số",
@@ -1528,7 +1530,9 @@
"previewTab": "Xem trước",
"headersTab": "Headers",
"binaryRawView": "Dữ liệu nhị phân không thể hiển thị ở chế độ raw.",
- "errorStatusLabel": "Lỗi"
+ "errorStatusLabel": "Lỗi",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "Tab mới"
@@ -1613,6 +1617,21 @@
"labelFolder": "Thư mục",
"placeholderFolder": "Chọn thư mục",
"save": "Lưu"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json
index 44f5b97b..be30441d 100644
--- a/apps/web/messages/zh.json
+++ b/apps/web/messages/zh.json
@@ -1467,7 +1467,8 @@
"requestFailed": "请求失败:{message}",
"curlPasted": "cURL 已粘贴并解析成功",
"responseCopied": "响应已复制到剪贴板",
- "codeCopied": "代码已复制到剪贴板"
+ "codeCopied": "代码已复制到剪贴板",
+ "copyFailed": "复制到剪贴板失败"
},
"layout": {
"collections": "集合",
@@ -1480,7 +1481,8 @@
"methodPlaceholder": "方法",
"urlPlaceholder": "https://api.example.com/v1/...",
"sending": "发送中…",
- "send": "发送"
+ "send": "发送",
+ "invalidJsonBodyHelp": "无法发送:JSON 正文无效"
},
"requestTabs": {
"params": "参数",
@@ -1528,7 +1530,9 @@
"previewTab": "预览",
"headersTab": "响应头",
"binaryRawView": "二进制数据无法在原始视图中显示。",
- "errorStatusLabel": "错误"
+ "errorStatusLabel": "错误",
+ "truncatedBanner": "Showing first {shownKb} KB of a large response.",
+ "downloadFullBody": "Download full body"
},
"tabBar": {
"addTabAria": "新标签"
@@ -1613,6 +1617,21 @@
"labelFolder": "文件夹",
"placeholderFolder": "选择文件夹",
"save": "保存"
+ },
+ "shortcuts": {
+ "ariaLabel": "Keyboard shortcuts",
+ "title": "Keyboard shortcuts",
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "sendRequest": "Send request"
+ },
+ "toolbar": {
+ "moreActions": "More actions",
+ "code": "Generate code",
+ "save": "Save request",
+ "importCurl": "Import cURL",
+ "environments": "Environments",
+ "shortcuts": "Keyboard shortcuts"
}
},
"RichEditor": {
diff --git a/apps/web/package.json b/apps/web/package.json
index f3ebfd0f..bc276195 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -16,6 +16,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^3",
"@monaco-editor/react": "^4.7.0",
"@pdfsmaller/pdf-decrypt": "^1.0.1",
"@pdfsmaller/pdf-encrypt": "^1.0.2",
@@ -45,6 +46,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@space-man/react-theme-animation": "^1.1.1",
"@tabler/icons-react": "^3.35.0",
+ "@tanstack/react-query": "^5",
"@tanstack/react-virtual": "^3.14.3",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
@@ -100,7 +102,9 @@
"react-dom": "^19.2.0",
"react-firebase-hooks": "^5.1.1",
"react-github-calendar": "^5.0.6",
+ "react-hook-form": "^7",
"react-resizable-panels": "^3.0.6",
+ "react-window": "^2.2.7",
"reflect-metadata": "^0.2.2",
"smol-toml": "^1.6.1",
"sonner": "^2.0.7",
@@ -116,6 +120,7 @@
"vanilla-jsoneditor": "^3.12.0",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
+ "zod": "^3",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -133,6 +138,7 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/react-window": "^2.0.0",
"@types/turndown": "^5.0.6",
"@vitalets/google-translate-api": "^9.2.1",
"autoprefixer": "^10.4.21",
diff --git a/apps/web/src/app/app/api-client/page.tsx b/apps/web/src/app/app/api-client/page.tsx
index 6e4d5406..ada5fdd8 100644
--- a/apps/web/src/app/app/api-client/page.tsx
+++ b/apps/web/src/app/app/api-client/page.tsx
@@ -1,8 +1,12 @@
import { ApiClientLazy } from '@/components/app-tools/client-only-tool-loaders'
+import { ApiClientProviders } from '@/components/api-client/context/api-client-context'
+
export default function ApiClientPage() {
return (
-
+
+
+
)
}
diff --git a/apps/web/src/app/app/to-do/KanbanCard.tsx b/apps/web/src/app/app/to-do/KanbanCard.tsx
index 3ae4d8cf..ad9baa0a 100644
--- a/apps/web/src/app/app/to-do/KanbanCard.tsx
+++ b/apps/web/src/app/app/to-do/KanbanCard.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, lazy, Suspense } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Edit, Calendar, Tag, CheckCircle2, MoreHorizontal, Trash2, Copy, Check, Play, Pause, Timer, Archive, ArchiveRestore } from "lucide-react";
@@ -8,8 +8,11 @@ import { formatElapsed, getElapsedMinutes } from "@/app/app/to-do/utils/taskTime
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Task } from "@/app/app/to-do/types/Task";
-import TaskEditDialog from "./TaskEditDialog";
-import { differenceInDays, isPast, parseISO, format, isValid, parse } from "date-fns";
+
+const TaskEditDialog = lazy(() => import("./TaskEditDialog"));
+import { LazyBoundary } from "./components/LazyBoundary";
+import { differenceInDays, isPast } from "date-fns";
+import { safeParseDate } from "@/app/app/to-do/utils/taskDate";
import { cn } from "@/lib/utils";
import {
Tooltip,
@@ -27,109 +30,6 @@ import {
import { STATUS_CONFIG, PRIORITY_CONFIG } from "./config/constants";
import { useProjectContext } from "@/app/app/to-do/context/ProjectContext";
-// Helper to safely parse and format dates
-const safeFormatDate = (dateString: string | undefined, formatStr: string): string => {
- if (!dateString || dateString === "Unknown") return dateString || "Unknown";
-
- // Try parsing as ISO first (for new data)
- try {
- const isoDate = parseISO(dateString);
- if (isValid(isoDate)) {
- return format(isoDate, formatStr);
- }
- } catch {
- // Not an ISO string, continue
- }
-
- // Try parsing the formatted date string from TaskContext: "dd MMM yyyy, hh:mm a"
- try {
- const parsedDate = parse(dateString, "dd MMM yyyy, hh:mm a", new Date());
- if (isValid(parsedDate)) {
- return format(parsedDate, formatStr);
- }
- } catch {
- // Not in that format, continue
- }
-
- // Try parsing just the date part "dd MMM yyyy"
- try {
- const parts = dateString.split(',');
- if (parts.length > 0) {
- const datePart = parts[0].trim(); // "dd MMM yyyy"
- const parsedDate = parse(datePart, "dd MMM yyyy", new Date());
- if (isValid(parsedDate)) {
- return format(parsedDate, formatStr);
- }
- }
- } catch {
- // Fall through
- }
-
- // Try parsing as Date object (fallback)
- try {
- const date = new Date(dateString);
- if (isValid(date)) {
- return format(date, formatStr);
- }
- } catch {
- // Not a valid date string
- }
-
- // If all parsing fails, return a shortened version of the string
- return dateString.length > 15 ? dateString.substring(0, 15) + "..." : dateString;
-};
-
-// Helper to safely parse date for calculations
-const safeParseDate = (dateString: string | undefined): Date | null => {
- if (!dateString) return null;
-
- // Try parsing as ISO first
- try {
- const isoDate = parseISO(dateString);
- if (isValid(isoDate)) {
- return isoDate;
- }
- } catch {
- // Not an ISO string
- }
-
- // Try parsing the formatted date string from TaskContext: "dd MMM yyyy, hh:mm a"
- try {
- const parsedDate = parse(dateString, "dd MMM yyyy, hh:mm a", new Date());
- if (isValid(parsedDate)) {
- return parsedDate;
- }
- } catch {
- // Not in that format
- }
-
- // Try parsing just the date part "dd MMM yyyy"
- try {
- const parts = dateString.split(',');
- if (parts.length > 0) {
- const datePart = parts[0].trim();
- const parsedDate = parse(datePart, "dd MMM yyyy", new Date());
- if (isValid(parsedDate)) {
- return parsedDate;
- }
- }
- } catch {
- // Fall through
- }
-
- // Try parsing as Date object (fallback)
- try {
- const date = new Date(dateString);
- if (isValid(date)) {
- return date;
- }
- } catch {
- // Not a valid date
- }
-
- return null;
-};
-
interface KanbanCardProps {
task: Task;
onUpdateTask: (id: string, updates: Partial) => Promise;
@@ -201,11 +101,11 @@ export default function KanbanCard({ task, onUpdateTask, onDeleteTask }: KanbanC
{...attributes}
{...listeners}
className={cn(
- "group relative p-3 md:p-4 rounded-xl border transition-all duration-300",
- "hover:shadow-md hover:scale-[1.02] bg-card",
+ "group relative p-3 md:p-4 rounded-xl border transition-all duration-200",
+ "hover:shadow-md hover:-translate-y-0.5 bg-card",
"cursor-grab active:cursor-grabbing border-border",
"hover:border-primary/30",
- isDragging && "shadow-2xl scale-110 z-50 rotate-2 opacity-90",
+ isDragging && "shadow-2xl scale-105 z-50 rotate-1 opacity-90",
task.status === "completed" && "opacity-75 bg-muted/30"
)}
role="button"
@@ -503,12 +403,18 @@ export default function KanbanCard({ task, onUpdateTask, onDeleteTask }: KanbanC
-
+ {isEditDialogOpen && (
+
+
+
+
+
+ )}
>
);
}
diff --git a/apps/web/src/app/app/to-do/KanbanColumn.tsx b/apps/web/src/app/app/to-do/KanbanColumn.tsx
index 7aa44134..a784fa68 100644
--- a/apps/web/src/app/app/to-do/KanbanColumn.tsx
+++ b/apps/web/src/app/app/to-do/KanbanColumn.tsx
@@ -1,7 +1,9 @@
"use client";
+import { useRef } from "react";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { useVirtualizer } from "@tanstack/react-virtual";
import { Task } from "@/app/app/to-do/types/Task";
import KanbanCard from "./KanbanCard";
import { LucideIcon } from "lucide-react";
@@ -18,6 +20,9 @@ interface KanbanColumnProps {
onDeleteTask: (id: string) => void;
}
+const VIRTUALIZATION_THRESHOLD = 30;
+const ESTIMATED_CARD_HEIGHT = 160;
+
export default function KanbanColumn({
id,
title,
@@ -28,12 +33,19 @@ export default function KanbanColumn({
}: KanbanColumnProps) {
const tKanban = useTranslations("Tasks.kanban");
const tStatus = useTranslations("Tasks.status");
- const { setNodeRef, isOver } = useDroppable({
- id,
- });
+ const { setNodeRef, isOver } = useDroppable({ id });
const config = STATUS_CONFIG[id];
const taskIds = tasks.map((task) => task.id);
+ const shouldVirtualize = tasks.length > VIRTUALIZATION_THRESHOLD;
+
+ const scrollRef = useRef(null);
+ const virtualizer = useVirtualizer({
+ count: shouldVirtualize ? tasks.length : 0,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => ESTIMATED_CARD_HEIGHT,
+ overscan: 6,
+ });
return (
- {/* Enhanced Column Header */}
+ {/* Column Header */}
- {/* Tasks - Enhanced */}
+ {/* Tasks */}
-
+
{tasks.length === 0 ? (
)}
+ ) : shouldVirtualize ? (
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const task = tasks[virtualRow.index];
+ return (
+
+
+
+ );
+ })}
+
) : (
tasks.map((task, index) => (
import("@/app/app/to-do/KanbanBoard"));
+const ExportImportDialog = lazy(() => import("@/app/app/to-do/ExportImportDialog"));
+const TaskCommandPalette = lazy(() =>
+ import("@/app/app/to-do/components/TaskCommandPalette").then((m) => ({
+ default: m.TaskCommandPalette,
+ }))
+);
import { useTaskContext } from "@/app/app/to-do/context/TaskContext";
import { useProjectContext } from "@/app/app/to-do/context/ProjectContext";
import { ListTodo, Circle, LayoutGrid, List, Search, X, Plus, Folder, Archive, ArchiveRestore } from "lucide-react";
@@ -55,6 +62,7 @@ export const TaskContainer = () => {
const [searchQuery, setSearchQuery] = useState("");
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const searchInputRef = useRef
(null);
const taskFormInputRef = useRef(null);
const {
@@ -98,42 +106,54 @@ export const TaskContainer = () => {
// Filter tasks based on filterStatus for list view
// For kanban view, always show all tasks (filtering is handled by columns)
- const filteredTasks = viewMode === "kanban" || filterStatus === "all"
- ? searchFilteredTasks
- : searchFilteredTasks.filter(task => task.status === filterStatus);
-
- const sortedTasks = [...filteredTasks].sort(
- (a: { status: "ongoing" | "not-started" | "completed" },
- b: { status: "ongoing" | "not-started" | "completed" }) => {
- const statusOrder: { [key in "ongoing" | "not-started" | "completed"]: number } = {
- ongoing: 1,
- "not-started": 2,
- completed: 3,
- };
- return statusOrder[a.status] - statusOrder[b.status];
- }
+ const filteredTasks = useMemo(
+ () =>
+ viewMode === "kanban" || filterStatus === "all"
+ ? searchFilteredTasks
+ : searchFilteredTasks.filter((task) => task.status === filterStatus),
+ [searchFilteredTasks, viewMode, filterStatus]
);
+ const sortedTasks = useMemo(() => {
+ const statusOrder: { ongoing: number; "not-started": number; completed: number } = {
+ ongoing: 1,
+ "not-started": 2,
+ completed: 3,
+ };
+ return [...filteredTasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
+ }, [filteredTasks]);
+
// Calculate statistics using all tasks stats
const completionRate = allTaskStats.total > 0 ? Math.round((allTaskStats.completed / allTaskStats.total) * 100) : 0;
- const handleAddTask = (taskText: string) => {
- addTask(taskText);
- setIsDrawerOpen(false);
- };
+ const handleAddTask = useCallback(
+ (taskText: string) => {
+ addTask(taskText);
+ setIsDrawerOpen(false);
+ },
+ [addTask]
+ );
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const hasStatusFilter = filterStatus !== "all";
const hasProjectFilter = filterProject !== "all";
const hasSearchFilter = searchQuery.trim().length > 0;
const hasActiveFilters = hasStatusFilter || hasProjectFilter || hasSearchFilter;
- const activeProject = projects.find((project) => project.id === filterProject);
+ const activeProject = useMemo(
+ () => projects.find((project) => project.id === filterProject),
+ [projects, filterProject]
+ );
- const clearAllFilters = () => {
+ const liveStatsMessage = useMemo(() => {
+ const { total, completed, ongoing, notStarted } = allTaskStats;
+ return `${total} task${total === 1 ? "" : "s"}: ${notStarted} not started, ${ongoing} ongoing, ${completed} completed`;
+ }, [allTaskStats]);
+
+ const clearAllFilters = useCallback(() => {
setSearchQuery("");
setFilterStatus("all");
setFilterProject("all");
- };
+ }, [setFilterStatus, setFilterProject]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -145,7 +165,7 @@ export const TaskContainer = () => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
- searchInputRef.current?.focus();
+ setIsPaletteOpen((open) => !open);
return;
}
@@ -171,10 +191,16 @@ export const TaskContainer = () => {
}, [hasSearchFilter, isMobile]);
return (
-
+
+
+ {liveStatsMessage}
+
{/* Mobile-specific Header */}
{isMobile && (
-
+
@@ -202,8 +228,8 @@ export const TaskContainer = () => {
{/* Title & Stats */}
-
{tPage("myTasksTitle")}
-
+
{tPage("myTasksTitle")}
+
{tPage("statsLine", { total: allTaskStats.total, percent: completionRate })}
@@ -288,10 +314,10 @@ export const TaskContainer = () => {
-
+
{tPage("myTasksTitle")}
-
+
{tPage("statsLineDesktop", { total: allTaskStats.total, percent: completionRate })}
@@ -302,9 +328,9 @@ export const TaskContainer = () => {
- {allTaskStats.total}
+ {allTaskStats.total}
-
{tFilters("total")}
+
{tFilters("total")}
{Object.values(STATUS_CONFIG).map((config) => {
@@ -325,9 +351,9 @@ export const TaskContainer = () => {
>
- {count}
+ {count}
-
{tStatus(`${config.id}.label` as any)}
+
{tStatus(`${config.id}.label` as any)}
);
})}
@@ -555,13 +581,23 @@ export const TaskContainer = () => {
{viewMode === "kanban" ? (
-
+
+
+
+
+ }
+ >
+
+
+
) : (
@@ -640,12 +676,35 @@ export const TaskContainer = () => {
-
+ {isExportDialogOpen && (
+
+
+
+
+
+ )}
+
+ {isPaletteOpen && (
+
+
+ {
+ if (isMobile) setIsDrawerOpen(true);
+ else taskFormInputRef.current?.focus();
+ }}
+ />
+
+
+ )}
);
};
diff --git a/apps/web/src/app/app/to-do/TaskEditDialog.tsx b/apps/web/src/app/app/to-do/TaskEditDialog.tsx
index 23fceeb2..eca94b1c 100644
--- a/apps/web/src/app/app/to-do/TaskEditDialog.tsx
+++ b/apps/web/src/app/app/to-do/TaskEditDialog.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
+import { z } from "zod";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerClose } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
@@ -33,6 +34,42 @@ const priorityConfig = {
low: { label: "Low", icon: Zap, color: "text-blue-500", bgColor: "bg-blue-50 dark:bg-blue-950", borderColor: "border-blue-500" },
};
+function hexToRgb(hex: string): [number, number, number] | null {
+ const m = hex.replace("#", "").match(/^([\da-f]{6}|[\da-f]{3})$/i);
+ if (!m) return null;
+ const h = m[1].length === 3
+ ? m[1].split("").map((c) => c + c).join("")
+ : m[1];
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
+}
+
+function relLuminance([r, g, b]: [number, number, number]): number {
+ const srgb = [r, g, b].map((v) => {
+ const x = v / 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+ });
+ return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
+}
+
+function contrastVsWhite(hex: string): number | null {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return null;
+ const l = relLuminance(rgb);
+ return (1.0 + 0.05) / (l + 0.05);
+}
+
+const taskEditSchema = z.object({
+ text: z.string().trim().min(1, "Title is required").max(200, "Max 200 characters"),
+ description: z.string().max(2000, "Max 2000 characters").optional(),
+ timeEstimate: z
+ .number()
+ .int("Must be a whole number")
+ .positive("Must be positive")
+ .optional(),
+});
+
+type TaskEditErrors = Partial>;
+
const predefinedTags = [
{ name: "Work", color: "#3b82f6" },
{ name: "Personal", color: "#10b981" },
@@ -54,6 +91,7 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
const [customTagColor, setCustomTagColor] = useState("#3b82f6");
const [isSaving, setIsSaving] = useState(false);
const [selectedDate, setSelectedDate] = useState(undefined);
+ const [errors, setErrors] = useState({});
const isMobile = useIsMobile();
const { projects } = useProjectContext();
@@ -70,16 +108,44 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
timeEstimate: task.timeEstimate,
projectId: task.projectId,
});
+ setErrors({});
if (task.dueDate) {
setSelectedDate(new Date(task.dueDate));
}
}
}, [open, task]);
+ const clearError = useCallback((field: keyof TaskEditErrors) => {
+ setErrors((prev) => {
+ if (!prev[field]) return prev;
+ const next = { ...prev };
+ delete next[field];
+ return next;
+ });
+ }, []);
+
const handleSave = async () => {
+ const result = taskEditSchema.safeParse({
+ text: editedTask.text ?? "",
+ description: editedTask.description,
+ timeEstimate: editedTask.timeEstimate,
+ });
+
+ if (!result.success) {
+ const fieldErrors: TaskEditErrors = {};
+ for (const issue of result.error.issues) {
+ const path = issue.path[0] as keyof TaskEditErrors | undefined;
+ if (path && !fieldErrors[path]) {
+ fieldErrors[path] = issue.message;
+ }
+ }
+ setErrors(fieldErrors);
+ return;
+ }
+
setIsSaving(true);
try {
- await onSave(editedTask);
+ await onSave({ ...editedTask, text: result.data.text });
onOpenChange(false);
} catch (error) {
console.error("Failed to save task:", error);
@@ -139,14 +205,25 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
{/* Task Title */}
-
+
setEditedTask({ ...editedTask, text: e.target.value })}
+ onChange={(e) => {
+ setEditedTask({ ...editedTask, text: e.target.value });
+ clearError("text");
+ }}
placeholder={t("taskTitlePlaceholder")}
- className="text-base"
+ className={cn("text-base", errors.text && "border-destructive focus-visible:ring-destructive/30")}
+ aria-invalid={!!errors.text}
+ aria-describedby={errors.text ? "task-title-error" : undefined}
/>
+ {errors.text && (
+
{errors.text}
+ )}
{/* Description */}
@@ -155,10 +232,18 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
{/* Status, Priority, Due Date Row */}
@@ -304,9 +389,18 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
type="number"
min="0"
value={editedTask.timeEstimate || ""}
- onChange={(e) => setEditedTask({ ...editedTask, timeEstimate: parseInt(e.target.value) || undefined })}
+ onChange={(e) => {
+ setEditedTask({ ...editedTask, timeEstimate: parseInt(e.target.value) || undefined });
+ clearError("timeEstimate");
+ }}
placeholder={t("timeEstimatePlaceholder")}
+ className={cn(errors.timeEstimate && "border-destructive focus-visible:ring-destructive/30")}
+ aria-invalid={!!errors.timeEstimate}
+ aria-describedby={errors.timeEstimate ? "time-estimate-error" : undefined}
/>
+ {errors.timeEstimate && (
+ {errors.timeEstimate}
+ )}
{/* Tags */}
@@ -355,11 +449,23 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
value={customTagColor}
onChange={(e) => setCustomTagColor(e.target.value)}
className="w-20"
+ aria-label="Tag color"
/>
+ {(() => {
+ const ratio = contrastVsWhite(customTagColor);
+ if (ratio !== null && ratio < 4.5) {
+ return (
+
+ Low contrast ({ratio.toFixed(2)}:1) — white tag label may be hard to read. WCAG AA needs ≥ 4.5:1.
+
+ );
+ }
+ return null;
+ })()}
@@ -425,7 +531,7 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
-