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/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/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/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/app/[username]/public-profile-client.tsx b/apps/web/src/app/[username]/public-profile-client.tsx index f7616114..81b52cad 100644 --- a/apps/web/src/app/[username]/public-profile-client.tsx +++ b/apps/web/src/app/[username]/public-profile-client.tsx @@ -1,6 +1,7 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Link as LinkIcon, Globe, @@ -625,8 +626,7 @@ export default function PublicProfileClient({ username: usernameParam }: { usern const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [mounted, setMounted] = useState(false) - const [copied, setCopied] = useState(false) - const copyTimerRef = useRef | null>(null) + const { isCopied: copied, copyToClipboard } = useCopyToClipboard() const usernameSegment = useMemo(() => usernameParam.trim(), [usernameParam]) @@ -683,12 +683,6 @@ export default function PublicProfileClient({ username: usernameParam }: { usern } }, [usernameSegment]) - useEffect(() => { - return () => { - if (copyTimerRef.current) clearTimeout(copyTimerRef.current) - } - }, []) - const settings = profile?.portfolio_settings const accent = settings?.accentColor?.trim() || '#3b82f6' const layoutTheme: LayoutTheme = @@ -751,18 +745,14 @@ export default function PublicProfileClient({ username: usernameParam }: { usern const sectionIds = useMemo(() => navItems.map((n) => n.id), [navItems]) const activeId = useScrollSpy(sectionIds) - const handleCopyLink = useCallback(async () => { + const handleCopyLink = useCallback(() => { if (typeof window === 'undefined') return - try { - await navigator.clipboard.writeText(window.location.href) - setCopied(true) - toast.success('Link copied to clipboard') - if (copyTimerRef.current) clearTimeout(copyTimerRef.current) - copyTimerRef.current = setTimeout(() => setCopied(false), 1800) - } catch { - toast.error('Could not copy link') - } - }, []) + void copyToClipboard(window.location.href, { + successMessage: 'Link copied to clipboard', + errorMessage: 'Could not copy link', + resetMs: 1800, + }) + }, [copyToClipboard]) const handleShare = useCallback(async () => { if (typeof window === 'undefined') return 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/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/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/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/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/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/graphql-formatter/graphql-formatter-layout.tsx b/apps/web/src/components/graphql-formatter/graphql-formatter-layout.tsx index 8df9a251..4435f6dc 100644 --- a/apps/web/src/components/graphql-formatter/graphql-formatter-layout.tsx +++ b/apps/web/src/components/graphql-formatter/graphql-formatter-layout.tsx @@ -5,6 +5,7 @@ import { parse, print, stripIgnoredCharacters } from 'graphql'; import { AlertCircle, Check, Copy, Trash2, Wand2 } from 'lucide-react'; import { useTheme } from 'next-themes'; import { useCallback, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { registerGraphqlMonarch } from '@/components/graphql-formatter/register-graphql-monarch'; import { useIsMobile } from '@/components/hooks/use-mobile'; @@ -55,7 +56,7 @@ export function GraphqlFormatterLayout() { const [output, setOutput] = useState(''); const [outputMode, setOutputMode] = useState('pretty'); const [error, setError] = useState(''); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard, reset: resetCopied } = useCopyToClipboard(); const [opType, setOpType] = useState<'query' | 'mutation' | 'subscription'>('query'); const [opName, setOpName] = useState('MyOperation'); @@ -67,7 +68,7 @@ export function GraphqlFormatterLayout() { const runFormat = useCallback(() => { setError(''); - setCopied(false); + resetCopied(); if (isMobile) setMobileTab('output'); const q = input; if (!q.trim()) { @@ -90,7 +91,7 @@ export function GraphqlFormatterLayout() { setOutput(''); setError(e instanceof Error ? e.message : t('errors.couldNotFormat')); } - }, [input, outputMode, isMobile, t]); + }, [input, outputMode, isMobile, t, resetCopied]); const applyBuilder = useCallback(() => { setError(''); @@ -115,15 +116,9 @@ export function GraphqlFormatterLayout() { setPanelTab('format'); }, [opType, opName, varDefs, selection, t]); - 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 }); }; return ( 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/json-formatter/json-formatter-layout.tsx b/apps/web/src/components/json-formatter/json-formatter-layout.tsx index 01e38381..4bcf4b39 100644 --- a/apps/web/src/components/json-formatter/json-formatter-layout.tsx +++ b/apps/web/src/components/json-formatter/json-formatter-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 { useIsMobile } from '@/components/hooks/use-mobile' import { @@ -77,6 +78,7 @@ const createPaneState = (initialName: string): PaneState => ({ export function JsonFormatterLayout() { const t = useTranslations('JsonFormatter') const { user } = useAuth(false) + const { copyToClipboard } = useCopyToClipboard() const searchParams = useSearchParams() const initialInputParam = searchParams.get('input') @@ -173,18 +175,13 @@ export function JsonFormatterLayout() { }) } - const handleCopy = async (pane: PaneKey) => { + const handleCopy = (pane: PaneKey) => { const paneState = pane === 'left' ? leftPane : rightPane - try { - const textContent = toTextContent(paneState.content) - await navigator.clipboard.writeText(textContent.text) - toast.success( - pane === 'left' ? t('toastCopiedText') : t('toastCopiedTree') - ) - } catch (error) { - console.error('Failed to copy JSON:', error) - toast.error(t('toastCopyFailed')) - } + const textContent = toTextContent(paneState.content) + void copyToClipboard(textContent.text, { + successMessage: pane === 'left' ? t('toastCopiedText') : t('toastCopiedTree'), + errorMessage: t('toastCopyFailed'), + }) } const handleSave = async (pane: PaneKey) => { diff --git a/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx b/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx index 95cc20d0..cfdbbd54 100644 --- a/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx +++ b/apps/web/src/components/json-schema-generator/json-schema-generator-layout.tsx @@ -1,10 +1,10 @@ 'use client'; import { useCallback, useMemo, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useIsMobile } from '@/components/hooks/use-mobile'; import { useTranslations } from 'next-intl'; import { AlertCircle, Check, Copy, FileJson, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; @@ -44,7 +44,7 @@ export function JsonSchemaGeneratorLayout() { const [mobileTab, setMobileTab] = useState<'input' | 'output'>('input'); const [input, setInput] = useState(defaultSample); const [language, setLanguage] = useState('python'); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const { output, error } = useMemo(() => { const trimmed = input.trim(); @@ -69,17 +69,13 @@ export function JsonSchemaGeneratorLayout() { [output, language, error] ); - const handleCopy = useCallback(async () => { + const handleCopy = useCallback(() => { if (!output) return; - try { - await navigator.clipboard.writeText(output); - setCopied(true); - toast.success(t('copied')); - setTimeout(() => setCopied(false), 2000); - } catch { - toast.error(t('copyFailed')); - } - }, [output, t]); + void copyToClipboard(output, { + successMessage: t('copied'), + errorMessage: t('copyFailed'), + }); + }, [output, t, copyToClipboard]); const handleClear = () => { setInput(''); 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/lorem-ipsum/lorem-ipsum-layout.tsx b/apps/web/src/components/lorem-ipsum/lorem-ipsum-layout.tsx index 71e5172f..cabbc3fd 100644 --- a/apps/web/src/components/lorem-ipsum/lorem-ipsum-layout.tsx +++ b/apps/web/src/components/lorem-ipsum/lorem-ipsum-layout.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, 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'; @@ -37,12 +38,12 @@ export function LoremIpsumLayout() { const [count, setCount] = useState(3); const [asHtml, setAsHtml] = useState(false); const [output, setOutput] = useState(''); - const [copied, setCopied] = useState(false); + const { isCopied: copied, copyToClipboard, reset: resetCopied } = useCopyToClipboard(); const limits = LOREM_LIMITS[unit]; const runGenerate = useCallback(() => { - setCopied(false); + resetCopied(); const n = Number(count); setOutput( generateLorem({ @@ -51,13 +52,11 @@ export function LoremIpsumLayout() { asHtml, }) ); - }, [unit, count, asHtml, limits.min]); + }, [unit, count, asHtml, limits.min, resetCopied]); - const handleCopy = async () => { + const handleCopy = () => { if (!output) return; - await navigator.clipboard.writeText(output); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + void copyToClipboard(output, { silent: true }); }; const handleDownload = () => { diff --git a/apps/web/src/components/markdown-preview-html/markdown-preview-html-layout.tsx b/apps/web/src/components/markdown-preview-html/markdown-preview-html-layout.tsx index f0eb5499..d77d7f47 100644 --- a/apps/web/src/components/markdown-preview-html/markdown-preview-html-layout.tsx +++ b/apps/web/src/components/markdown-preview-html/markdown-preview-html-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 { marked } from 'marked'; import TurndownService from 'turndown'; @@ -72,23 +73,19 @@ export function MarkdownPreviewLayout() { // Markdown → HTML tab const [markdown, setMarkdown] = useState(''); const [outputTab, setOutputTab] = useState<'preview' | 'html'>('preview'); - const [copiedHtml, setCopiedHtml] = useState(false); + const { isCopied: copiedHtml, copyToClipboard: copyHtml } = useCopyToClipboard(); // HTML → Markdown tab const [htmlInput, setHtmlInput] = useState(''); - const [copiedMd, setCopiedMd] = useState(false); + const { isCopied: copiedMd, copyToClipboard: copyMd } = useCopyToClipboard(); const renderedHtml = useMemo(() => renderMarkdown(markdown), [markdown]); const convertedMarkdown = useMemo(() => htmlInput ? htmlToMarkdown(htmlInput) : '', [htmlInput]); - const handleCopyHtml = useCallback(async () => { + const handleCopyHtml = useCallback(() => { if (!renderedHtml) return; - try { - await navigator.clipboard.writeText(renderedHtml); - setCopiedHtml(true); - setTimeout(() => setCopiedHtml(false), 2000); - } catch { /* ignore */ } - }, [renderedHtml]); + void copyHtml(renderedHtml, { silent: true }); + }, [renderedHtml, copyHtml]); const handleExportHtml = useCallback(() => { if (!renderedHtml) return; @@ -101,14 +98,10 @@ export function MarkdownPreviewLayout() { URL.revokeObjectURL(url); }, [renderedHtml]); - const handleCopyMd = useCallback(async () => { + const handleCopyMd = useCallback(() => { if (!convertedMarkdown) return; - try { - await navigator.clipboard.writeText(convertedMarkdown); - setCopiedMd(true); - setTimeout(() => setCopiedMd(false), 2000); - } catch { /* ignore */ } - }, [convertedMarkdown]); + void copyMd(convertedMarkdown, { silent: true }); + }, [convertedMarkdown, copyMd]); return (
diff --git a/apps/web/src/components/mock-data-generator/mock-data-generator-layout.tsx b/apps/web/src/components/mock-data-generator/mock-data-generator-layout.tsx index f983f82e..77e99ef6 100644 --- a/apps/web/src/components/mock-data-generator/mock-data-generator-layout.tsx +++ b/apps/web/src/components/mock-data-generator/mock-data-generator-layout.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useId, useMemo, useState } from 'react' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useIsMobile } from '@/components/hooks/use-mobile' import Link from 'next/link' import { useTranslations } from 'next-intl' @@ -87,7 +88,7 @@ export function MockDataGeneratorLayout() { const [format, setFormat] = useState('json') const [tableName, setTableName] = useState('records') const [output, setOutput] = useState('') - const [copied, setCopied] = useState(false) + const { isCopied: copied, copyToClipboard, reset: resetCopied } = useCopyToClipboard() const [presetSelectKey, setPresetSelectKey] = useState(0) const schema = useMemo(() => toFieldSchema(schemaRows), [schemaRows]) @@ -113,7 +114,7 @@ export function MockDataGeneratorLayout() { }, []) const runGenerate = useCallback(() => { - setCopied(false) + resetCopied() setOutput( generateMockData({ schema, @@ -123,13 +124,11 @@ export function MockDataGeneratorLayout() { }) ) if (isMobile) setMobileTab('output') - }, [schema, rowCount, format, tableName, isMobile]) + }, [schema, rowCount, format, tableName, isMobile, resetCopied]) - const handleCopy = async () => { + const handleCopy = () => { if (!output) return - await navigator.clipboard.writeText(output) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + void copyToClipboard(output, { silent: true }) } const handleDownload = () => { @@ -145,7 +144,7 @@ export function MockDataGeneratorLayout() { const loadPreset = (key: string) => { setPresetSelectKey((k) => k + 1) - setCopied(false) + resetCopied() setOutput('') switch (key) { case 'users': diff --git a/apps/web/src/components/nosql-explorer/connection-form.tsx b/apps/web/src/components/nosql-explorer/connection-form.tsx index 32a1e428..06ba6b1d 100644 --- a/apps/web/src/components/nosql-explorer/connection-form.tsx +++ b/apps/web/src/components/nosql-explorer/connection-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -43,6 +44,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp el, de, da, af, fa: faIR, ms, nb, nl, pt, }; const dateLocale = DATE_LOCALE_MAP[locale] ?? enUS; + const { copyToClipboard } = useCopyToClipboard(); const [connectionString, setConnectionString] = useState(""); const [name, setName] = useState("My Connection"); const [savedConnections, setSavedConnections] = useState([]); @@ -279,8 +281,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp className="h-6 w-6 flex-shrink-0 hover:text-primary" onClick={(e) => { e.stopPropagation(); - navigator.clipboard.writeText(conn.connectionString); - toast.success(t("toastStringCopied")); + void copyToClipboard(conn.connectionString, t("toastStringCopied")); }} title={t("copyStringTitle")} > diff --git a/apps/web/src/components/nosql-explorer/document-view.tsx b/apps/web/src/components/nosql-explorer/document-view.tsx index a0fbded3..56230e10 100644 --- a/apps/web/src/components/nosql-explorer/document-view.tsx +++ b/apps/web/src/components/nosql-explorer/document-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Document } from "./types"; import { Button } from "@/components/ui/button"; @@ -409,6 +410,7 @@ export function DocumentView({ const [indexesLoading, setIndexesLoading] = useState(false); const [indexesError, setIndexesError] = useState(null); const { theme } = useTheme(); + const { copyToClipboard } = useCopyToClipboard(); const tableContainerRef = useRef(null); @@ -479,8 +481,7 @@ export function DocumentView({ }; const handleCopy = () => { - navigator.clipboard.writeText(jsonViewContent); - toast.success(t("copiedClipboard")); + void copyToClipboard(jsonViewContent, t("copiedClipboard")); }; const handleViewValue = (value: any) => { @@ -529,8 +530,7 @@ export function DocumentView({ }; const handleCopyDocument = (doc: Document) => { - navigator.clipboard.writeText(JSON.stringify(doc, null, 2)); - toast.success(t("docCopied")); + void copyToClipboard(JSON.stringify(doc, null, 2), t("docCopied")); }; const handleSort = (field: string) => { diff --git a/apps/web/src/components/nosql-explorer/explorer-sidebar.tsx b/apps/web/src/components/nosql-explorer/explorer-sidebar.tsx index 8c4ced42..0fdef175 100644 --- a/apps/web/src/components/nosql-explorer/explorer-sidebar.tsx +++ b/apps/web/src/components/nosql-explorer/explorer-sidebar.tsx @@ -6,6 +6,7 @@ import { Database, Collection, SavedConnection } from "./types"; import { IconDatabase, IconFolder, IconChevronRight, IconChevronDown, IconRefresh, IconSearch, IconPlus, IconServer, IconPencil, IconCheck, IconX, IconDotsVertical, IconTrash, IconEdit, IconCopy, IconAlertCircle, IconLoader2 } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import React, { useState, useEffect, useRef } from "react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; import useAuth from "@/utils/useAuth"; import { useMasterKeyStore } from "@/store/master-key-store"; @@ -62,6 +63,7 @@ export function ExplorerSidebar({ const t = useTranslations("NoSqlExplorer.sidebar"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); + const { copyToClipboard } = useCopyToClipboard(); const [connections, setConnections] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState(true); @@ -603,8 +605,7 @@ export function ExplorerSidebar({ { e.stopPropagation(); - navigator.clipboard.writeText(node.connection.connectionString); - toast.success(t("toastSidebarStringCopied")); + void copyToClipboard(node.connection.connectionString, t("toastSidebarStringCopied")); }}> {t("menuCopyConnectionString")} @@ -690,8 +691,7 @@ export function ExplorerSidebar({ {t("refresh")} { - navigator.clipboard.writeText(db.name); - toast.success(t("toastDbNameCopied")); + void copyToClipboard(db.name, t("toastDbNameCopied")); }}> {t("copyDbName")} 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/password-manager/advanced-generator.tsx b/apps/web/src/components/password-manager/advanced-generator.tsx index 6c6af6df..1924f620 100644 --- a/apps/web/src/components/password-manager/advanced-generator.tsx +++ b/apps/web/src/components/password-manager/advanced-generator.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useCallback } from "react" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { Button } from "@/components/ui/button" import { Slider } from "@/components/ui/slider" import { Label } from "@/components/ui/label" @@ -27,7 +28,7 @@ export function AdvancedGenerator({ onPasswordChange, initialLength = 16, classN const [mode, setMode] = useState<"password" | "passphrase">("password") const [length, setLength] = useState(initialLength) const [password, setPassword] = useState("") - const [copied, setCopied] = useState(false) + const { isCopied: copied, copyToClipboard } = useCopyToClipboard() // Password Options const [options, setOptions] = useState({ @@ -122,14 +123,8 @@ export function AdvancedGenerator({ onPasswordChange, initialLength = 16, classN generatePassword() }, [generatePassword]) - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(password) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch { - // Silent fail - } + const handleCopy = () => { + void copyToClipboard(password, { silent: true }) } const strengthScore = calculatePasswordStrength(password) 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/s3-drive/checkbox.tsx b/apps/web/src/components/s3-drive/checkbox.tsx new file mode 100644 index 00000000..852f30d6 --- /dev/null +++ b/apps/web/src/components/s3-drive/checkbox.tsx @@ -0,0 +1,39 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { IconCheck } from "@tabler/icons-react" + +export function Checkbox({ + checked, indeterminate, onToggle, className, ariaLabel, +}: { + checked: boolean; indeterminate?: boolean; onToggle: () => void; className?: string; ariaLabel?: string +}) { + return ( +
{ if (e.key === " " || e.key === "Enter") { e.preventDefault(); onToggle() } }} + onClick={(e) => { e.stopPropagation(); onToggle() }} + className={cn( + "size-[18px] rounded-[4px] border-2 flex items-center justify-center cursor-pointer shrink-0 transition-all duration-100", + checked || indeterminate + ? "bg-blue-600 border-blue-600" + : "border-slate-400/60 bg-white/70 dark:bg-black/30 hover:border-blue-500", + className, + )} + > + {indeterminate && !checked + ?
+ : checked + ? + : null + } +
+ ) +} + +export function isCheckboxClick(e: React.MouseEvent) { + return !!(e.target as HTMLElement).closest("[data-sel-cb]") +} diff --git a/apps/web/src/components/s3-drive/file-browser.tsx b/apps/web/src/components/s3-drive/file-browser.tsx index d567ecb9..3fc1984e 100644 --- a/apps/web/src/components/s3-drive/file-browser.tsx +++ b/apps/web/src/components/s3-drive/file-browser.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -41,15 +42,6 @@ import { } from "@/components/ui/dialog" import { IconFolder, - IconFile, - IconFileTypePdf, - IconFileTypeZip, - IconFileTypeCsv, - IconFileTypeDoc, - IconFileCode, - IconPhoto, - IconVideo, - IconMusic, IconTrash, IconDownload, IconUpload, @@ -75,634 +67,29 @@ import { IconPackage, IconArrowsMove, IconShare, - IconAlertCircle, IconPlus, - IconMinus, - IconChevronDown, } from "@tabler/icons-react" import { listObjects, deleteObjects, getPresignedDownloadUrl, getPresignedUploadUrl, getPresignedBatch, moveObject, configureBucketCors } from "@/lib/s3-drive-api" import type { S3Credentials, S3ObjectItem } from "@/lib/s3-drive-api" import { useS3DriveStore } from "@/store/s3-drive-store" import { CreateFolderDialog } from "./create-folder-dialog" -import { formatBytes } from "./utils" +import { formatBytes, moveFolderRecursive, uploadFileXHR } from "./utils" +import { getFileType, isPreviewable, TYPE_BG_CLASS, TYPE_LABEL } from "./file-types" +import { FileIconComp } from "./file-icon" +import { Checkbox, isCheckboxClick } from "./checkbox" +import { FilePreviewDialog, type PreviewState } from "./file-preview-dialog" +import { RenameDialog } from "./rename-dialog" +import { ShareLinkDialog } from "./share-link-dialog" +import { UploadProgressPanel, type FileUploadStatus } from "./upload-progress-panel" +import { MoveToDialog } from "./move-to-dialog" type ViewMode = "list" | "grid" type SortCol = "name" | "size" | "modified" type SortDir = "asc" | "desc" -type PreviewState = { - key: string - url: string | null - loading: boolean - fileType: string - textContent?: string -} type RenameTarget = { key: string; isFolder: boolean; displayName: string } -// ── File type helpers ───────────────────────────────────────────────────────── - -const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "svg", "webp", "avif", "bmp", "ico"] -const VIDEO_EXTS = ["mp4", "mov", "avi", "mkv", "webm", "m4v"] -const AUDIO_EXTS = ["mp3", "wav", "ogg", "flac", "aac", "m4a"] -const ARCHIVE_EXTS = ["zip", "tar", "gz", "bz2", "7z", "rar", "xz"] -const CODE_EXTS = ["js", "ts", "tsx", "jsx", "py", "go", "rs", "rb", "java", "c", "cpp", "h", "cs", "php", "sh", "yaml", "yml", "toml", "json", "xml", "html", "css", "scss", "sql"] -const DOC_EXTS = ["doc", "docx", "txt", "md", "rtf", "odt"] -const SHEET_EXTS = ["xls", "xlsx", "csv", "ods"] - -function getFileType(name: string) { - const ext = name.split(".").pop()?.toLowerCase() ?? "" - if (IMAGE_EXTS.includes(ext)) return "image" - if (VIDEO_EXTS.includes(ext)) return "video" - if (AUDIO_EXTS.includes(ext)) return "audio" - if (ext === "pdf") return "pdf" - if (ARCHIVE_EXTS.includes(ext)) return "archive" - if (CODE_EXTS.includes(ext)) return "code" - if (DOC_EXTS.includes(ext)) return "doc" - if (SHEET_EXTS.includes(ext)) return "sheet" - return "file" -} - -function isPreviewable(type: string): boolean { - return ["image", "pdf", "code", "doc", "video", "audio"].includes(type) -} - -const TYPE_ICON_COLOR: Record = { - image: "text-emerald-500", video: "text-purple-500", audio: "text-blue-500", - pdf: "text-red-500", archive: "text-orange-500", code: "text-sky-500", - doc: "text-indigo-400", sheet: "text-green-600", file: "text-slate-400", -} -const TYPE_BG_CLASS: Record = { - image: "bg-emerald-500/10", - video: "bg-purple-500/10", - audio: "bg-blue-500/10", - pdf: "bg-red-500/10", - archive: "bg-orange-500/10", - code: "bg-sky-500/10", - doc: "bg-indigo-400/10", - sheet: "bg-green-600/10", - file: "bg-slate-100 dark:bg-slate-800", -} -const TYPE_LABEL: Record = { - image: "Image", video: "Video", audio: "Audio", pdf: "PDF", - archive: "Archive", code: "Code", doc: "Document", sheet: "Spreadsheet", file: "File", -} - -function FileIconComp({ type, className }: { type: string; className?: string }) { - const c = cn(TYPE_ICON_COLOR[type] ?? "text-slate-400", className) - switch (type) { - case "image": return - case "video": return - case "audio": return - case "pdf": return - case "archive": return - case "code": return - case "doc": return - case "sheet": return - default: return - } -} - -// ── Checkbox ────────────────────────────────────────────────────────────────── - -function Checkbox({ - checked, indeterminate, onToggle, className, ariaLabel, -}: { - checked: boolean; indeterminate?: boolean; onToggle: () => void; className?: string; ariaLabel?: string -}) { - return ( -
{ if (e.key === " " || e.key === "Enter") { e.preventDefault(); onToggle() } }} - onClick={(e) => { e.stopPropagation(); onToggle() }} - className={cn( - "size-[18px] rounded-[4px] border-2 flex items-center justify-center cursor-pointer shrink-0 transition-all duration-100", - checked || indeterminate - ? "bg-blue-600 border-blue-600" - : "border-slate-400/60 bg-white/70 dark:bg-black/30 hover:border-blue-500", - className, - )} - > - {indeterminate && !checked - ?
- : checked - ? - : null - } -
- ) -} - -function isCheckboxClick(e: React.MouseEvent) { - return !!(e.target as HTMLElement).closest("[data-sel-cb]") -} // ── File preview dialog ─────────────────────────────────────────────────────── - -function FilePreviewDialog({ - preview, onClose, onDownload, -}: { - preview: PreviewState | null - onClose: () => void - onDownload: (key: string) => void -}) { - if (!preview) return null - const name = preview.key.split("/").pop() ?? preview.key - - const body = (() => { - if (preview.loading) { - return ( -
- -
- ) - } - if (!preview.url) { - return

Failed to load preview

- } - if (preview.fileType === "image") { - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {name} -
- ) - } - if (preview.fileType === "pdf") { - return ( -