From ad3363a5cd2317c94bbd8d032bdb4f5487e245fa Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Wed, 24 Jun 2026 15:21:45 +0530 Subject: [PATCH 1/5] Initial optimisation --- .../app/api/routes/code_snippets/api.py | 5 +- .../app/api/routes/code_snippets/schema.py | 21 +++--- .../app/api/routes/code_snippets/services.py | 46 +++--------- .../app/api/routes/environment_manager/api.py | 5 +- .../api/routes/environment_manager/schema.py | 7 +- .../routes/environment_manager/services.py | 45 ++++------- apps/backend/app/api/routes/notes/api.py | 4 +- apps/backend/app/api/routes/notes/schema.py | 28 +++---- apps/backend/app/api/routes/notes/services.py | 27 ++----- apps/backend/app/api/routes/passwords/api.py | 3 +- .../app/api/routes/passwords/schema.py | 9 +-- .../app/api/routes/passwords/services.py | 51 ++++--------- apps/backend/app/utils/crud.py | 74 +++++++++++++++++++ apps/web/package.json | 1 - .../collections/virtual-history-list.tsx | 67 +++++++++-------- apps/web/src/lib/backend-api.ts | 30 ++++++++ apps/web/src/lib/code-snippets-api.ts | 31 ++------ apps/web/src/lib/environment-manager-api.ts | 37 ++-------- apps/web/src/lib/password-manager-api.ts | 37 ++-------- apps/web/src/lib/s3-drive-api.ts | 53 ++++--------- apps/web/src/lib/url-shortener-api.ts | 33 ++------- apps/web/src/lib/user-preferences-api.ts | 42 ++--------- 22 files changed, 269 insertions(+), 387 deletions(-) create mode 100644 apps/backend/app/utils/crud.py create mode 100644 apps/web/src/lib/backend-api.ts diff --git a/apps/backend/app/api/routes/code_snippets/api.py b/apps/backend/app/api/routes/code_snippets/api.py index 34fea583..c8699451 100644 --- a/apps/backend/app/api/routes/code_snippets/api.py +++ b/apps/backend/app/api/routes/code_snippets/api.py @@ -1,10 +1,9 @@ -from typing import Optional from fastapi import APIRouter, Depends, Query from app.api.routes.auth.services import get_current_uid -from app.api.routes.code_snippets.schema import CodeSnippetCreate, CodeSnippetOut, CodeSnippetUpdate from app.api.routes.code_snippets import services as snippet_svc +from app.api.routes.code_snippets.schema import CodeSnippetCreate, CodeSnippetOut, CodeSnippetUpdate router = APIRouter(prefix="/code-snippets", tags=["code-snippets"]) @@ -13,7 +12,7 @@ async def list_snippets( uid: str = Depends(get_current_uid), skip: int = Query(default=0, ge=0), - limit: Optional[int] = Query(default=None, ge=1, le=500), + limit: int | None = Query(default=None, ge=1, le=500), ) -> list[CodeSnippetOut]: return await snippet_svc.list_code_snippets(uid=uid, skip=skip, limit=limit) diff --git a/apps/backend/app/api/routes/code_snippets/schema.py b/apps/backend/app/api/routes/code_snippets/schema.py index 995dea1a..2928637c 100644 --- a/apps/backend/app/api/routes/code_snippets/schema.py +++ b/apps/backend/app/api/routes/code_snippets/schema.py @@ -9,7 +9,6 @@ db.code_snippets.create_index([("created_by", 1), ("updatedAt", -1)]) """ -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -21,24 +20,24 @@ class CodeSnippetCreate(BaseModel): model_config = ConfigDict(extra="ignore") - id: Optional[str] = Field(default=None, description="Client-generated id (UUID); optional") + id: str | None = Field(default=None, description="Client-generated id (UUID); optional") title: str = Field(default="Untitled snippet", min_length=1) language: str = Field(default="auto", min_length=1) code: str = "" - tags: Optional[list[str]] = None - pinned: Optional[bool] = None - createdAt: Optional[int] = Field(default=None, description="Unix ms; server defaults if omitted") - updatedAt: Optional[int] = Field(default=None, description="Unix ms; server defaults if omitted") + tags: list[str] | None = None + pinned: bool | None = None + createdAt: int | None = Field(default=None, description="Unix ms; server defaults if omitted") + updatedAt: int | None = Field(default=None, description="Unix ms; server defaults if omitted") class CodeSnippetUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1) - language: Optional[str] = Field(default=None, min_length=1) - code: Optional[str] = None - tags: Optional[list[str]] = None - pinned: Optional[bool] = None + title: str | None = Field(default=None, min_length=1) + language: str | None = Field(default=None, min_length=1) + code: str | None = None + tags: list[str] | None = None + pinned: bool | None = None class CodeSnippetOut(BaseModel): diff --git a/apps/backend/app/api/routes/code_snippets/services.py b/apps/backend/app/api/routes/code_snippets/services.py index bcadcafa..8357db15 100644 --- a/apps/backend/app/api/routes/code_snippets/services.py +++ b/apps/backend/app/api/routes/code_snippets/services.py @@ -1,18 +1,17 @@ from typing import Any from fastapi import HTTPException, status -from pymongo.errors import PyMongoError -from pymongo import ReturnDocument -from app.utils.collection_name import CODE_SNIPPETS as SNIPPETS from app.api.routes.code_snippets.schema import ( CodeSnippetCreate, CodeSnippetOut, CodeSnippetUpdate, ) +from app.core.cache import bump_version, cached from app.database import db_manager -from app.utils.utils import create_timestamp, is_duplicate_key_error, new_id -from app.core.cache import cached, bump_version +from app.utils.collection_name import CODE_SNIPPETS as SNIPPETS +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id def _doc_to_out(doc: dict[str, Any]) -> CodeSnippetOut: @@ -57,18 +56,7 @@ async def create_code_snippet(uid: str, body: CodeSnippetCreate) -> CodeSnippetO "createdAt": created, "updatedAt": updated, } - try: - await db_manager.insert_one(SNIPPETS, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Snippet id already exists.", - ) from exc - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create snippet.", - ) from exc + await safe_insert(SNIPPETS, doc, name="Snippet") await bump_version(ns="code_snippets", uid=uid) return _doc_to_out(doc) @@ -78,20 +66,12 @@ async def update_code_snippet(uid: str, snippet_id: str, body: CodeSnippetUpdate if not patch: return await get_code_snippet(uid=uid, snippet_id=snippet_id) patch["updatedAt"] = create_timestamp() - try: - result = await db_manager.find_one_and_update( - SNIPPETS, - {"_id": snippet_id, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update snippet.", - ) from exc - if not result: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snippet not found.") + result = await safe_update_one( + SNIPPETS, + {"_id": snippet_id, "created_by": uid}, + patch, + name="Snippet", + ) await bump_version(ns="code_snippets", uid=uid) return _doc_to_out(result) @@ -105,7 +85,5 @@ async def get_code_snippet(*, uid: str, snippet_id: str) -> CodeSnippetOut: async def delete_code_snippet(uid: str, snippet_id: str) -> None: - result = await db_manager.delete_one(SNIPPETS, {"_id": snippet_id, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Snippet not found.") + await safe_delete_one(SNIPPETS, {"_id": snippet_id, "created_by": uid}, name="Snippet") await bump_version(ns="code_snippets", uid=uid) diff --git a/apps/backend/app/api/routes/environment_manager/api.py b/apps/backend/app/api/routes/environment_manager/api.py index 5fc53ef7..992e5442 100644 --- a/apps/backend/app/api/routes/environment_manager/api.py +++ b/apps/backend/app/api/routes/environment_manager/api.py @@ -1,14 +1,13 @@ -from typing import Optional from fastapi import APIRouter, Depends, Query from app.api.routes.auth.services import get_current_uid +from app.api.routes.environment_manager import services as env_svc from app.api.routes.environment_manager.schema import ( EnvSetEntryCreate, EnvSetEntryOut, EnvSetEntryUpdate, ) -from app.api.routes.environment_manager import services as env_svc router = APIRouter(prefix="/environment-manager", tags=["environment-manager"]) @@ -20,7 +19,7 @@ ) async def list_entries( uid: str = Depends(get_current_uid), - limit: Optional[int] = Query(default=None, ge=1, le=1000), + limit: int | None = Query(default=None, ge=1, le=1000), offset: int = Query(default=0, ge=0), ) -> list[EnvSetEntryOut]: return await env_svc.list_entries(uid, limit=limit, offset=offset) diff --git a/apps/backend/app/api/routes/environment_manager/schema.py b/apps/backend/app/api/routes/environment_manager/schema.py index 50db14ee..4ade2ab5 100644 --- a/apps/backend/app/api/routes/environment_manager/schema.py +++ b/apps/backend/app/api/routes/environment_manager/schema.py @@ -1,4 +1,3 @@ -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -6,8 +5,8 @@ class EnvSetEntryCreate(BaseModel): encryptedData: str = Field(min_length=1) iv: str = Field(min_length=1) - createdAt: Optional[int] = Field(default=None, ge=0) - updatedAt: Optional[int] = Field(default=None, ge=0) + createdAt: int | None = Field(default=None, ge=0) + updatedAt: int | None = Field(default=None, ge=0) class EnvSetEntryUpdate(BaseModel): @@ -15,7 +14,7 @@ class EnvSetEntryUpdate(BaseModel): encryptedData: str = Field(min_length=1) iv: str = Field(min_length=1) - updatedAt: Optional[int] = Field(default=None, ge=0) + updatedAt: int | None = Field(default=None, ge=0) class EnvSetEntryOut(BaseModel): diff --git a/apps/backend/app/api/routes/environment_manager/services.py b/apps/backend/app/api/routes/environment_manager/services.py index e0b4429f..41bf77a7 100644 --- a/apps/backend/app/api/routes/environment_manager/services.py +++ b/apps/backend/app/api/routes/environment_manager/services.py @@ -1,16 +1,16 @@ -from typing import Any, Optional +from typing import Any from fastapi import HTTPException, status -from pymongo.errors import PyMongoError from app.api.routes.environment_manager.schema import ( EnvSetEntryCreate, EnvSetEntryOut, EnvSetEntryUpdate, ) -from app.utils.collection_name import ENV_MANAGER_ENTRIES -from app.utils.utils import create_timestamp, is_duplicate_key_error, new_id from app.database import db_manager +from app.utils.collection_name import ENV_MANAGER_ENTRIES +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id def _entry_doc_to_out(doc: dict[str, Any], *, entry_id: str) -> EnvSetEntryOut: @@ -25,7 +25,7 @@ def _entry_doc_to_out(doc: dict[str, Any], *, entry_id: str) -> EnvSetEntryOut: ) -async def list_entries(uid: str, *, limit: Optional[int] = None, offset: int = 0) -> list[EnvSetEntryOut]: +async def list_entries(uid: str, *, limit: int | None = None, offset: int = 0) -> list[EnvSetEntryOut]: docs = await db_manager.find( ENV_MANAGER_ENTRIES, {"created_by": uid}, @@ -50,15 +50,7 @@ async def create_entry(uid: str, body: EnvSetEntryCreate) -> EnvSetEntryOut: "createdAt": created_at, "updatedAt": updated_at, } - try: - await db_manager.insert_one(ENV_MANAGER_ENTRIES, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Entry id collision.") from exc - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create entry." - ) from exc - + await safe_insert(ENV_MANAGER_ENTRIES, doc, name="Entry") return _entry_doc_to_out(doc, entry_id=eid) @@ -76,21 +68,16 @@ async def update_entry(uid: str, entry_id: str, body: EnvSetEntryUpdate) -> EnvS "iv": body.iv, "updatedAt": ts_updated, } - try: - result = await db_manager.update_one( - ENV_MANAGER_ENTRIES, {"_id": entry_id, "created_by": uid}, {"$set": patch} - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update entry." - ) from exc - - if result.matched_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found.") - return await get_entry(uid, entry_id) + result = await safe_update_one( + ENV_MANAGER_ENTRIES, + {"_id": entry_id, "created_by": uid}, + patch, + name="Entry", + ) + return _entry_doc_to_out(result, entry_id=entry_id) async def delete_entry(uid: str, entry_id: str) -> None: - result = await db_manager.delete_one(ENV_MANAGER_ENTRIES, {"_id": entry_id, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found.") + await safe_delete_one( + ENV_MANAGER_ENTRIES, {"_id": entry_id, "created_by": uid}, name="Entry" + ) diff --git a/apps/backend/app/api/routes/notes/api.py b/apps/backend/app/api/routes/notes/api.py index 70b58f7b..7090a5f3 100644 --- a/apps/backend/app/api/routes/notes/api.py +++ b/apps/backend/app/api/routes/notes/api.py @@ -1,11 +1,9 @@ -from typing import Optional from fastapi import APIRouter, Depends, Query from app.api.routes.auth.services import get_current_uid -from app.api.routes.notes.schema import NoteCreate, NoteOut, NoteUpdate from app.api.routes.notes import services as note_svc - +from app.api.routes.notes.schema import NoteCreate, NoteOut, NoteUpdate router = APIRouter(prefix="/notes", tags=["notes"]) diff --git a/apps/backend/app/api/routes/notes/schema.py b/apps/backend/app/api/routes/notes/schema.py index 2ac55c18..4b1110ce 100644 --- a/apps/backend/app/api/routes/notes/schema.py +++ b/apps/backend/app/api/routes/notes/schema.py @@ -1,5 +1,5 @@ import json -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -25,12 +25,12 @@ def _validate_content(value: Any) -> Any: class NoteCreate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) + title: str | None = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) content: Any = Field(default_factory=dict) - parentId: Optional[str] = Field(default=None, max_length=128) - icon: Optional[str] = Field(default=None, max_length=_ICON_MAX_LEN) - pinned: Optional[bool] = None - tags: Optional[list[str]] = None + parentId: str | None = Field(default=None, max_length=128) + icon: str | None = Field(default=None, max_length=_ICON_MAX_LEN) + pinned: bool | None = None + tags: list[str] | None = None @field_validator("content", mode="before") @classmethod @@ -52,12 +52,12 @@ def validate_tags(cls, v: Any) -> Any: class NoteUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) - content: Optional[Any] = None - parentId: Optional[str] = Field(default=None, max_length=128) - icon: Optional[str] = Field(default=None, max_length=_ICON_MAX_LEN) - pinned: Optional[bool] = None - tags: Optional[list[str]] = None + title: str | None = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) + content: Any | None = None + parentId: str | None = Field(default=None, max_length=128) + icon: str | None = Field(default=None, max_length=_ICON_MAX_LEN) + pinned: bool | None = None + tags: list[str] | None = None @field_validator("content", mode="before") @classmethod @@ -82,8 +82,8 @@ class NoteOut(BaseModel): id: str title: str content: Any - parentId: Optional[str] = None - icon: Optional[str] = None + parentId: str | None = None + icon: str | None = None pinned: bool = False tags: list[str] = Field(default_factory=list) userId: str diff --git a/apps/backend/app/api/routes/notes/services.py b/apps/backend/app/api/routes/notes/services.py index 6e6d9690..f06472db 100644 --- a/apps/backend/app/api/routes/notes/services.py +++ b/apps/backend/app/api/routes/notes/services.py @@ -1,14 +1,14 @@ from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any from fastapi import HTTPException, status -from pymongo.errors import PyMongoError from app.api.routes.notes.schema import NoteCreate, NoteOut, NoteUpdate -from app.core.cache import cached, bump_version +from app.core.cache import bump_version, cached +from app.database import db_manager from app.utils.collection_name import NOTES +from app.utils.crud import safe_insert, safe_update_one from app.utils.utils import new_id -from app.database import db_manager def isoformat_utc(dt: datetime) -> str: @@ -85,10 +85,7 @@ async def create_note(uid: str, body: NoteCreate) -> NoteOut: "createdAt": ts, "updatedAt": ts, } - try: - await db_manager.insert_one(NOTES, doc) - except PyMongoError as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create note.") from exc + await safe_insert(NOTES, doc, name="Note") await bump_version(ns="notes", uid=uid) return _doc_to_out(doc) @@ -107,17 +104,9 @@ async def update_note(uid: str, note_id: str, body: NoteUpdate) -> NoteOut: return await get_note(uid=uid, note_id=note_id) patch["updatedAt"] = datetime.now(timezone.utc) - try: - result = await db_manager.update_one(NOTES, {"_id": note_id, "created_by": uid}, {"$set": patch}) - except PyMongoError as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update note.") from exc - - if result.matched_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.") - - doc = await db_manager.find_one(NOTES, {"_id": note_id, "created_by": uid}) - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.") + doc = await safe_update_one( + NOTES, {"_id": note_id, "created_by": uid}, patch, name="Note" + ) await bump_version(ns="notes", uid=uid) return _doc_to_out(doc) diff --git a/apps/backend/app/api/routes/passwords/api.py b/apps/backend/app/api/routes/passwords/api.py index 5c12cda0..c73cc81d 100644 --- a/apps/backend/app/api/routes/passwords/api.py +++ b/apps/backend/app/api/routes/passwords/api.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, Query, Request from app.api.routes.auth.services import get_current_uid +from app.api.routes.passwords import services as pw_svc from app.api.routes.passwords.schema import ( PasswordEntryCreate, PasswordEntryOut, @@ -8,10 +9,8 @@ VaultOut, VaultSetupRequest, ) -from app.api.routes.passwords import services as pw_svc from app.core.limiter import limiter - router = APIRouter(prefix="/password-manager", tags=["password-manager"]) diff --git a/apps/backend/app/api/routes/passwords/schema.py b/apps/backend/app/api/routes/passwords/schema.py index 333796ef..feebb413 100644 --- a/apps/backend/app/api/routes/passwords/schema.py +++ b/apps/backend/app/api/routes/passwords/schema.py @@ -1,4 +1,3 @@ -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -13,7 +12,7 @@ class KeyVerifier(BaseModel): class VaultSetupRequest(BaseModel): salt: str = Field(min_length=1) verifier: KeyVerifier - createdAt: Optional[int] = Field(default=None, ge=0) + createdAt: int | None = Field(default=None, ge=0) class VaultOut(BaseModel): @@ -27,8 +26,8 @@ class VaultOut(BaseModel): class PasswordEntryCreate(BaseModel): encryptedData: str = Field(min_length=1) iv: str = Field(min_length=1) - createdAt: Optional[int] = Field(default=None, ge=0) - updatedAt: Optional[int] = Field(default=None, ge=0) + createdAt: int | None = Field(default=None, ge=0) + updatedAt: int | None = Field(default=None, ge=0) class PasswordEntryUpdate(BaseModel): @@ -36,7 +35,7 @@ class PasswordEntryUpdate(BaseModel): encryptedData: str = Field(min_length=1) iv: str = Field(min_length=1) - updatedAt: Optional[int] = Field(default=None, ge=0) + updatedAt: int | None = Field(default=None, ge=0) class PasswordEntryOut(BaseModel): diff --git a/apps/backend/app/api/routes/passwords/services.py b/apps/backend/app/api/routes/passwords/services.py index 53f1e6d2..e2bb28a4 100644 --- a/apps/backend/app/api/routes/passwords/services.py +++ b/apps/backend/app/api/routes/passwords/services.py @@ -1,21 +1,21 @@ from typing import Any from fastapi import HTTPException, status -from pymongo import ReturnDocument from pymongo.errors import PyMongoError from app.api.routes.passwords.schema import ( KeyVerifier, PasswordEntryCreate, - PasswordEntryUpdate, PasswordEntryOut, + PasswordEntryUpdate, VaultOut, VaultSetupRequest, ) -from app.core.cache import cached, bump_version -from app.utils.collection_name import PASSWORD_ENTRIES, PASSWORD_VAULTS -from app.utils.utils import create_timestamp, is_duplicate_key_error, new_id +from app.core.cache import bump_version, cached from app.database import db_manager +from app.utils.collection_name import PASSWORD_ENTRIES, PASSWORD_VAULTS +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id def _vault_doc_to_out(doc: dict[str, Any]) -> VaultOut: @@ -78,13 +78,7 @@ async def setup_vault(uid: str, body: VaultSetupRequest) -> VaultOut: "createdAt": ts_created, "updatedAt": ts_updated, } - try: - await db_manager.insert_one(PASSWORD_VAULTS, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to setup vault." - ) from exc - + await safe_insert(PASSWORD_VAULTS, doc, name="Vault") await bump_version(ns="passwords", uid=uid) return await get_vault(uid=uid) @@ -115,15 +109,7 @@ async def create_entry(uid: str, body: PasswordEntryCreate) -> PasswordEntryOut: "createdAt": created_at, "updatedAt": updated_at, } - try: - await db_manager.insert_one(PASSWORD_ENTRIES, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Entry id collision.") from exc - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create entry." - ) from exc - + await safe_insert(PASSWORD_ENTRIES, doc, name="Entry") await bump_version(ns="passwords", uid=uid) return _entry_doc_to_out(doc, entry_id=eid) @@ -143,27 +129,18 @@ async def update_entry(uid: str, entry_id: str, body: PasswordEntryUpdate) -> Pa "iv": body.iv, "updatedAt": ts_updated, } - try: - doc = await db_manager.find_one_and_update( - PASSWORD_ENTRIES, - {"_id": entry_id, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update entry." - ) from exc - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found.") + doc = await safe_update_one( + PASSWORD_ENTRIES, + {"_id": entry_id, "created_by": uid}, + patch, + name="Entry", + ) await bump_version(ns="passwords", uid=uid) return _entry_doc_to_out(doc, entry_id=entry_id) async def delete_entry(uid: str, entry_id: str) -> None: - result = await db_manager.delete_one(PASSWORD_ENTRIES, {"_id": entry_id, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found.") + await safe_delete_one(PASSWORD_ENTRIES, {"_id": entry_id, "created_by": uid}, name="Entry") await bump_version(ns="passwords", uid=uid) diff --git a/apps/backend/app/utils/crud.py b/apps/backend/app/utils/crud.py new file mode 100644 index 00000000..a8d0fad5 --- /dev/null +++ b/apps/backend/app/utils/crud.py @@ -0,0 +1,74 @@ +"""Tiny CRUD helpers shared by route services. + +Each service still owns its own `_doc_to_out` mapper and cache decorators; these +helpers only collapse the repeating try/except/HTTPException boilerplate around +insert/update/delete. Keeps services readable instead of forcing a generic +base class with many hooks. +""" +from typing import Any + +from fastapi import HTTPException, status +from pymongo import ReturnDocument +from pymongo.errors import PyMongoError + +from app.database import db_manager +from app.utils.utils import is_duplicate_key_error + + +async def safe_insert(collection: str, doc: dict[str, Any], *, name: str) -> None: + """Insert; map duplicate-key → 409, other Mongo errors → 500.""" + try: + await db_manager.insert_one(collection, doc) + except PyMongoError as exc: + if is_duplicate_key_error(exc): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"{name} id already exists.", + ) from exc + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create {name.lower()}.", + ) from exc + + +async def safe_update_one( + collection: str, + query: dict[str, Any], + patch: dict[str, Any], + *, + name: str, +) -> dict[str, Any]: + """Atomic find-and-set; map Mongo errors → 500, missing → 404.""" + try: + doc = await db_manager.find_one_and_update( + collection, + query, + {"$set": patch}, + return_document=ReturnDocument.AFTER, + ) + except PyMongoError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update {name.lower()}.", + ) from exc + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{name} not found.", + ) + return doc + + +async def safe_delete_one( + collection: str, + query: dict[str, Any], + *, + name: str, +) -> None: + """Delete one; missing → 404.""" + result = await db_manager.delete_one(collection, query) + if result.deleted_count == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{name} not found.", + ) diff --git a/apps/web/package.json b/apps/web/package.json index f3c20a1f..0d0dea8b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -92,7 +92,6 @@ "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", diff --git a/apps/web/src/components/api-client/collections/virtual-history-list.tsx b/apps/web/src/components/api-client/collections/virtual-history-list.tsx index 2ee91cd0..6522c8cc 100644 --- a/apps/web/src/components/api-client/collections/virtual-history-list.tsx +++ b/apps/web/src/components/api-client/collections/virtual-history-list.tsx @@ -1,30 +1,9 @@ "use client" import * as React from "react" -import { List } from "react-window" +import { useVirtualizer } from "@tanstack/react-virtual" import type { HistoryRequest } from "../types" -// Custom data passed through rowProps (must not include ariaAttributes, index, or style) -type HistoryRowCustomProps = { - items: HistoryRequest[] - renderRow: (item: HistoryRequest, style: React.CSSProperties) => React.ReactNode -} - -// Full props received by the row component (custom data + injected by react-window) -type HistoryRowProps = HistoryRowCustomProps & { - ariaAttributes: { - "aria-posinset": number - "aria-setsize": number - role: "listitem" - } - index: number - style: React.CSSProperties -} - -function HistoryRow({ index, style, items, renderRow }: HistoryRowProps): React.ReactElement | null { - const item = items[index] - if (!item) return null - return <>{renderRow(item, style)} -} +const ROW_HEIGHT = 64 interface VirtualHistoryListProps { items: HistoryRequest[] @@ -39,15 +18,39 @@ export const VirtualHistoryList = React.memo(function VirtualHistoryList({ height, renderRow, }: VirtualHistoryListProps) { - const rowProps: HistoryRowCustomProps = { items, renderRow } + const scrollRef = React.useRef(null) + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 5, + }) + return ( - - rowComponent={HistoryRow} - rowProps={rowProps} - rowCount={items.length} - rowHeight={64} - style={{ height }} - defaultHeight={height} - /> +
+
+ {virtualizer.getVirtualItems().map((row) => { + const item = items[row.index] + if (!item) return null + const style: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: row.size, + transform: `translateY(${row.start}px)`, + } + return ( + + {renderRow(item, style)} + + ) + })} +
+
) }) diff --git a/apps/web/src/lib/backend-api.ts b/apps/web/src/lib/backend-api.ts new file mode 100644 index 00000000..222b5990 --- /dev/null +++ b/apps/web/src/lib/backend-api.ts @@ -0,0 +1,30 @@ +import { proxyJsonAuthed } from "@/lib/backend-auth" + +export const BACKEND_BASE_URL: string = + process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_BASE_URL || + "http://localhost:8000" + +export function extractBackendError(data: unknown): string { + if (typeof data === "string" && data.trim()) return data + if (data && typeof data === "object" && "detail" in data) { + const d = (data as { detail: unknown }).detail + if (typeof d === "string") return d + try { + return JSON.stringify(d) + } catch { + return "Request failed" + } + } + return "Request failed" +} + +export async function apiRequest( + method: string, + path: string, + body?: unknown, +): Promise { + const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) + if (status < 200 || status >= 300) throw new Error(extractBackendError(data)) + return data as T +} diff --git a/apps/web/src/lib/code-snippets-api.ts b/apps/web/src/lib/code-snippets-api.ts index 84e7b2bb..aef39b46 100644 --- a/apps/web/src/lib/code-snippets-api.ts +++ b/apps/web/src/lib/code-snippets-api.ts @@ -1,29 +1,10 @@ -import { proxyJsonAuthed } from "@/lib/backend-auth" +import { apiRequest } from "@/lib/backend-api" import type { CodeSnippet } from "@/store/snippet-manager-store" -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - "http://localhost:8000" - -function extractError(data: unknown): string { - if (typeof data === "string" && data.trim()) return data - if (data && typeof data === "object" && "detail" in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === "string") return d - try { return JSON.stringify(d) } catch { return "Request failed" } - } - return "Request failed" -} - -async function snippetRequest(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) throw new Error(extractError(data)) - return data as T -} +const BASE = "/api/v1/code-snippets" export async function listCodeSnippetsApi(): Promise { - return snippetRequest("GET", "/api/v1/code-snippets") + return apiRequest("GET", BASE) } export type CodeSnippetCreatePayload = Pick< @@ -35,18 +16,18 @@ export type CodeSnippetCreatePayload = Pick< export async function createCodeSnippetApi( payload: CodeSnippetCreatePayload ): Promise { - return snippetRequest("POST", "/api/v1/code-snippets", payload) + return apiRequest("POST", BASE, payload) } export async function patchCodeSnippetApi( id: string, patch: Partial> ): Promise { - return snippetRequest("PATCH", `/api/v1/code-snippets/${id}`, patch) + return apiRequest("PATCH", `${BASE}/${id}`, patch) } export async function deleteCodeSnippetApi(id: string): Promise { - await snippetRequest("DELETE", `/api/v1/code-snippets/${id}`) + await apiRequest("DELETE", `${BASE}/${id}`) } export function isSnippetDuplicateError(err: unknown): boolean { diff --git a/apps/web/src/lib/environment-manager-api.ts b/apps/web/src/lib/environment-manager-api.ts index 7701784a..f6235b48 100644 --- a/apps/web/src/lib/environment-manager-api.ts +++ b/apps/web/src/lib/environment-manager-api.ts @@ -1,9 +1,4 @@ -import { proxyJsonAuthed } from "@/lib/backend-auth" - -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - "http://localhost:8000" +import { apiRequest } from "@/lib/backend-api" const BASE = "/api/v1/environment-manager" @@ -28,41 +23,19 @@ export type EnvSetEntryUpdate = { updatedAt?: number } -function backendErrorMessage(data: unknown): string { - if (typeof data === "string" && data.trim()) return data - if (data && typeof data === "object" && "detail" in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === "string") return d - try { - return JSON.stringify(d) - } catch { - return "Request failed" - } - } - return "Request failed" -} - -async function envManagerRequest(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) { - throw new Error(backendErrorMessage(data)) - } - return data as T -} - export async function listEnvSetEntries(): Promise { - return envManagerRequest("GET", `${BASE}/entries`) + return apiRequest("GET", `${BASE}/entries`) } export async function createEnvSetEntry(body: EnvSetEntryCreate): Promise { - return envManagerRequest("POST", `${BASE}/entries`, body) + return apiRequest("POST", `${BASE}/entries`, body) } export async function updateEnvSetEntry( entryId: string, body: EnvSetEntryUpdate ): Promise { - return envManagerRequest( + return apiRequest( "PATCH", `${BASE}/entries/${encodeURIComponent(entryId)}`, body @@ -70,5 +43,5 @@ export async function updateEnvSetEntry( } export async function deleteEnvSetEntry(entryId: string): Promise { - await envManagerRequest("DELETE", `${BASE}/entries/${encodeURIComponent(entryId)}`) + await apiRequest("DELETE", `${BASE}/entries/${encodeURIComponent(entryId)}`) } diff --git a/apps/web/src/lib/password-manager-api.ts b/apps/web/src/lib/password-manager-api.ts index 3dbe4044..dc3d529e 100644 --- a/apps/web/src/lib/password-manager-api.ts +++ b/apps/web/src/lib/password-manager-api.ts @@ -1,9 +1,4 @@ -import { proxyJsonAuthed } from "@/lib/backend-auth" - -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - "http://localhost:8000" +import { apiRequest } from "@/lib/backend-api" const BASE = "/api/v1/password-manager" @@ -28,28 +23,6 @@ export type PasswordEntryUpdate = { updatedAt?: number } -function backendErrorMessage(data: unknown): string { - if (typeof data === "string" && data.trim()) return data - if (data && typeof data === "object" && "detail" in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === "string") return d - try { - return JSON.stringify(d) - } catch { - return "Request failed" - } - } - return "Request failed" -} - -async function passwordManagerRequest(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) { - throw new Error(backendErrorMessage(data)) - } - return data as T -} - export async function listPasswordEntries({ skip, limit, @@ -66,20 +39,20 @@ export async function listPasswordEntries({ } const qs = params.toString() const path = qs ? `${BASE}/entries?${qs}` : `${BASE}/entries` - return passwordManagerRequest("GET", path) + return apiRequest("GET", path) } export async function createPasswordEntry(body: PasswordEntryCreate): Promise { - return passwordManagerRequest("POST", `${BASE}/entries`, body) + return apiRequest("POST", `${BASE}/entries`, body) } export async function updatePasswordEntry( entryId: string, body: PasswordEntryUpdate ): Promise { - return passwordManagerRequest("PATCH", `${BASE}/entries/${encodeURIComponent(entryId)}`, body) + return apiRequest("PATCH", `${BASE}/entries/${encodeURIComponent(entryId)}`, body) } export async function deletePasswordEntry(entryId: string): Promise { - await passwordManagerRequest("DELETE", `${BASE}/entries/${encodeURIComponent(entryId)}`) + await apiRequest("DELETE", `${BASE}/entries/${encodeURIComponent(entryId)}`) } diff --git a/apps/web/src/lib/s3-drive-api.ts b/apps/web/src/lib/s3-drive-api.ts index dd3acde0..b7024d43 100644 --- a/apps/web/src/lib/s3-drive-api.ts +++ b/apps/web/src/lib/s3-drive-api.ts @@ -1,14 +1,7 @@ -import { proxyJsonAuthed } from "@/lib/backend-auth" - -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - "http://localhost:8000" +import { apiRequest } from "@/lib/backend-api" const BASE = "/api/v1/s3-drive" -// ── Types ───────────────────────────────────────────────────────────────────── - export type S3Provider = "aws" | "digitalocean" | "custom" export type S3ConnectionOut = { @@ -70,48 +63,32 @@ export type BucketInfo = { creationDate?: string } -function extractError(data: unknown): string { - if (typeof data === "string" && data.trim()) return data - if (data && typeof data === "object" && "detail" in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === "string") return d - try { return JSON.stringify(d) } catch { return "Request failed" } - } - return "Request failed" -} - -async function s3Request(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) throw new Error(extractError(data)) - return data as T -} - // ── Connection CRUD ─────────────────────────────────────────────────────────── export async function listConnections(): Promise { - return s3Request("GET", `${BASE}/connections`) + return apiRequest("GET", `${BASE}/connections`) } export async function createConnection(body: S3ConnectionCreate): Promise { - return s3Request("POST", `${BASE}/connections`, body) + return apiRequest("POST", `${BASE}/connections`, body) } export async function getConnection(connId: string): Promise { - return s3Request("GET", `${BASE}/connections/${encodeURIComponent(connId)}`) + return apiRequest("GET", `${BASE}/connections/${encodeURIComponent(connId)}`) } export async function updateConnection(connId: string, body: S3ConnectionUpdate): Promise { - return s3Request("PATCH", `${BASE}/connections/${encodeURIComponent(connId)}`, body) + return apiRequest("PATCH", `${BASE}/connections/${encodeURIComponent(connId)}`, body) } export async function deleteConnection(connId: string): Promise { - await s3Request("DELETE", `${BASE}/connections/${encodeURIComponent(connId)}`) + await apiRequest("DELETE", `${BASE}/connections/${encodeURIComponent(connId)}`) } // ── S3 operations ───────────────────────────────────────────────────────────── export async function listBuckets(credentials: S3Credentials): Promise { - return s3Request("POST", `${BASE}/operations/buckets`, { credentials }) + return apiRequest("POST", `${BASE}/operations/buckets`, { credentials }) } export async function listObjects( @@ -120,7 +97,7 @@ export async function listObjects( continuationToken?: string, delimiter = "/", ): Promise { - return s3Request("POST", `${BASE}/operations/list`, { + return apiRequest("POST", `${BASE}/operations/list`, { credentials, prefix, continuationToken, @@ -129,15 +106,15 @@ export async function listObjects( } export async function deleteObjects(credentials: S3Credentials, keys: string[]): Promise<{ deleted: number }> { - return s3Request<{ deleted: number }>("POST", `${BASE}/operations/delete`, { credentials, keys }) + return apiRequest<{ deleted: number }>("POST", `${BASE}/operations/delete`, { credentials, keys }) } export async function createFolder(credentials: S3Credentials, prefix: string): Promise<{ key: string }> { - return s3Request<{ key: string }>("POST", `${BASE}/operations/create-folder`, { credentials, prefix }) + return apiRequest<{ key: string }>("POST", `${BASE}/operations/create-folder`, { credentials, prefix }) } export async function getPresignedDownloadUrl(credentials: S3Credentials, key: string, expiresIn = 3600): Promise { - return s3Request("POST", `${BASE}/operations/presigned-download`, { credentials, key, expiresIn }) + return apiRequest("POST", `${BASE}/operations/presigned-download`, { credentials, key, expiresIn }) } export async function getPresignedUploadUrl( @@ -145,7 +122,7 @@ export async function getPresignedUploadUrl( key: string, contentType = "application/octet-stream", ): Promise { - return s3Request("POST", `${BASE}/operations/presigned-upload`, { credentials, key, contentType }) + return apiRequest("POST", `${BASE}/operations/presigned-upload`, { credentials, key, contentType }) } export type PresignedBatchItem = { key: string; op?: "get" | "put"; contentType?: string } @@ -156,7 +133,7 @@ export async function getPresignedBatch( items: PresignedBatchItem[], expiresIn = 3600, ): Promise { - return s3Request("POST", `${BASE}/operations/presigned-batch`, { credentials, items, expiresIn }) + return apiRequest("POST", `${BASE}/operations/presigned-batch`, { credentials, items, expiresIn }) } export async function moveObject( @@ -164,12 +141,12 @@ export async function moveObject( sourceKey: string, destinationKey: string, ): Promise<{ sourceKey: string; destinationKey: string }> { - return s3Request("POST", `${BASE}/operations/move`, { credentials, sourceKey, destinationKey }) + return apiRequest("POST", `${BASE}/operations/move`, { credentials, sourceKey, destinationKey }) } export async function configureBucketCors( credentials: S3Credentials, allowedOrigins: string[], ): Promise<{ bucket: string; status: string }> { - return s3Request("POST", `${BASE}/operations/configure-cors`, { credentials, allowedOrigins }) + return apiRequest("POST", `${BASE}/operations/configure-cors`, { credentials, allowedOrigins }) } diff --git a/apps/web/src/lib/url-shortener-api.ts b/apps/web/src/lib/url-shortener-api.ts index 076ad140..059e76e7 100644 --- a/apps/web/src/lib/url-shortener-api.ts +++ b/apps/web/src/lib/url-shortener-api.ts @@ -1,9 +1,4 @@ -import { proxyJsonAuthed } from '@/lib/backend-auth' - -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - 'http://localhost:8000' +import { apiRequest } from '@/lib/backend-api' const BASE = '/api/v1/url-shortener' @@ -28,36 +23,20 @@ export interface ShortLinkUpdate { active?: boolean } -function backendErrorMessage(data: unknown): string { - if (typeof data === 'string' && data.trim()) return data - if (data && typeof data === 'object' && 'detail' in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === 'string') return d - try { return JSON.stringify(d) } catch { return 'Request failed' } - } - return 'Request failed' -} - -async function request(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) throw new Error(backendErrorMessage(data)) - return data as T -} - export async function createShortLink(body: ShortLinkCreate): Promise { - return request('POST', BASE, body) + return apiRequest('POST', BASE, body) } export async function listShortLinks(skip = 0, limit = 500): Promise { - return request('GET', `${BASE}?skip=${skip}&limit=${limit}`) + return apiRequest('GET', `${BASE}?skip=${skip}&limit=${limit}`) } export async function updateShortLink(code: string, body: ShortLinkUpdate): Promise { - return request('PATCH', `${BASE}/${encodeURIComponent(code)}`, body) + return apiRequest('PATCH', `${BASE}/${encodeURIComponent(code)}`, body) } export async function deleteShortLink(code: string): Promise { - await request('DELETE', `${BASE}/${encodeURIComponent(code)}`) + await apiRequest('DELETE', `${BASE}/${encodeURIComponent(code)}`) } export interface StatEntry { @@ -80,5 +59,5 @@ export interface LinkAnalytics { } export async function getLinkAnalytics(code: string, days = 30): Promise { - return request('GET', `${BASE}/${encodeURIComponent(code)}/analytics?days=${days}`) + return apiRequest('GET', `${BASE}/${encodeURIComponent(code)}/analytics?days=${days}`) } diff --git a/apps/web/src/lib/user-preferences-api.ts b/apps/web/src/lib/user-preferences-api.ts index 4b394e82..81c97c40 100644 --- a/apps/web/src/lib/user-preferences-api.ts +++ b/apps/web/src/lib/user-preferences-api.ts @@ -1,9 +1,4 @@ -import { proxyJsonAuthed } from "@/lib/backend-auth" - -const BACKEND_BASE_URL: string = - process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || - process.env.NEXT_PUBLIC_BACKEND_BASE_URL || - "http://localhost:8000" +import { apiRequest } from "@/lib/backend-api" const BASE = "/api/v1/user-preferences" @@ -42,41 +37,16 @@ export type NosqlQueryHistoryOut = { updatedAt: number } -function backendErrorMessage(data: unknown): string { - if (typeof data === "string" && data.trim()) return data - if (data && typeof data === "object" && "detail" in data) { - const d = (data as { detail: unknown }).detail - if (typeof d === "string") return d - try { - return JSON.stringify(d) - } catch { - return "Request failed" - } - } - return "Request failed" -} - -async function userPrefsRequest(method: string, path: string, body?: unknown): Promise { - const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body) - if (status < 200 || status >= 300) { - throw new Error(backendErrorMessage(data)) - } - if (data === null || data === undefined) { - throw new Error(`Empty response body from ${path} (status ${status})`) - } - return data as T -} - export async function getUserPreferences(): Promise { - return userPrefsRequest("GET", BASE) + return apiRequest("GET", BASE) } export async function patchUserPreferences(body: UserPreferencesPatch): Promise { - return userPrefsRequest("PATCH", BASE, body) + return apiRequest("PATCH", BASE, body) } export async function trackToolUsageApi(toolId: string): Promise { - await userPrefsRequest<{ ok: boolean }>("POST", `${BASE}/tool-usage`, { toolId }) + await apiRequest<{ ok: boolean }>("POST", `${BASE}/tool-usage`, { toolId }) } export async function getNosqlQueryHistory(params: { @@ -89,7 +59,7 @@ export async function getNosqlQueryHistory(params: { dbName: params.dbName, collectionName: params.collectionName, }) - return userPrefsRequest("GET", `${BASE}/nosql-query-history?${q.toString()}`) + return apiRequest("GET", `${BASE}/nosql-query-history?${q.toString()}`) } export async function putNosqlQueryHistory(params: { @@ -98,5 +68,5 @@ export async function putNosqlQueryHistory(params: { collectionName: string queries: string[] }): Promise { - return userPrefsRequest("PUT", `${BASE}/nosql-query-history`, params) + return apiRequest("PUT", `${BASE}/nosql-query-history`, params) } From 6f67df940d87d687bb662e391e9891d8bb11b0b8 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Wed, 24 Jun 2026 18:05:41 +0530 Subject: [PATCH 2/5] refactor: extend crud helpers across services + clipboard hook sweep Backend: api_client, bookmarks, s3_drive, tasks services now use safe_insert/safe_update_one/safe_delete_one helpers. -139 LOC. Web: useCopyToClipboard extended with silent/resetMs options + stable useCallback. Migrated 16 tool components from raw clipboard try/catch pattern to the shared hook. -216 net LOC, 29 files. Web typecheck clean. Backend 54/55 (pre-existing cache_config env-leak unrelated). Co-Authored-By: Claude Opus 4.7 --- apps/backend/app/api/routes/api_client/api.py | 7 +- .../routes/api_client/collections_delta.py | 4 +- .../app/api/routes/api_client/schema.py | 20 ++--- .../app/api/routes/api_client/services.py | 84 +++++-------------- apps/backend/app/api/routes/bookmarks/api.py | 9 +- .../app/api/routes/bookmarks/schema.py | 43 +++++----- .../app/api/routes/bookmarks/services.py | 75 +++++------------ apps/backend/app/api/routes/s3_drive/api.py | 30 +++---- .../backend/app/api/routes/s3_drive/schema.py | 31 ++++--- .../app/api/routes/s3_drive/services.py | 57 +++++-------- apps/backend/app/api/routes/tasks/schema.py | 84 +++++++++---------- apps/backend/app/api/routes/tasks/services.py | 79 +++++------------ .../src/components/base64/base64-layout.tsx | 13 +-- .../cron-builder/cron-builder-layout.tsx | 13 +-- .../css-gradient-builder/gradient-layout.tsx | 17 ++-- .../format-converter-layout.tsx | 13 ++- .../gitignore-generator/gitignore-layout.tsx | 15 ++-- .../hash-generator/hash-generator-layout.tsx | 15 ++-- .../hmac-generator/hmac-generator-layout.tsx | 13 +-- .../image-to-base64-layout.tsx | 13 +-- .../ip-subnet-calculator-layout.tsx | 13 +-- .../jwt-decoder/jwt-decoder-layout.tsx | 13 +-- .../jwt-decoder/jwt-signer-layout.tsx | 15 ++-- .../number-base-converter-layout.tsx | 13 +-- .../pem-cert-decoder-layout.tsx | 15 +--- .../svg-optimizer/svg-optimizer-layout.tsx | 13 +-- .../timestamp-converter-layout.tsx | 13 +-- .../url-encode/url-encode-layout.tsx | 13 +-- apps/web/src/hooks/use-copy-to-clipboard.ts | 45 +++++----- 29 files changed, 286 insertions(+), 502 deletions(-) diff --git a/apps/backend/app/api/routes/api_client/api.py b/apps/backend/app/api/routes/api_client/api.py index d6ce504a..1ea48795 100644 --- a/apps/backend/app/api/routes/api_client/api.py +++ b/apps/backend/app/api/routes/api_client/api.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Query -from app.api.routes.auth.services import get_current_uid +from app.api.routes.api_client import collections_delta from app.api.routes.api_client import services as api_client_svc from app.api.routes.api_client.schema import ( + HISTORY_MAX_ITEMS, ApiClientCollectionCreate, ApiClientCollectionOut, ApiClientCollectionUpdate, @@ -11,10 +12,8 @@ ApiClientEnvironmentUpdate, ApiClientHistoryCreate, ApiClientHistoryOut, - HISTORY_MAX_ITEMS, ) -from app.api.routes.api_client import collections_delta - +from app.api.routes.auth.services import get_current_uid router = APIRouter(prefix="/api-client", tags=["api-client"]) router.include_router(collections_delta.router) diff --git a/apps/backend/app/api/routes/api_client/collections_delta.py b/apps/backend/app/api/routes/api_client/collections_delta.py index acacf310..216a86bc 100644 --- a/apps/backend/app/api/routes/api_client/collections_delta.py +++ b/apps/backend/app/api/routes/api_client/collections_delta.py @@ -17,17 +17,17 @@ 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, + ApiClientCollectionOut, ApplyDeltaRequest, ApplyDeltaResponse, - ApiClientCollectionOut, DeleteItemOp, MoveItemOp, Op, UpdateItemOp, ) +from app.api.routes.auth.services import get_current_uid from app.core.cache import bump_version from app.database import db_manager from app.utils.collection_name import API_CLIENT_COLLECTIONS diff --git a/apps/backend/app/api/routes/api_client/schema.py b/apps/backend/app/api/routes/api_client/schema.py index 58f383a1..fac37d49 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 Annotated, Any, Literal, Optional, Union +from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -15,8 +15,8 @@ class ApiClientCollectionCreate(BaseModel): class ApiClientCollectionUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - name: Optional[str] = Field(default=None, min_length=1) - items: Optional[list[dict[str, Any]]] = None + name: str | None = Field(default=None, min_length=1) + items: list[dict[str, Any]] | None = None class ApiClientCollectionOut(ApiClientCollectionBase): @@ -37,8 +37,8 @@ class ApiClientEnvironmentCreate(BaseModel): class ApiClientEnvironmentUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - name: Optional[str] = Field(default=None, min_length=1) - variables: Optional[list[dict[str, Any]]] = None + name: str | None = Field(default=None, min_length=1) + variables: list[dict[str, Any]] | None = None class ApiClientEnvironmentOut(ApiClientEnvironmentBase): @@ -56,7 +56,7 @@ class AddItemOp(BaseModel): type: Literal["add"] parent_id: str item: dict[str, Any] - position: Optional[int] = None + position: int | None = None class UpdateItemOp(BaseModel): @@ -78,7 +78,7 @@ class MoveItemOp(BaseModel): Op = Annotated[ - Union[AddItemOp, UpdateItemOp, DeleteItemOp, MoveItemOp], + AddItemOp | UpdateItemOp | DeleteItemOp | MoveItemOp, Field(discriminator="type"), ] @@ -103,8 +103,8 @@ class ApiClientHistoryCreate(BaseModel): body: dict[str, Any] = Field(default_factory=dict) auth: dict[str, Any] = Field(default_factory=dict) name: str = Field(min_length=1) - status: Optional[int] = None - timestamp: Optional[int] = None + status: int | None = None + timestamp: int | None = None class ApiClientHistoryOut(BaseModel): @@ -119,5 +119,5 @@ class ApiClientHistoryOut(BaseModel): auth: dict[str, Any] name: str timestamp: int - status: Optional[int] = None + status: int | None = None diff --git a/apps/backend/app/api/routes/api_client/services.py b/apps/backend/app/api/routes/api_client/services.py index d9039527..ad4156ef 100644 --- a/apps/backend/app/api/routes/api_client/services.py +++ b/apps/backend/app/api/routes/api_client/services.py @@ -4,15 +4,10 @@ from bson import ObjectId from bson.errors import InvalidId from fastapi import HTTPException, status -from pymongo import ReturnDocument from pymongo.errors import PyMongoError -from app.utils.collection_name import ( - API_CLIENT_COLLECTIONS, - API_CLIENT_ENVIRONMENTS, - API_CLIENT_HISTORY, -) from app.api.routes.api_client.schema import ( + HISTORY_MAX_ITEMS, ApiClientCollectionCreate, ApiClientCollectionOut, ApiClientCollectionUpdate, @@ -21,10 +16,15 @@ ApiClientEnvironmentUpdate, ApiClientHistoryCreate, ApiClientHistoryOut, - HISTORY_MAX_ITEMS, ) -from app.core.cache import cached, bump_version +from app.core.cache import bump_version, cached from app.database import db_manager +from app.utils.collection_name import ( + API_CLIENT_COLLECTIONS, + API_CLIENT_ENVIRONMENTS, + API_CLIENT_HISTORY, +) +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one HISTORY_TRIM_BATCH_SIZE = 500 @@ -70,13 +70,7 @@ async def list_collections(*, uid: str) -> list[ApiClientCollectionOut]: async def create_collection(uid: str, body: ApiClientCollectionCreate) -> ApiClientCollectionOut: doc: dict[str, Any] = {"created_by": uid, "name": body.name, "items": []} - try: - result = await db_manager.insert_one(API_CLIENT_COLLECTIONS, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create collection." - ) from exc - doc["_id"] = result.inserted_id + await safe_insert(API_CLIENT_COLLECTIONS, doc, name="Collection") await bump_version(ns="api_client", uid=uid) return _collection_to_out(doc) @@ -89,28 +83,16 @@ async def patch_collection(uid: str, collection_id: str, body: ApiClientCollecti if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") return _collection_to_out(doc) - try: - doc = await db_manager.find_one_and_update( - API_CLIENT_COLLECTIONS, - {"_id": oid, "created_by": uid}, - {"$set": patch}, - 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 doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") + doc = await safe_update_one( + API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}, patch, name="Collection" + ) await bump_version(ns="api_client", uid=uid) return _collection_to_out(doc) async def delete_collection(uid: str, collection_id: str) -> None: oid = _parse_oid(collection_id, kind="collection") - result = await db_manager.delete_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") + await safe_delete_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}, name="Collection") await bump_version(ns="api_client", uid=uid) @@ -127,13 +109,7 @@ async def list_environments(*, uid: str) -> list[ApiClientEnvironmentOut]: async def create_environment(uid: str, body: ApiClientEnvironmentCreate) -> ApiClientEnvironmentOut: doc: dict[str, Any] = {"created_by": uid, "name": body.name, "variables": []} - try: - result = await db_manager.insert_one(API_CLIENT_ENVIRONMENTS, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create environment." - ) from exc - doc["_id"] = result.inserted_id + await safe_insert(API_CLIENT_ENVIRONMENTS, doc, name="Environment") await bump_version(ns="api_client", uid=uid) return _env_to_out(doc) @@ -146,28 +122,16 @@ async def patch_environment(uid: str, environment_id: str, body: ApiClientEnviro if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Environment not found.") return _env_to_out(doc) - try: - doc = await db_manager.find_one_and_update( - API_CLIENT_ENVIRONMENTS, - {"_id": oid, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update environment." - ) from exc - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Environment not found.") + doc = await safe_update_one( + API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid}, patch, name="Environment" + ) await bump_version(ns="api_client", uid=uid) return _env_to_out(doc) async def delete_environment(uid: str, environment_id: str) -> None: oid = _parse_oid(environment_id, kind="environment") - result = await db_manager.delete_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Environment not found.") + await safe_delete_one(API_CLIENT_ENVIRONMENTS, {"_id": oid, "created_by": uid}, name="Environment") await bump_version(ns="api_client", uid=uid) @@ -228,22 +192,14 @@ async def create_history(uid: str, body: ApiClientHistoryCreate) -> ApiClientHis "timestamp": ts, "status": body.status, } - try: - result = await db_manager.insert_one(API_CLIENT_HISTORY, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save history entry." - ) from exc - doc["_id"] = result.inserted_id + await safe_insert(API_CLIENT_HISTORY, doc, name="History entry") await bump_version(ns="api_client", uid=uid) return _history_doc_to_out(doc) async def delete_history_entry(uid: str, entry_id: str) -> None: oid = _parse_oid(entry_id, kind="history") - result = await db_manager.delete_one(API_CLIENT_HISTORY, {"_id": oid, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History entry not found.") + await safe_delete_one(API_CLIENT_HISTORY, {"_id": oid, "created_by": uid}, name="History entry") await bump_version(ns="api_client", uid=uid) diff --git a/apps/backend/app/api/routes/bookmarks/api.py b/apps/backend/app/api/routes/bookmarks/api.py index 9eaa07fa..8c250efc 100644 --- a/apps/backend/app/api/routes/bookmarks/api.py +++ b/apps/backend/app/api/routes/bookmarks/api.py @@ -1,6 +1,5 @@ -from fastapi import APIRouter, Depends, Query -from typing import Optional +from fastapi import APIRouter, Depends, Query from app.api.routes.auth.services import get_current_uid from app.api.routes.bookmarks import services as bm_svc @@ -46,12 +45,12 @@ async def clear_all(uid: str = Depends(get_current_uid)) -> dict[str, int]: @bookmarks_router.get("", response_model=list[BookmarkOut], summary="List bookmarks") async def list_bookmarks( uid: str = Depends(get_current_uid), - folder_id: Optional[str] = Query( + folder_id: str | None = Query( default=None, alias="folderId", ), skip: int = Query(default=0, ge=0), - limit: Optional[int] = Query(default=None, ge=1, le=500), + limit: int | None = Query(default=None, ge=1, le=500), ) -> list[BookmarkOut]: return await bm_svc.list_bookmarks(uid=uid, folder_id=folder_id, skip=skip, limit=limit) @@ -106,7 +105,7 @@ async def remove_bookmark( async def list_folders( uid: str = Depends(get_current_uid), skip: int = Query(default=0, ge=0), - limit: Optional[int] = Query(default=None, ge=1, le=500), + limit: int | None = Query(default=None, ge=1, le=500), ) -> list[BookmarkFolderOut]: return await bm_svc.list_folders(uid=uid, skip=skip, limit=limit) diff --git a/apps/backend/app/api/routes/bookmarks/schema.py b/apps/backend/app/api/routes/bookmarks/schema.py index d0684c52..80c4b9c2 100644 --- a/apps/backend/app/api/routes/bookmarks/schema.py +++ b/apps/backend/app/api/routes/bookmarks/schema.py @@ -20,7 +20,6 @@ db.bookmarkFolders.create_index([("created_by", 1), ("createdAt", 1)]) """ -from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field @@ -36,31 +35,31 @@ class BookmarkBase(BaseModel): title: str = Field(min_length=1) url: str = Field(min_length=1) - description: Optional[str] = None - favicon: Optional[str] = None + description: str | None = None + favicon: str | None = None tags: list[str] = Field(default_factory=list) - folderId: Optional[str] = None + folderId: str | None = None class BookmarkCreate(BookmarkBase): """Optional ``id`` — if omitted, server generates one (client uses timestamp-random).""" - id: Optional[str] = None + id: str | None = None class BookmarkUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1) - url: Optional[str] = Field(default=None, min_length=1) - description: Optional[str] = None - favicon: Optional[str] = None - tags: Optional[list[str]] = None - folderId: Optional[str] = None + title: str | None = Field(default=None, min_length=1) + url: str | None = Field(default=None, min_length=1) + description: str | None = None + favicon: str | None = None + tags: list[str] | None = None + folderId: str | None = None class BookmarkMove(BaseModel): - folderId: Optional[str] = None + folderId: str | None = None class BookmarkOut(BookmarkBase): @@ -73,24 +72,24 @@ class BookmarkFolderBase(BaseModel): model_config = ConfigDict(extra="ignore") name: str = Field(min_length=1) - parentId: Optional[str] = None - color: Optional[str] = None - icon: Optional[str] = None - isExpanded: Optional[bool] = None + parentId: str | None = None + color: str | None = None + icon: str | None = None + isExpanded: bool | None = None class BookmarkFolderCreate(BookmarkFolderBase): - id: Optional[str] = None + id: str | None = None class BookmarkFolderUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - name: Optional[str] = Field(default=None, min_length=1) - parentId: Optional[str] = None - color: Optional[str] = None - icon: Optional[str] = None - isExpanded: Optional[bool] = None + name: str | None = Field(default=None, min_length=1) + parentId: str | None = None + color: str | None = None + icon: str | None = None + isExpanded: bool | None = None class BookmarkFolderExpanded(BaseModel): diff --git a/apps/backend/app/api/routes/bookmarks/services.py b/apps/backend/app/api/routes/bookmarks/services.py index 137b90a0..f93ad9fc 100644 --- a/apps/backend/app/api/routes/bookmarks/services.py +++ b/apps/backend/app/api/routes/bookmarks/services.py @@ -1,15 +1,9 @@ -from typing import Any, Optional +from typing import Any from fastapi import HTTPException, status from pymongo import ReplaceOne from pymongo.errors import PyMongoError -from pymongo import ReturnDocument -from app.utils.utils import new_id, create_timestamp, is_duplicate_key_error -from app.core import audit -from app.core.cache import cached, bump_version -from app.utils.collection_name import BOOKMARK_FOLDERS as FOLDERS, BOOKMARKS -from app.database import db_manager from app.api.routes.bookmarks.schema import ( BookmarkCreate, BookmarkFolderCreate, @@ -21,6 +15,13 @@ BookmarkSnapshotOut, BookmarkUpdate, ) +from app.core import audit +from app.core.cache import bump_version, cached +from app.database import db_manager +from app.utils.collection_name import BOOKMARK_FOLDERS as FOLDERS +from app.utils.collection_name import BOOKMARKS +from app.utils.crud import safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id def _bookmark_doc_to_out(doc: dict[str, Any]) -> BookmarkOut: @@ -55,9 +56,9 @@ def _folder_doc_to_out(doc: dict[str, Any]) -> BookmarkFolderOut: async def list_bookmarks( *, uid: str, - folder_id: Optional[str] = None, + folder_id: str | None = None, skip: int = 0, - limit: Optional[int] = None, + limit: int | None = None, ) -> list[BookmarkOut]: q: dict[str, Any] = {"created_by": uid} if folder_id == "uncategorized": @@ -94,16 +95,7 @@ async def create_bookmark(uid: str, body: BookmarkCreate) -> BookmarkOut: "createdAt": ts, "updatedAt": ts, } - try: - await db_manager.insert_one(BOOKMARKS, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Bookmark id already exists." - ) from exc - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create bookmark." - ) from exc + await safe_insert(BOOKMARKS, doc, name="Bookmark") audit.set_action("bookmark.create") audit.set_entity("bookmark", bid) audit.set_summary(f"Created bookmark '{body.title}'") @@ -118,19 +110,9 @@ async def update_bookmark(uid: str, bookmark_id: str, body: BookmarkUpdate) -> B return await get_bookmark(uid=uid, bookmark_id=bookmark_id) before = await db_manager.find_one(BOOKMARKS, {"_id": bookmark_id, "created_by": uid}) patch["updatedAt"] = create_timestamp() - try: - result = await db_manager.find_one_and_update( - BOOKMARKS, - {"_id": bookmark_id, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update bookmark." - ) from exc - if not result: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found.") + result = await safe_update_one( + BOOKMARKS, {"_id": bookmark_id, "created_by": uid}, patch, name="Bookmark" + ) audit.set_action("bookmark.update") audit.set_entity("bookmark", bookmark_id) audit.set_summary(f"Updated bookmark '{result.get('title', '')}'") @@ -217,7 +199,7 @@ async def snapshot(uid: str) -> BookmarkSnapshotOut: @cached(ns="bookmarks", ttl=120, scope="user") -async def list_folders(*, uid: str, skip: int = 0, limit: Optional[int] = None) -> list[BookmarkFolderOut]: +async def list_folders(*, uid: str, skip: int = 0, limit: int | None = None) -> list[BookmarkFolderOut]: docs = await db_manager.find( FOLDERS, {"created_by": uid}, sort=[("createdAt", 1)], skip=skip, limit=limit or 0 ) @@ -244,16 +226,7 @@ async def create_folder(uid: str, body: BookmarkFolderCreate) -> BookmarkFolderO "isExpanded": body.isExpanded if body.isExpanded is not None else False, "createdAt": ts, } - try: - await db_manager.insert_one(FOLDERS, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Folder id already exists." - ) from exc - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create folder." - ) from exc + await safe_insert(FOLDERS, doc, name="Folder") await bump_version(ns="bookmarks", uid=uid) return _folder_doc_to_out(doc) @@ -262,19 +235,9 @@ async def update_folder(uid: str, folder_id: str, body: BookmarkFolderUpdate) -> patch = body.model_dump(exclude_unset=True) if not patch: return await get_folder(uid, folder_id) - try: - result = await db_manager.find_one_and_update( - FOLDERS, - {"_id": folder_id, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update folder." - ) from exc - if not result: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found.") + result = await safe_update_one( + FOLDERS, {"_id": folder_id, "created_by": uid}, patch, name="Folder" + ) await bump_version(ns="bookmarks", uid=uid) return _folder_doc_to_out(result) diff --git a/apps/backend/app/api/routes/s3_drive/api.py b/apps/backend/app/api/routes/s3_drive/api.py index 5a55411b..c181cd7b 100644 --- a/apps/backend/app/api/routes/s3_drive/api.py +++ b/apps/backend/app/api/routes/s3_drive/api.py @@ -4,29 +4,29 @@ from fastapi import APIRouter, Depends, Request from app.api.routes.auth.services import get_current_uid -from app.core.cache.decorator import bump_version, get_or_set -from app.core.cache.keys import version_key -from app.core.redis_client import get_redis +from app.api.routes.s3_drive import services as svc from app.api.routes.s3_drive.schema import ( - S3ConnectionCreate, - S3ConnectionOut, - S3ConnectionUpdate, + BucketInfo, + ConfigureCorsRequest, + CreateFolderRequest, + DeleteObjectsRequest, + ListBucketsRequest, ListObjectsRequest, ListObjectsResponse, - DeleteObjectsRequest, - CreateFolderRequest, - PresignedDownloadRequest, - PresignedUploadRequest, + MoveObjectRequest, PresignedBatchRequest, PresignedBatchResponse, + PresignedDownloadRequest, + PresignedUploadRequest, PresignedUrlResponse, - MoveObjectRequest, - ListBucketsRequest, - BucketInfo, - ConfigureCorsRequest, + S3ConnectionCreate, + S3ConnectionOut, + S3ConnectionUpdate, ) -from app.api.routes.s3_drive import services as svc +from app.core.cache.decorator import bump_version, get_or_set +from app.core.cache.keys import version_key from app.core.limiter import limiter +from app.core.redis_client import get_redis router = APIRouter(prefix="/s3-drive", tags=["s3-drive"]) diff --git a/apps/backend/app/api/routes/s3_drive/schema.py b/apps/backend/app/api/routes/s3_drive/schema.py index 59e506d1..30f2a92f 100644 --- a/apps/backend/app/api/routes/s3_drive/schema.py +++ b/apps/backend/app/api/routes/s3_drive/schema.py @@ -1,6 +1,5 @@ -from typing import Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field # ── Stored connection (credentials encrypted client-side) ───────────────────── @@ -9,17 +8,17 @@ class S3ConnectionCreate(BaseModel): provider: str = Field(min_length=1, max_length=50) # "aws" | "digitalocean" | "custom" encryptedData: str = Field(min_length=1) iv: str = Field(min_length=1) - createdAt: Optional[int] = Field(default=None, ge=0) + createdAt: int | None = Field(default=None, ge=0) class S3ConnectionUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - name: Optional[str] = Field(default=None, min_length=1, max_length=100) - provider: Optional[str] = Field(default=None, min_length=1, max_length=50) - encryptedData: Optional[str] = Field(default=None, min_length=1) - iv: Optional[str] = Field(default=None, min_length=1) - updatedAt: Optional[int] = Field(default=None, ge=0) + name: str | None = Field(default=None, min_length=1, max_length=100) + provider: str | None = Field(default=None, min_length=1, max_length=50) + encryptedData: str | None = Field(default=None, min_length=1) + iv: str | None = Field(default=None, min_length=1) + updatedAt: int | None = Field(default=None, ge=0) class S3ConnectionOut(BaseModel): @@ -41,7 +40,7 @@ class S3Credentials(BaseModel): secretKey: str = Field(min_length=1) region: str = Field(default="us-east-1") bucket: str = Field(min_length=1) - endpoint: Optional[str] = Field(default=None) # custom endpoint for DO Spaces / self-hosted + endpoint: str | None = Field(default=None) # custom endpoint for DO Spaces / self-hosted # ── Operation request bodies ────────────────────────────────────────────────── @@ -50,7 +49,7 @@ class ListObjectsRequest(BaseModel): credentials: S3Credentials prefix: str = Field(default="") delimiter: str = Field(default="/") - continuationToken: Optional[str] = Field(default=None) + continuationToken: str | None = Field(default=None) maxKeys: int = Field(default=1000, ge=1, le=1000) @@ -90,7 +89,7 @@ class ListBucketsRequest(BaseModel): class PresignedBatchItem(BaseModel): key: str = Field(min_length=1) op: str = Field(default="get", pattern="^(get|put)$") - contentType: Optional[str] = Field(default=None) + contentType: str | None = Field(default=None) class PresignedBatchRequest(BaseModel): @@ -108,9 +107,9 @@ class ConfigureCorsRequest(BaseModel): class S3ObjectItem(BaseModel): key: str - size: Optional[int] = None - lastModified: Optional[str] = None - etag: Optional[str] = None + size: int | None = None + lastModified: str | None = None + etag: str | None = None isFolder: bool = False @@ -118,7 +117,7 @@ class ListObjectsResponse(BaseModel): objects: list[S3ObjectItem] prefixes: list[str] isTruncated: bool - nextContinuationToken: Optional[str] = None + nextContinuationToken: str | None = None class PresignedUrlResponse(BaseModel): @@ -132,4 +131,4 @@ class PresignedBatchResponse(BaseModel): class BucketInfo(BaseModel): name: str - creationDate: Optional[str] = None + creationDate: str | None = None diff --git a/apps/backend/app/api/routes/s3_drive/services.py b/apps/backend/app/api/routes/s3_drive/services.py index 251c11d0..14c613cb 100644 --- a/apps/backend/app/api/routes/s3_drive/services.py +++ b/apps/backend/app/api/routes/s3_drive/services.py @@ -8,34 +8,33 @@ try: import boto3 from botocore.config import Config - from botocore.exceptions import ClientError, BotoCoreError + from botocore.exceptions import BotoCoreError, ClientError except ImportError: # pragma: no cover boto3 = None # type: ignore -from pymongo.errors import PyMongoError - from app.api.routes.s3_drive.schema import ( - S3ConnectionCreate, - S3ConnectionUpdate, - S3ConnectionOut, - S3Credentials, - ListObjectsRequest, - DeleteObjectsRequest, + BucketInfo, CreateFolderRequest, - PresignedDownloadRequest, - PresignedUploadRequest, - PresignedBatchRequest, - PresignedBatchResponse, - MoveObjectRequest, + DeleteObjectsRequest, ListBucketsRequest, - S3ObjectItem, + ListObjectsRequest, ListObjectsResponse, + MoveObjectRequest, + PresignedBatchRequest, + PresignedBatchResponse, + PresignedDownloadRequest, + PresignedUploadRequest, PresignedUrlResponse, - BucketInfo, + S3ConnectionCreate, + S3ConnectionOut, + S3ConnectionUpdate, + S3Credentials, + S3ObjectItem, ) -from app.utils.collection_name import S3_CONNECTIONS -from app.utils.utils import create_timestamp, is_duplicate_key_error, new_id from app.database import db_manager +from app.utils.collection_name import S3_CONNECTIONS +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one +from app.utils.utils import create_timestamp, new_id def _doc_to_out(doc: dict[str, Any]) -> S3ConnectionOut: @@ -74,12 +73,7 @@ async def create_connection(uid: str, body: S3ConnectionCreate) -> S3ConnectionO "createdAt": created_at, "updatedAt": ts, } - try: - await db_manager.insert_one(S3_CONNECTIONS, doc) - except PyMongoError as exc: - if is_duplicate_key_error(exc): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Connection id collision.") from exc - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create connection.") from exc + await safe_insert(S3_CONNECTIONS, doc, name="Connection") return _doc_to_out(doc) @@ -100,19 +94,14 @@ async def update_connection(uid: str, conn_id: str, body: S3ConnectionUpdate) -> patch["encryptedData"] = body.encryptedData if body.iv is not None: patch["iv"] = body.iv - try: - result = await db_manager.update_one(S3_CONNECTIONS, {"_id": conn_id, "created_by": uid}, {"$set": patch}) - except PyMongoError as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update connection.") from exc - if result.matched_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found.") - return await get_connection(uid, conn_id) + doc = await safe_update_one( + S3_CONNECTIONS, {"_id": conn_id, "created_by": uid}, patch, name="Connection" + ) + return _doc_to_out(doc) async def delete_connection(uid: str, conn_id: str) -> None: - result = await db_manager.delete_one(S3_CONNECTIONS, {"_id": conn_id, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found.") + await safe_delete_one(S3_CONNECTIONS, {"_id": conn_id, "created_by": uid}, name="Connection") # ── S3 client factory (TTL-based cache) ─────────────────────────────────────── diff --git a/apps/backend/app/api/routes/tasks/schema.py b/apps/backend/app/api/routes/tasks/schema.py index e3ff598b..6f7e023b 100644 --- a/apps/backend/app/api/routes/tasks/schema.py +++ b/apps/backend/app/api/routes/tasks/schema.py @@ -26,12 +26,10 @@ db.projects.create_index([("created_by", 1), ("createdAt", 1)]) """ -from typing import Any, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field - - TaskStatus = Literal["not-started", "ongoing", "completed"] TaskPriority = Literal["low", "medium", "high"] @@ -56,26 +54,26 @@ class TaskBase(BaseModel): """Writable task fields (aligned with ``NewTask`` / Firestore writes).""" text: str = Field(min_length=1) - description: Optional[str] = None + description: str | None = None status: TaskStatus = "not-started" statusOrder: int = 2 - priority: Optional[TaskPriority] = None - dueDate: Optional[str] = None - tags: Optional[list[TaskTag]] = None - subTasks: Optional[list[SubTask]] = None - archived: Optional[bool] = None - timeEstimate: Optional[int] = None - timeLogged: Optional[int] = None - isTimerRunning: Optional[bool] = None - timerStartedAt: Optional[str] = None - projectId: Optional[str] = None + priority: TaskPriority | None = None + dueDate: str | None = None + tags: list[TaskTag] | None = None + subTasks: list[SubTask] | None = None + archived: bool | None = None + timeEstimate: int | None = None + timeLogged: int | None = None + isTimerRunning: bool | None = None + timerStartedAt: str | None = None + projectId: str | None = None class TaskCreate(BaseModel): """Maps to ``addTask``: title + optional project.""" text: str = Field(min_length=1) - projectId: Optional[str] = None + projectId: str | None = None class TaskUpdate(BaseModel): @@ -86,20 +84,20 @@ class TaskUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - text: Optional[str] = None - description: Optional[str] = None - status: Optional[TaskStatus] = None - statusOrder: Optional[int] = None - priority: Optional[TaskPriority] = None - dueDate: Optional[str] = None - tags: Optional[list[TaskTag]] = None - subTasks: Optional[list[SubTask]] = None - archived: Optional[bool] = None - timeEstimate: Optional[int] = None - timeLogged: Optional[int] = None - isTimerRunning: Optional[bool] = None - timerStartedAt: Optional[str] = None - projectId: Optional[str] = None + text: str | None = None + description: str | None = None + status: TaskStatus | None = None + statusOrder: int | None = None + priority: TaskPriority | None = None + dueDate: str | None = None + tags: list[TaskTag] | None = None + subTasks: list[SubTask] | None = None + archived: bool | None = None + timeEstimate: int | None = None + timeLogged: int | None = None + isTimerRunning: bool | None = None + timerStartedAt: str | None = None + projectId: str | None = None class TaskStatusUpdate(BaseModel): @@ -115,22 +113,22 @@ class TaskOut(BaseModel): id: str text: str - description: Optional[str] = None + description: str | None = None status: TaskStatus statusOrder: int - priority: Optional[TaskPriority] = None - dueDate: Optional[str] = None - tags: Optional[list[dict[str, Any]]] = None - subTasks: Optional[list[dict[str, Any]]] = None + priority: TaskPriority | None = None + dueDate: str | None = None + tags: list[dict[str, Any]] | None = None + subTasks: list[dict[str, Any]] | None = None createdAt: str - completedAt: Optional[str] = None + completedAt: str | None = None created_by: str - archived: Optional[bool] = None - timeEstimate: Optional[int] = None - timeLogged: Optional[int] = None - isTimerRunning: Optional[bool] = None - timerStartedAt: Optional[str] = None - projectId: Optional[str] = None + archived: bool | None = None + timeEstimate: int | None = None + timeLogged: int | None = None + isTimerRunning: bool | None = None + timerStartedAt: str | None = None + projectId: str | None = None class TaskListResponse(BaseModel): @@ -168,8 +166,8 @@ class ProjectCreate(ProjectBase): class ProjectUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - name: Optional[str] = Field(default=None, min_length=1) - color: Optional[str] = Field(default=None, min_length=1) + name: str | None = Field(default=None, min_length=1) + color: str | None = Field(default=None, min_length=1) class ProjectOut(BaseModel): diff --git a/apps/backend/app/api/routes/tasks/services.py b/apps/backend/app/api/routes/tasks/services.py index e889f6f4..d349e620 100644 --- a/apps/backend/app/api/routes/tasks/services.py +++ b/apps/backend/app/api/routes/tasks/services.py @@ -1,15 +1,11 @@ from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any -from app.database import db_manager from bson import ObjectId from bson.errors import InvalidId from fastapi import HTTPException, status -from pymongo import ReturnDocument from pymongo.errors import PyMongoError -from app.core.cache import cached, bump_version -from app.utils.collection_name import TASKS, PROJECTS from app.api.routes.tasks.schema import ( ProjectCreate, ProjectOut, @@ -23,6 +19,10 @@ TaskStatusUpdate, TaskUpdate, ) +from app.core.cache import bump_version, cached +from app.database import db_manager +from app.utils.collection_name import PROJECTS, TASKS +from app.utils.crud import safe_delete_one, safe_insert, safe_update_one STATUS_ORDER_MAP: dict[TaskStatus, int] = { "ongoing": 1, @@ -90,8 +90,8 @@ def _project_doc_to_out(doc: dict[str, Any]) -> ProjectOut: def _task_filter( uid: str, - status_filter: Optional[str] = None, - project_filter: Optional[str] = None, + status_filter: str | None = None, + project_filter: str | None = None, ) -> dict[str, Any]: q: dict[str, Any] = {"created_by": uid} if status_filter and status_filter != "all": @@ -170,13 +170,7 @@ async def create_task(uid: str, body: TaskCreate) -> TaskOut: "createdAt": now, "projectId": body.projectId, } - try: - result = await db_manager.insert_one(TASKS, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create task." - ) from exc - doc["_id"] = result.inserted_id + await safe_insert(TASKS, doc, name="Task") await bump_version(ns="tasks", uid=uid) return _task_doc_to_out(doc) @@ -204,19 +198,9 @@ async def update_task(uid: str, task_id: str, body: TaskUpdate) -> TaskOut: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") return _task_doc_to_out(doc) - try: - doc = await db_manager.find_one_and_update( - TASKS, - {"_id": oid, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update task." - ) from exc - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") + doc = await safe_update_one( + TASKS, {"_id": oid, "created_by": uid}, patch, name="Task" + ) await bump_version(ns="tasks", uid=uid) return _task_doc_to_out(doc) @@ -230,19 +214,9 @@ async def update_task_status(uid: str, task_id: str, body: TaskStatusUpdate) -> } if new_status == "completed": patch["completedAt"] = datetime.now(timezone.utc) - try: - doc = await db_manager.find_one_and_update( - TASKS, - {"_id": oid, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, - ) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update task status." - ) from exc - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") + doc = await safe_update_one( + TASKS, {"_id": oid, "created_by": uid}, patch, name="Task" + ) await bump_version(ns="tasks", uid=uid) return _task_doc_to_out(doc) @@ -256,9 +230,7 @@ async def get_task(*, uid: str, task_id: str) -> TaskOut: async def delete_task(uid: str, task_id: str) -> None: oid = _parse_object_id(task_id, "task id") - result = await db_manager.delete_one(TASKS, {"_id": oid, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") + await safe_delete_one(TASKS, {"_id": oid, "created_by": uid}, name="Task") await bump_version(ns="tasks", uid=uid) @@ -305,13 +277,7 @@ async def create_project(uid: str, body: ProjectCreate) -> ProjectOut: "color": body.color, "createdAt": now, } - try: - result = await db_manager.insert_one(PROJECTS, doc) - except PyMongoError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create project." - ) from exc - doc["_id"] = result.inserted_id + await safe_insert(PROJECTS, doc, name="Project") await bump_version(ns="tasks", uid=uid) return _project_doc_to_out(doc) @@ -324,21 +290,14 @@ async def update_project(uid: str, project_id: str, body: ProjectUpdate) -> Proj if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.") return _project_doc_to_out(doc) - doc = await db_manager.find_one_and_update( - PROJECTS, - {"_id": oid, "created_by": uid}, - {"$set": patch}, - return_document=ReturnDocument.AFTER, + doc = await safe_update_one( + PROJECTS, {"_id": oid, "created_by": uid}, patch, name="Project" ) - if not doc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.") await bump_version(ns="tasks", uid=uid) return _project_doc_to_out(doc) async def delete_project(uid: str, project_id: str) -> None: oid = _parse_object_id(project_id, "project id") - result = await db_manager.delete_one(PROJECTS, {"_id": oid, "created_by": uid}) - if result.deleted_count == 0: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.") + await safe_delete_one(PROJECTS, {"_id": oid, "created_by": uid}, name="Project") await bump_version(ns="tasks", uid=uid) diff --git a/apps/web/src/components/base64/base64-layout.tsx b/apps/web/src/components/base64/base64-layout.tsx index c3a93aed..14e5873f 100644 --- a/apps/web/src/components/base64/base64-layout.tsx +++ b/apps/web/src/components/base64/base64-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useCallback, useRef } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -29,7 +30,7 @@ export function Base64Layout() { const [output, setOutput] = useState(''); const [mode, setMode] = useState('encode'); const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); @@ -90,15 +91,9 @@ export function Base64Layout() { } }; - const handleCopy = async () => { + const handleCopy = () => { if (!output) return; - try { - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // ignore clipboard failures - } + void copyToClipboard(output, { silent: true }); }; const handleClear = () => { diff --git a/apps/web/src/components/cron-builder/cron-builder-layout.tsx b/apps/web/src/components/cron-builder/cron-builder-layout.tsx index 69989794..2596d886 100644 --- a/apps/web/src/components/cron-builder/cron-builder-layout.tsx +++ b/apps/web/src/components/cron-builder/cron-builder-layout.tsx @@ -2,6 +2,7 @@ import { format } from 'date-fns'; import { useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -57,7 +58,7 @@ export function CronBuilderLayout() { const locale = useLocale(); const [expression, setExpression] = useState('0 * * * *'); const [rawDraft, setRawDraft] = useState('0 * * * *'); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const commitExpression = (next: string) => { const t = next.trim(); @@ -82,14 +83,8 @@ export function CronBuilderLayout() { commitExpression(setFiveFieldAt(expression, index, value)); }; - const copyExpr = async () => { - try { - await navigator.clipboard.writeText(expression.trim()); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // ignore clipboard failures - } + const copyExpr = () => { + void copyToClipboard(expression.trim(), { silent: true, resetMs: 1500 }); }; const monthShort = useMemo( diff --git a/apps/web/src/components/css-gradient-builder/gradient-layout.tsx b/apps/web/src/components/css-gradient-builder/gradient-layout.tsx index e7d80273..770f08ef 100644 --- a/apps/web/src/components/css-gradient-builder/gradient-layout.tsx +++ b/apps/web/src/components/css-gradient-builder/gradient-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useMemo, useCallback } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -50,7 +51,7 @@ export function GradientLayout() { { id: generateId(), color: '#ec4899', position: 100 }, ]); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); // Compute CSS const cssString = useMemo(() => { @@ -68,15 +69,9 @@ export function GradientLayout() { const cssValue = cssString.replace('background: ', '').replace(';', ''); - const copyToClipboard = useCallback(async () => { - try { - await navigator.clipboard.writeText(cssString); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Ignore - } - }, [cssString]); + const handleCopy = useCallback(() => { + void copyToClipboard(cssString, { silent: true }); + }, [cssString, copyToClipboard]); const downloadImage = useCallback(async () => { const svg = ` @@ -267,7 +262,7 @@ export function GradientLayout() { - diff --git a/apps/web/src/components/hash-generator/hash-generator-layout.tsx b/apps/web/src/components/hash-generator/hash-generator-layout.tsx index 40b43c1b..6768c4fd 100644 --- a/apps/web/src/components/hash-generator/hash-generator-layout.tsx +++ b/apps/web/src/components/hash-generator/hash-generator-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { useDebouncedCallback } from 'use-debounce'; import { Card } from '@/components/ui/card'; @@ -47,7 +48,7 @@ export function HashGeneratorLayout() { const [fileBytes, setFileBytes] = useState(null); const [hashOut, setHashOut] = useState(''); const [hashing, setHashing] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [fileError, setFileError] = useState(null); const autoCopy = useAutoCopyStore((state) => state.autoCopy); @@ -123,16 +124,10 @@ export function HashGeneratorLayout() { } }; - const handleCopy = useCallback(async () => { + const handleCopy = useCallback(() => { if (!hashOut) return; - try { - await navigator.clipboard.writeText(hashOut); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - /* ignore */ - } - }, [hashOut]); + void copyToClipboard(hashOut, { silent: true }); + }, [hashOut, copyToClipboard]); useEffect(() => { if (autoCopy && hashOut) { diff --git a/apps/web/src/components/hmac-generator/hmac-generator-layout.tsx b/apps/web/src/components/hmac-generator/hmac-generator-layout.tsx index e7d629b0..6322b2cf 100644 --- a/apps/web/src/components/hmac-generator/hmac-generator-layout.tsx +++ b/apps/web/src/components/hmac-generator/hmac-generator-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { useDebouncedCallback } from 'use-debounce'; import { Card } from '@/components/ui/card'; @@ -33,7 +34,7 @@ export function HmacGeneratorLayout() { const [message, setMessage] = useState(''); const [signature, setSignature] = useState(''); const [working, setWorking] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [error, setError] = useState(false); const secretBytes = useMemo(() => new TextEncoder().encode(secret).length, [secret]); @@ -65,15 +66,9 @@ export function HmacGeneratorLayout() { debounced(); }, [digest, secret, message, outputFormat, debounced]); - const handleCopy = async () => { + const handleCopy = () => { if (!signature) return; - try { - await navigator.clipboard.writeText(signature); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - /* ignore */ - } + void copyToClipboard(signature, { silent: true }); }; return ( diff --git a/apps/web/src/components/image-to-base64/image-to-base64-layout.tsx b/apps/web/src/components/image-to-base64/image-to-base64-layout.tsx index 99dbb257..00643310 100644 --- a/apps/web/src/components/image-to-base64/image-to-base64-layout.tsx +++ b/apps/web/src/components/image-to-base64/image-to-base64-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useCallback, useRef } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -25,7 +26,7 @@ export function ImageToBase64Layout() { const [previewUrl, setPreviewUrl] = useState(null); const [mode, setMode] = useState('dataUri'); const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); @@ -56,16 +57,10 @@ export function ImageToBase64Layout() { setError(''); }; - const handleCopy = async () => { + const handleCopy = () => { const textToCopy = mode === 'rawString' ? output.replace(/^data:image\/[a-z]+;base64,/, '') : output; if (!textToCopy) return; - try { - await navigator.clipboard.writeText(textToCopy); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // ignore clipboard failures - } + void copyToClipboard(textToCopy, { silent: true }); }; const handleDownload = () => { diff --git a/apps/web/src/components/ip-subnet-calculator/ip-subnet-calculator-layout.tsx b/apps/web/src/components/ip-subnet-calculator/ip-subnet-calculator-layout.tsx index a47ac753..fa00f2e5 100644 --- a/apps/web/src/components/ip-subnet-calculator/ip-subnet-calculator-layout.tsx +++ b/apps/web/src/components/ip-subnet-calculator/ip-subnet-calculator-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { useDebouncedCallback } from 'use-debounce'; import { Card } from '@/components/ui/card'; @@ -23,15 +24,9 @@ function Row({ mono?: boolean; copyLabel: string; }) { - const [copied, setCopied] = useState(false); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - /* ignore */ - } + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); + const handleCopy = () => { + void copyToClipboard(value, { silent: true, resetMs: 1500 }); }; return ( diff --git a/apps/web/src/components/jwt-decoder/jwt-decoder-layout.tsx b/apps/web/src/components/jwt-decoder/jwt-decoder-layout.tsx index b82a68eb..b4260804 100644 --- a/apps/web/src/components/jwt-decoder/jwt-decoder-layout.tsx +++ b/apps/web/src/components/jwt-decoder/jwt-decoder-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -14,7 +15,7 @@ import { JwtSignerLayout } from './jwt-signer-layout'; import { SendToMenu } from '@/components/ui/send-to-menu'; function CopyBtn({ text, title }: { text: string; title: string }) { - const [done, setDone] = useState(false); + const { isCopied: done, copyToClipboard } = useCopyToClipboard(); return ( diff --git a/apps/web/src/components/jwt-decoder/jwt-signer-layout.tsx b/apps/web/src/components/jwt-decoder/jwt-signer-layout.tsx index f3111fe4..717a0a11 100644 --- a/apps/web/src/components/jwt-decoder/jwt-signer-layout.tsx +++ b/apps/web/src/components/jwt-decoder/jwt-signer-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -41,7 +42,7 @@ export function JwtSignerLayout() { const [verifyErrorKey, setVerifyErrorKey] = useState(null); const [verified, setVerified] = useState(null); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const keyMode = useMemo<'hs' | 'rs' | 'none'>(() => { if (alg === 'none') return 'none'; @@ -92,16 +93,10 @@ export function JwtSignerLayout() { setVerified(res.valid); }, [token, verificationKey, signingKey, keyMode]); - const handleCopy = useCallback(async () => { + const handleCopy = useCallback(() => { if (!token) return; - try { - await navigator.clipboard.writeText(token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // ignore - } - }, [token]); + void copyToClipboard(token, { silent: true }); + }, [token, copyToClipboard]); const keyLabel = keyMode === 'hs' ? t('signer.secretLabel') : t('signer.privateKeyLabel'); const keyPlaceholder = diff --git a/apps/web/src/components/number-base-converter/number-base-converter-layout.tsx b/apps/web/src/components/number-base-converter/number-base-converter-layout.tsx index d1aec83b..79674660 100644 --- a/apps/web/src/components/number-base-converter/number-base-converter-layout.tsx +++ b/apps/web/src/components/number-base-converter/number-base-converter-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { useDebouncedCallback } from 'use-debounce'; import { Card } from '@/components/ui/card'; @@ -31,7 +32,7 @@ export function NumberBaseConverterLayout() { const [output, setOutput] = useState(''); const [error, setError] = useState(null); const [working, setWorking] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const inputBytes = useMemo(() => new TextEncoder().encode(input).length, [input]); @@ -61,15 +62,9 @@ export function NumberBaseConverterLayout() { debounced(); }, [input, inputBase, outputBase, debounced]); - const handleCopy = async () => { + const handleCopy = () => { if (!output) return; - try { - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - /* ignore */ - } + void copyToClipboard(output, { silent: true }); }; const handleSwap = () => { diff --git a/apps/web/src/components/pem-cert-decoder/pem-cert-decoder-layout.tsx b/apps/web/src/components/pem-cert-decoder/pem-cert-decoder-layout.tsx index 31cbd45b..1d82979f 100644 --- a/apps/web/src/components/pem-cert-decoder/pem-cert-decoder-layout.tsx +++ b/apps/web/src/components/pem-cert-decoder/pem-cert-decoder-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import type { DecodedPemBlock } from '@/lib/pem-cert-decode'; import { decodePemCertificateInput } from '@/lib/pem-cert-decode'; import { Button } from '@/components/ui/button'; @@ -11,7 +12,7 @@ import { AlertCircle, Check, Copy, Trash2 } from 'lucide-react'; import { useLocale, useTranslations } from 'next-intl'; function CopyBtn({ text, title }: { text: string; title: string }) { - const [done, setDone] = useState(false); + const { isCopied, copyToClipboard } = useCopyToClipboard(); return ( ); } diff --git a/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx b/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx index ec8b4a00..e236f1e8 100644 --- a/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx +++ b/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { useDebouncedCallback } from 'use-debounce' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Card } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' @@ -28,7 +29,7 @@ export function SvgOptimizerLayout() { const [input, setInput] = useState('') const [output, setOutput] = useState('') const [error, setError] = useState(null) - const [copied, setCopied] = useState(false) + const { isCopied: copied, copyToClipboard } = useCopyToClipboard() const runOptimize = useCallback((raw: string) => { const result = optimizeSvgMarkup(raw) @@ -58,15 +59,9 @@ export function SvgOptimizerLayout() { ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(output)}` : null - const handleCopy = async () => { + const handleCopy = () => { if (!output) return - try { - await navigator.clipboard.writeText(output) - setCopied(true) - setTimeout(() => setCopied(false), 1600) - } catch { - /* ignore */ - } + void copyToClipboard(output, { silent: true, resetMs: 1600 }) } const handleDownload = () => { diff --git a/apps/web/src/components/timestamp-converter/timestamp-converter-layout.tsx b/apps/web/src/components/timestamp-converter/timestamp-converter-layout.tsx index 0961fc37..a08e142b 100644 --- a/apps/web/src/components/timestamp-converter/timestamp-converter-layout.tsx +++ b/apps/web/src/components/timestamp-converter/timestamp-converter-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -18,7 +19,7 @@ function CopyField({ value: string; copyTitle: string; }) { - const [done, setDone] = useState(false); + const { isCopied: done, copyToClipboard } = useCopyToClipboard(); return (
@@ -32,15 +33,7 @@ function CopyField({ size="icon" className="h-8 w-8 shrink-0" title={copyTitle} - onClick={async () => { - try { - await navigator.clipboard.writeText(value); - setDone(true); - setTimeout(() => setDone(false), 1500); - } catch { - // ignore clipboard failures - } - }} + onClick={() => copyToClipboard(value, { silent: true, resetMs: 1500 })} > {done ? : } diff --git a/apps/web/src/components/url-encode/url-encode-layout.tsx b/apps/web/src/components/url-encode/url-encode-layout.tsx index 4aa3b9d1..3c2364b1 100644 --- a/apps/web/src/components/url-encode/url-encode-layout.tsx +++ b/apps/web/src/components/url-encode/url-encode-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useCallback, useRef } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -29,7 +30,7 @@ export function UrlEncodeLayout() { const [output, setOutput] = useState(''); const [mode, setMode] = useState('encode'); const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); @@ -80,15 +81,9 @@ export function UrlEncodeLayout() { } }; - const handleCopy = async () => { + const handleCopy = () => { if (!output) return; - try { - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // ignore clipboard failures - } + void copyToClipboard(output, { silent: true }); }; const handleClear = () => { diff --git a/apps/web/src/hooks/use-copy-to-clipboard.ts b/apps/web/src/hooks/use-copy-to-clipboard.ts index 409be945..75d8fbeb 100644 --- a/apps/web/src/hooks/use-copy-to-clipboard.ts +++ b/apps/web/src/hooks/use-copy-to-clipboard.ts @@ -1,40 +1,47 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { toast } from 'sonner'; -/** - * Hook for copying text to clipboard with consistent toast notifications - */ +type CopyOptions = { + successMessage?: string; + /** When true, skip toast notifications (silent fail). */ + silent?: boolean; + /** Milliseconds before isCopied resets. */ + resetMs?: number; +}; + export function useCopyToClipboard() { const [isCopied, setIsCopied] = useState(false); - const copyToClipboard = async (text: string, successMessage?: string) => { + const copyToClipboard = useCallback(async ( + text: string, + options?: string | CopyOptions, + ): Promise => { + const opts: CopyOptions = + typeof options === 'string' ? { successMessage: options } : options ?? {}; + const { successMessage, silent = false, resetMs = 2000 } = opts; + if (!text) { - toast.error('Nothing to copy'); + if (!silent) toast.error('Nothing to copy'); return false; } try { await navigator.clipboard.writeText(text); setIsCopied(true); - toast.success(successMessage || 'Copied to clipboard!', { - duration: 2000, - }); - - // Reset copied state after animation - setTimeout(() => setIsCopied(false), 2000); + if (!silent) { + toast.success(successMessage || 'Copied to clipboard!', { duration: resetMs }); + } + setTimeout(() => setIsCopied(false), resetMs); return true; } catch (err) { - console.error("Failed to copy text:", err); - toast.error('Failed to copy to clipboard'); + console.error('Failed to copy text:', err); + if (!silent) toast.error('Failed to copy to clipboard'); setIsCopied(false); return false; } - }; + }, []); - return { - copyToClipboard, - isCopied, - }; + return { copyToClipboard, isCopied }; } From 2bd1359a8dc27b9cd7256b1518f9166990d084aa Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Thu, 25 Jun 2026 00:04:08 +0530 Subject: [PATCH 3/5] refactor: extend clipboard hook with errorMessage + sweep 20 more callsites Hook: add errorMessage option for translated error toasts. Migrated 20 components from raw clipboard try/catch to useCopyToClipboard: - Toast-flow (10): bookmark-card, api-client/code-generator, api-client/response-panel, csv-excel-json, json-formatter, json-schema-generator, nosql-explorer/{connection-form,document-view, explorer-sidebar}, snippet-manager-tool, s3-drive/file-browser - Silent-fail (10): dns-lookup, docker-compose, encryption-playground, markdown-preview-html, password-manager/advanced-generator, ssh-key-generator, totp-generator, uuid-generator (IdRow), to-do/{KanbanCard,TaskItem} -56 net LOC, 22 files. Typecheck clean. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/app/to-do/KanbanCard.tsx | 9 +++---- apps/web/src/app/app/to-do/TaskItem.tsx | 9 +++---- .../components/api-client/code-generator.tsx | 9 +++---- .../components/api-client/response-panel.tsx | 15 ++++++----- .../components/bookmarks/bookmark-card.tsx | 8 +++--- .../csv-excel-json/csv-excel-json-tool.tsx | 17 ++++++------- .../dns-lookup/dns-lookup-layout.tsx | 9 +++---- .../docker-compose-generator-layout.tsx | 13 +++------- .../encryption-playground-layout.tsx | 25 ++++++------------- .../json-formatter/json-formatter-layout.tsx | 19 ++++++-------- .../json-schema-generator-layout.tsx | 20 ++++++--------- .../markdown-preview-html-layout.tsx | 25 +++++++------------ .../nosql-explorer/connection-form.tsx | 5 ++-- .../nosql-explorer/document-view.tsx | 8 +++--- .../nosql-explorer/explorer-sidebar.tsx | 8 +++--- .../password-manager/advanced-generator.tsx | 13 +++------- .../src/components/s3-drive/file-browser.tsx | 11 ++++---- .../snippet-manager/snippet-manager-tool.tsx | 17 ++++++------- .../ssh-key-generator-layout.tsx | 9 +++---- .../totp-generator/totp-generator-layout.tsx | 13 +++------- .../uuid-generator/uuid-generator-layout.tsx | 11 +++----- apps/web/src/hooks/use-copy-to-clipboard.ts | 7 +++--- 22 files changed, 112 insertions(+), 168 deletions(-) diff --git a/apps/web/src/app/app/to-do/KanbanCard.tsx b/apps/web/src/app/app/to-do/KanbanCard.tsx index ad9baa0a..ebc6be90 100644 --- a/apps/web/src/app/app/to-do/KanbanCard.tsx +++ b/apps/web/src/app/app/to-do/KanbanCard.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, lazy, Suspense } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 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"; @@ -38,7 +39,7 @@ interface KanbanCardProps { export default function KanbanCard({ task, onUpdateTask, onDeleteTask }: KanbanCardProps) { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [elapsed, setElapsed] = useState(() => getElapsedMinutes(task)); useEffect(() => { @@ -86,11 +87,9 @@ export default function KanbanCard({ task, onUpdateTask, onDeleteTask }: KanbanC await onUpdateTask(task.id, updates); }; - const handleCopy = async () => { + const handleCopy = () => { const taskText = `${task.text}${task.description ? `\n${task.description}` : ''}`; - await navigator.clipboard.writeText(taskText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + void copyToClipboard(taskText, { silent: true }); }; return ( diff --git a/apps/web/src/app/app/to-do/TaskItem.tsx b/apps/web/src/app/app/to-do/TaskItem.tsx index 33f0f76e..3382258b 100644 --- a/apps/web/src/app/app/to-do/TaskItem.tsx +++ b/apps/web/src/app/app/to-do/TaskItem.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, lazy, Suspense } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { formatElapsed, getElapsedMinutes } from "@/app/app/to-do/utils/taskTimeUtils"; import { Select, @@ -58,7 +59,7 @@ function TaskItem({ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); const [justCompleted, setJustCompleted] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [elapsed, setElapsed] = useState(() => getElapsedMinutes(task)); useEffect(() => { @@ -129,11 +130,9 @@ function TaskItem({ } }; - const handleCopy = async () => { + const handleCopy = () => { const taskText = `${task.text}${task.description ? `\n${task.description}` : ''}`; - await navigator.clipboard.writeText(taskText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + void copyToClipboard(taskText, { silent: true }); }; const handleQuickComplete = () => { diff --git a/apps/web/src/components/api-client/code-generator.tsx b/apps/web/src/components/api-client/code-generator.tsx index 631f0952..fd8e30df 100644 --- a/apps/web/src/components/api-client/code-generator.tsx +++ b/apps/web/src/components/api-client/code-generator.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { Dialog, DialogContent, @@ -19,7 +20,6 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { IconCopy, IconCheck } from "@tabler/icons-react" import { ApiRequestState } from "./types" import { generateCode, CodeLanguage } from "./generate-code" -import { toast } from "sonner" import { useTranslations } from "next-intl" interface CodeGeneratorProps { @@ -33,7 +33,7 @@ export function CodeGenerator({ request, open, onOpenChange }: CodeGeneratorProp const tApi = useTranslations("ApiClient") const [language, setLanguage] = React.useState("curl") const [code, setCode] = React.useState("") - const [copied, setCopied] = React.useState(false) + const { isCopied: copied, copyToClipboard } = useCopyToClipboard() React.useEffect(() => { try { @@ -46,10 +46,7 @@ export function CodeGenerator({ request, open, onOpenChange }: CodeGeneratorProp }, [request, language, t]) const handleCopy = () => { - navigator.clipboard.writeText(code) - setCopied(true) - toast.success(tApi("toasts.codeCopied")) - setTimeout(() => setCopied(false), 2000) + void copyToClipboard(code, tApi("toasts.codeCopied")) } return ( diff --git a/apps/web/src/components/api-client/response-panel.tsx b/apps/web/src/components/api-client/response-panel.tsx index 44006dfa..8146fdb6 100644 --- a/apps/web/src/components/api-client/response-panel.tsx +++ b/apps/web/src/components/api-client/response-panel.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import dynamic from "next/dynamic" @@ -14,7 +15,6 @@ import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { CheckCircle2, AlertCircle, Copy, Download, Search, Info, Clock, Database, Cookie } from "lucide-react" -import { toast } from "sonner" import { cn } from "@/lib/utils" import type { editor } from "monaco-editor" import { truncateBody } from "./truncate-body" @@ -104,6 +104,7 @@ interface ResponsePanelProps { export function ResponsePanel({ response, isLoading }: ResponsePanelProps) { const t = useTranslations("ApiClient.responsePanel") const tApi = useTranslations("ApiClient") + const { copyToClipboard } = useCopyToClipboard() const bodyEditorRef = React.useRef(null) const handleOpenSearch = () => { @@ -153,14 +154,12 @@ export function ResponsePanel({ response, isLoading }: ResponsePanelProps) { return "text" } - const handleCopy = async () => { + const handleCopy = () => { if (!response?.body) return - try { - await navigator.clipboard.writeText(response.body) - toast.success(tApi("toasts.responseCopied")) - } catch { - toast.error(tApi("toasts.copyFailed")) - } + void copyToClipboard(response.body, { + successMessage: tApi("toasts.responseCopied"), + errorMessage: tApi("toasts.copyFailed"), + }) } const handleDownload = () => { diff --git a/apps/web/src/components/bookmarks/bookmark-card.tsx b/apps/web/src/components/bookmarks/bookmark-card.tsx index ce0d40d4..56f8085d 100644 --- a/apps/web/src/components/bookmarks/bookmark-card.tsx +++ b/apps/web/src/components/bookmarks/bookmark-card.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useCallback } from "react" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { motion } from "framer-motion" import { IconExternalLink, @@ -66,6 +67,8 @@ export default function BookmarkCard({ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [imageError, setImageError] = useState(false) + const { copyToClipboard } = useCopyToClipboard() + const folder = folders.find(f => f.id === bookmark.folderId) const faviconUrl = getFaviconUrl(bookmark.url) const domain = getDomainFromUrl(bookmark.url) @@ -75,9 +78,8 @@ export default function BookmarkCard({ }, [bookmark.url]) const handleCopyUrl = useCallback(() => { - navigator.clipboard.writeText(bookmark.url) - toast.success(t("urlCopied")) - }, [bookmark.url, t]) + void copyToClipboard(bookmark.url, t("urlCopied")) + }, [bookmark.url, t, copyToClipboard]) const handleDelete = useCallback(() => { const snapshot = { ...bookmark } diff --git a/apps/web/src/components/csv-excel-json/csv-excel-json-tool.tsx b/apps/web/src/components/csv-excel-json/csv-excel-json-tool.tsx index 1f2c2d3a..40873600 100644 --- a/apps/web/src/components/csv-excel-json/csv-excel-json-tool.tsx +++ b/apps/web/src/components/csv-excel-json/csv-excel-json-tool.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useTranslations } from "next-intl"; import { usePathname } from "next/navigation"; import { @@ -40,7 +41,7 @@ export function CsvExcelJsonTool() { const [jsonText, setJsonText] = useState(""); const [error, setError] = useState(null); const [dragOver, setDragOver] = useState(false); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const fileRef = useRef(null); useEffect(() => { @@ -134,16 +135,12 @@ export function CsvExcelJsonTool() { } }; - const copyJson = async () => { + const copyJson = () => { if (!jsonText.trim()) return; - try { - await navigator.clipboard.writeText(jsonText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - toast.success(t("toastCopied")); - } catch { - toast.error(t("errors.copyFailed")); - } + void copyToClipboard(jsonText, { + successMessage: t("toastCopied"), + errorMessage: t("errors.copyFailed"), + }); }; const loadSample = () => { diff --git a/apps/web/src/components/dns-lookup/dns-lookup-layout.tsx b/apps/web/src/components/dns-lookup/dns-lookup-layout.tsx index 539404b5..715dd29f 100644 --- a/apps/web/src/components/dns-lookup/dns-lookup-layout.tsx +++ b/apps/web/src/components/dns-lookup/dns-lookup-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useCallback, useRef } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { motion, AnimatePresence } from 'framer-motion'; import { Card, CardContent } from '@/components/ui/card'; @@ -60,15 +61,11 @@ function formatTtl(ttl: number): string { } function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); return ( @@ -836,6 +838,7 @@ export function FileBrowser({ credentials, connectionName }: Props) { toggleSelectKey, clearSelection, selectAll, } = useS3DriveStore() + const { copyToClipboard } = useCopyToClipboard() const [viewMode, setViewMode] = useState("list") const [search, setSearch] = useState("") const [debouncedSearch, setDebouncedSearch] = useState("") @@ -1158,15 +1161,13 @@ export function FileBrowser({ credentials, connectionName }: Props) { } function onCopyS3Path(key: string) { - navigator.clipboard.writeText(`s3://${credentials.bucket}/${key}`) - toast.success("Copied S3 path") + void copyToClipboard(`s3://${credentials.bucket}/${key}`, "Copied S3 path") } async function onCopyLink(key: string) { try { const { url } = await getPresignedDownloadUrl(credentials, key) - await navigator.clipboard.writeText(url) - toast.success("Copied link") + void copyToClipboard(url, "Copied link") } catch { toast.error("Failed to copy link") } diff --git a/apps/web/src/components/snippet-manager/snippet-manager-tool.tsx b/apps/web/src/components/snippet-manager/snippet-manager-tool.tsx index 667d1b60..03684646 100644 --- a/apps/web/src/components/snippet-manager/snippet-manager-tool.tsx +++ b/apps/web/src/components/snippet-manager/snippet-manager-tool.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useAuthState } from "react-firebase-hooks/auth"; import { useTranslations } from "next-intl"; import { useDebouncedCallback } from "use-debounce"; @@ -305,7 +306,7 @@ export function SnippetManagerTool() { const [tagInput, setTagInput] = useState(""); const [search, setSearch] = useState(""); const [mode, setMode] = useState("edit"); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const [deleteTarget, setDeleteTarget] = useState(null); const [listOpen, setListOpen] = useState(false); @@ -647,19 +648,15 @@ export function SnippetManagerTool() { setListOpen(false); }, [selectedId, snippets, debouncedSaveCode, addSnippet, t]); - const handleCopy = async () => { + const handleCopy = () => { if (!draftCode) { toast.message(t("toastNothingToCopy")); return; } - try { - await navigator.clipboard.writeText(draftCode); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - toast.success(t("toastCopied")); - } catch { - toast.error(t("toastCopyFailed")); - } + void copyToClipboard(draftCode, { + successMessage: t("toastCopied"), + errorMessage: t("toastCopyFailed"), + }); }; const handleFormat = async () => { diff --git a/apps/web/src/components/ssh-key-generator/ssh-key-generator-layout.tsx b/apps/web/src/components/ssh-key-generator/ssh-key-generator-layout.tsx index 00001253..af328c50 100644 --- a/apps/web/src/components/ssh-key-generator/ssh-key-generator-layout.tsx +++ b/apps/web/src/components/ssh-key-generator/ssh-key-generator-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useCallback } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -216,16 +217,12 @@ function downloadText(text: string, filename: string) { } function CopyButton({ text, label }: { text: string; label?: string }) { - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); return (