diff --git a/.env.example b/.env.example index c5895f1..12924e5 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,5 @@ QF_CLIENT_SECRET="your-qf-client-secret" QF_AUTH_BASE_URL="https://prelive-oauth2.quran.foundation" QF_API_BASE_URL="https://apis-prelive.quran.foundation" QF_REDIRECT_URI="http://localhost:8000/auth/qf/callback" +QF_MUSHAF_ID=4 FRONTEND_URL="http://localhost:3000" diff --git a/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py b/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py new file mode 100644 index 0000000..67608c5 --- /dev/null +++ b/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py @@ -0,0 +1,81 @@ +"""add bookmarks table + +Revision ID: 788ac94a8323 +Revises: add_qf_oauth_fields_to_users +Create Date: 2026-05-19 17:49:39.129853+00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "788ac94a8323" +down_revision: str | Sequence[str] | None = "add_qf_oauth_fields_to_users" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("bookmarks", sa.Column("surah_number", sa.Integer(), nullable=True)) + op.add_column("bookmarks", sa.Column("verse_number", sa.Integer(), nullable=True)) + + conn = op.get_bind() + conn.execute( + sa.text(""" + UPDATE bookmarks + SET surah_number = split_part(ayah_key, ':', 1)::integer, + verse_number = split_part(ayah_key, ':', 2)::integer + """) + ) + + op.alter_column("bookmarks", "surah_number", nullable=False) + op.alter_column("bookmarks", "verse_number", nullable=False) + op.create_foreign_key(None, "bookmarks", "users", ["user_id"], ["id"]) + op.drop_column("bookmarks", "note") + op.drop_column("bookmarks", "extra_data") + op.drop_column("bookmarks", "translation") + op.drop_column("bookmarks", "arabic_text") + op.drop_column("bookmarks", "surah_name") + op.drop_index(op.f("ix_notes_user_id"), table_name="notes") + op.create_foreign_key( + None, "notes", "users", ["user_id"], ["id"], ondelete="CASCADE" + ) + + +def downgrade() -> None: + op.drop_constraint(None, "notes", type_="foreignkey") + op.create_index(op.f("ix_notes_user_id"), "notes", ["user_id"], unique=False) + op.add_column( + "bookmarks", + sa.Column( + "surah_name", sa.VARCHAR(length=100), autoincrement=False, nullable=False + ), + ) + op.add_column( + "bookmarks", + sa.Column("arabic_text", sa.TEXT(), autoincrement=False, nullable=False), + ) + op.add_column( + "bookmarks", + sa.Column("translation", sa.TEXT(), autoincrement=False, nullable=False), + ) + op.add_column( + "bookmarks", + sa.Column( + "extra_data", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + ) + op.add_column( + "bookmarks", sa.Column("note", sa.TEXT(), autoincrement=False, nullable=True) + ) + op.drop_constraint(None, "bookmarks", type_="foreignkey") + op.drop_column("bookmarks", "verse_number") + op.drop_column("bookmarks", "surah_number") diff --git a/apps/api/alembic/versions/20260520_0000-drop_bookmarks_table.py b/apps/api/alembic/versions/20260520_0000-drop_bookmarks_table.py new file mode 100644 index 0000000..070a7c4 --- /dev/null +++ b/apps/api/alembic/versions/20260520_0000-drop_bookmarks_table.py @@ -0,0 +1,39 @@ +"""drop bookmarks table + +Revision ID: drop_bookmarks_table +Revises: 788ac94a8323 +Create Date: 2026-05-20 00:00:00.000000+00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "drop_bookmarks_table" +down_revision: str | Sequence[str] | None = "788ac94a8323" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_table("bookmarks") + + +def downgrade() -> None: + op.create_table( + "bookmarks", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("ayah_key", sa.String(length=20), nullable=False), + sa.Column("surah_number", sa.Integer(), nullable=False), + sa.Column("verse_number", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + ) + op.create_index( + op.f("ix_bookmarks_user_id"), "bookmarks", ["user_id"], unique=False + ) diff --git a/apps/api/app/api/auth/router.py b/apps/api/app/api/auth/router.py index ffa5793..6aceef3 100644 --- a/apps/api/app/api/auth/router.py +++ b/apps/api/app/api/auth/router.py @@ -145,7 +145,7 @@ async def qf_authorize(request: Request): ) ) - scope = "openid offline_access user collection" + scope = "openid offline_access user collection bookmark" oauth_data = { "code_verifier": code_verifier, diff --git a/apps/api/app/api/bookmarks/__init__.py b/apps/api/app/api/bookmarks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/app/api/bookmarks/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/app/api/bookmarks/router.py b/apps/api/app/api/bookmarks/router.py index ba61402..62e9c9d 100644 --- a/apps/api/app/api/bookmarks/router.py +++ b/apps/api/app/api/bookmarks/router.py @@ -1,6 +1,6 @@ -from uuid import UUID +from typing import Annotated -from fastapi import APIRouter, Body, Depends, HTTPException, status +from fastapi import APIRouter, Depends, status from sqlalchemy.ext.asyncio import AsyncSession from app.api.auth.router import get_current_user @@ -8,9 +8,6 @@ BookmarkCreate, BookmarkListResponse, BookmarkResponse, - NoteCreate, - NoteListResponse, - NoteResponse, ) from app.core.database import get_db from app.models.user import User @@ -18,129 +15,36 @@ router = APIRouter(prefix="/bookmarks", tags=["bookmarks"]) - -@router.post("", status_code=status.HTTP_201_CREATED, response_model=BookmarkResponse) -async def create_bookmark( - body: BookmarkCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - bookmark = await service.create_bookmark( - db, - user_id=current_user.id, - ayah_key=body.ayah_key, - surah_name=body.surah_name, - arabic_text=body.arabic_text, - translation=body.translation, - note=body.note, - extra_data=body.extra_data, - ) - return bookmark +CurrentUserDep = Annotated[User, Depends(get_current_user)] +DbDep = Annotated[AsyncSession, Depends(get_db)] @router.get("", response_model=BookmarkListResponse) async def get_bookmarks( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - bookmarks = await service.get_bookmarks(db, current_user.id) - return BookmarkListResponse( - bookmarks=[ - BookmarkResponse( - id=b.id, - user_id=b.user_id, - ayah_key=b.ayah_key, - surah_name=b.surah_name, - arabic_text=b.arabic_text, - translation=b.translation, - note=b.note, - extra_data=b.extra_data, - created_at=b.created_at, - ) - for b in bookmarks - ] - ) + db: DbDep, + current_user: CurrentUserDep, +) -> BookmarkListResponse: + bookmarks = await service.list_bookmarks(db, current_user) + return BookmarkListResponse(bookmarks=bookmarks) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=BookmarkResponse, +) +async def create_bookmark( + body: BookmarkCreate, + db: DbDep, + current_user: CurrentUserDep, +) -> BookmarkResponse: + return await service.create_bookmark(db, current_user, body.ayah_key) @router.delete("/{bookmark_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_bookmark( bookmark_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - success = await service.delete_bookmark(db, UUID(bookmark_id), current_user.id) - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Bookmark not found", - ) - - -@router.post("/notes", status_code=status.HTTP_201_CREATED, response_model=NoteResponse) -async def create_note( - body: NoteCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - note = await service.create_note( - db, - user_id=current_user.id, - topic=body.topic, - content=body.content, - verses=body.verses, - ) - return note - - -@router.get("/notes", response_model=NoteListResponse) -async def get_notes( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - notes = await service.get_notes(db, current_user.id) - return NoteListResponse( - notes=[ - NoteResponse( - id=n.id, - user_id=n.user_id, - topic=n.topic, - content=n.content, - verses=n.verses, - created_at=n.created_at, - updated_at=n.updated_at, - ) - for n in notes - ] - ) - - -@router.patch("/notes/{note_id}", response_model=NoteResponse) -async def update_note( - note_id: str, - content: str = Body(...), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - note = await service.update_note( - db, UUID(note_id), current_user.id, content=content - ) - if not note: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found", - ) - return note - - -@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_note( - note_id: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - success = await service.delete_note(db, UUID(note_id), current_user.id) - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found", - ) + db: DbDep, + current_user: CurrentUserDep, +) -> None: + await service.delete_bookmark(db, current_user, bookmark_id) diff --git a/apps/api/app/api/bookmarks/serializer.py b/apps/api/app/api/bookmarks/serializer.py index 9bc1238..cce77b7 100644 --- a/apps/api/app/api/bookmarks/serializer.py +++ b/apps/api/app/api/bookmarks/serializer.py @@ -1,51 +1,27 @@ from datetime import datetime -from uuid import UUID from pydantic import BaseModel class BookmarkCreate(BaseModel): ayah_key: str - surah_name: str - arabic_text: str - translation: str - note: str | None = None - extra_data: dict | None = None class BookmarkResponse(BaseModel): - id: UUID + id: str ayah_key: str + type: str + surah_number: int surah_name: str - arabic_text: str - translation: str - note: str | None = None - extra_data: dict | None = None + verse_number: int + group: str | None = None + is_in_default_collection: bool = True + is_reading: bool | None = None + collections_count: int | None = None created_at: datetime - - model_config = {"from_attributes": True} + arabic_text: str = "" + translation: str = "" class BookmarkListResponse(BaseModel): bookmarks: list[BookmarkResponse] - - -class NoteCreate(BaseModel): - topic: str - content: str - verses: list[dict] | None = None - - -class NoteResponse(BaseModel): - id: UUID - topic: str - content: str - verses: list[dict] | None = None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class NoteListResponse(BaseModel): - notes: list[NoteResponse] diff --git a/apps/api/app/api/notes/__init__.py b/apps/api/app/api/notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/api/notes/router.py b/apps/api/app/api/notes/router.py new file mode 100644 index 0000000..68cf672 --- /dev/null +++ b/apps/api/app/api/notes/router.py @@ -0,0 +1,85 @@ +from uuid import UUID + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.router import get_current_user +from app.api.notes.serializer import ( + NoteCreate, + NoteListResponse, + NoteResponse, +) +from app.core.database import get_db +from app.models.user import User +from app.modules.notes import service + +router = APIRouter(prefix="/notes", tags=["notes"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, response_model=NoteResponse) +async def create_note( + body: NoteCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + note = await service.create_note( + db, + user_id=current_user.id, + topic=body.topic, + content=body.content, + verses=body.verses, + ) + return note + + +@router.get("", response_model=NoteListResponse) +async def get_notes( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + notes = await service.get_notes(db, current_user.id) + return NoteListResponse( + notes=[ + NoteResponse( + id=n.id, + topic=n.topic, + content=n.content, + verses=n.verses, + created_at=n.created_at, + updated_at=n.updated_at, + ) + for n in notes + ] + ) + + +@router.patch("/{note_id}", response_model=NoteResponse) +async def update_note( + note_id: str, + content: str = Body(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + note = await service.update_note( + db, UUID(note_id), current_user.id, content=content + ) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found", + ) + return note + + +@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_note( + note_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + success = await service.delete_note(db, UUID(note_id), current_user.id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Note not found", + ) diff --git a/apps/api/app/api/notes/serializer.py b/apps/api/app/api/notes/serializer.py new file mode 100644 index 0000000..d5497c6 --- /dev/null +++ b/apps/api/app/api/notes/serializer.py @@ -0,0 +1,25 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class NoteCreate(BaseModel): + topic: str + content: str + verses: list[dict] | None = None + + +class NoteResponse(BaseModel): + id: UUID + topic: str + content: str + verses: list[dict] | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class NoteListResponse(BaseModel): + notes: list[NoteResponse] diff --git a/apps/api/app/core/settings.py b/apps/api/app/core/settings.py index c1f0794..ba087de 100644 --- a/apps/api/app/core/settings.py +++ b/apps/api/app/core/settings.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): QF_AUTH_BASE_URL: str = "https://prelive-oauth2.quran.foundation" QF_API_BASE_URL: str = "https://apis-prelive.quran.foundation" QF_REDIRECT_URI: str = "" + QF_MUSHAF_ID: int = 4 FRONTEND_URL: str = "http://localhost:3000" model_config = SettingsConfigDict(env_file=str(ROOT_DIR / ".env"), extra="ignore") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 91acd90..8ec7bfc 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -10,6 +10,7 @@ from app.api.auth.router import router as auth_router from app.api.bookmarks.router import router as bookmarks_router +from app.api.notes.router import router as notes_router from app.api.search.router import router as search_router from app.api.users.router import router as users_router from app.core.redis import close_redis @@ -65,6 +66,7 @@ async def health(): app.include_router(auth_router) app.include_router(users_router) app.include_router(search_router) +app.include_router(notes_router) app.include_router(bookmarks_router) diff --git a/apps/api/app/models/__init__.py b/apps/api/app/models/__init__.py index 9041e69..fae2606 100644 --- a/apps/api/app/models/__init__.py +++ b/apps/api/app/models/__init__.py @@ -1,5 +1,5 @@ -from app.models.bookmark import Bookmark +from app.models.note import Note from app.models.search import Topic, TopicResult, UserSearch from app.models.user import User -__all__ = ["Bookmark", "Topic", "TopicResult", "User", "UserSearch"] +__all__ = ["Note", "Topic", "TopicResult", "User", "UserSearch"] diff --git a/apps/api/app/models/bookmark.py b/apps/api/app/models/note.py similarity index 57% rename from apps/api/app/models/bookmark.py rename to apps/api/app/models/note.py index 22302d9..cd98ce0 100644 --- a/apps/api/app/models/bookmark.py +++ b/apps/api/app/models/note.py @@ -8,26 +8,6 @@ from app.core.database import Base -class Bookmark(Base): - __tablename__ = "bookmarks" - - id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 - ) - user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE") - ) - ayah_key: Mapped[str] = mapped_column(String(20)) - surah_name: Mapped[str] = mapped_column(String(100)) - arabic_text: Mapped[str] = mapped_column(Text) - translation: Mapped[str] = mapped_column(Text) - note: Mapped[str | None] = mapped_column(Text, nullable=True) - extra_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) - created_at: Mapped[datetime] = mapped_column( - DateTime, default=lambda: datetime.now(UTC).replace(tzinfo=None) - ) - - class Note(Base): __tablename__ = "notes" diff --git a/apps/api/app/modules/auth/qf_service.py b/apps/api/app/modules/auth/qf_service.py index 57a37de..979513b 100644 --- a/apps/api/app/modules/auth/qf_service.py +++ b/apps/api/app/modules/auth/qf_service.py @@ -11,6 +11,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.redis import get_redis from app.core.settings import get_settings from app.models.user import User @@ -152,21 +153,42 @@ async def refresh_qf_access_token( return None -async def call_qf_api(access_token: str, path: str) -> dict[str, Any] | None: +async def call_qf_api( + access_token: str, + path: str, + *, + method: str = "GET", + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, +) -> dict[str, Any] | None: cfg = _get_qf_config() try: async with httpx.AsyncClient() as client: - resp = await client.get( + resp = await client.request( + method, f"{cfg['api_base_url']}{path}", + params=params, + json=json_body, headers={ "x-auth-token": access_token, "x-client-id": cfg["client_id"], }, ) resp.raise_for_status() + if resp.status_code == 204: + return {"success": True} return resp.json() + except httpx.HTTPStatusError as e: + logger.error( + "QF User API call failed: {} {} {} - {}", + method, + path, + e.response.status_code, + e.response.text, + ) + return None except httpx.HTTPError as e: - logger.error("QF User API call failed: {} {}", e, path) + logger.error("QF User API call failed: {} {} {}", method, path, e) return None @@ -174,17 +196,27 @@ async def get_valid_qf_access_token(db: AsyncSession, user: User) -> str | None: if not user.qf_refresh_token: return None + redis_conn = await get_redis() + cache_key = f"{_USER_TOKEN_PREFIX}{user.id}" + cached = await redis_conn.get(cache_key) + if cached: + return cached.decode() if isinstance(cached, bytes) else cached + token_data = await refresh_qf_access_token(user.qf_refresh_token) if token_data is None: return None new_access = token_data.get("access_token") new_refresh = token_data.get("refresh_token") + expires_in = token_data.get("expires_in", 3600) if new_refresh: user.qf_refresh_token = new_refresh await db.commit() + if new_access: + await redis_conn.setex(cache_key, expires_in - 60, new_access) + return new_access @@ -234,3 +266,120 @@ async def login_or_create_user( await db.commit() await db.refresh(user) return user + + +_CONTENT_TOKEN_KEY = "qf_content_token" +_CHAPTERS_CACHE_KEY = "qf_chapters" +_USER_TOKEN_PREFIX = "qf_user_token:" + + +async def get_content_api_token() -> str | None: + redis_conn = await get_redis() + cached = await redis_conn.get(_CONTENT_TOKEN_KEY) + if cached: + return cached.decode() if isinstance(cached, bytes) else cached + + cfg = _get_qf_config() + if not cfg["client_secret"]: + return None + + data = { + "grant_type": "client_credentials", + "scope": "content", + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{cfg['auth_base_url']}/oauth2/token", + data=data, + auth=(cfg["client_id"], cfg["client_secret"]), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + result = resp.json() + access_token = result.get("access_token") + expires_in = result.get("expires_in", 3600) + if access_token: + await redis_conn.setex( + _CONTENT_TOKEN_KEY, expires_in - 60, access_token + ) + return access_token + except httpx.HTTPError as e: + logger.error("QF content API token request failed: {}", e) + return None + + +async def fetch_chapters(language: str = "en") -> dict[int, str] | None: + redis_conn = await get_redis() + cached = await redis_conn.get(_CHAPTERS_CACHE_KEY) + if cached: + raw = cached.decode() if isinstance(cached, bytes) else cached + return json.loads(raw) + + token = await get_content_api_token() + if not token: + return None + + cfg = _get_qf_config() + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{cfg['api_base_url']}/content/api/v4/chapters", + params={"language": language}, + headers={ + "x-auth-token": token, + "x-client-id": cfg["client_id"], + }, + ) + resp.raise_for_status() + data = resp.json() + + chapters = {ch["id"]: ch["name_simple"] for ch in data.get("chapters", [])} + await redis_conn.setex(_CHAPTERS_CACHE_KEY, 86400, json.dumps(chapters)) + return chapters + except httpx.HTTPError as e: + logger.error("QF chapters fetch failed: {}", e) + return None + + +async def fetch_verses_by_keys( + verse_keys: list[str], + mushaf_id: int = 4, +) -> dict[str, dict[str, Any]]: + result = {} + try: + async with httpx.AsyncClient() as client: + for key in verse_keys: + url = f"https://api.quran.com/api/v4/verses/by_key/{key}" + resp = await client.get( + url, + params={ + "words": "false", + "translations": "131,20", + "fields": "text_uthmani", + }, + ) + if resp.status_code != 200: + logger.warning( + "Quran.com API verse fetch failed for {}: {}", + key, + resp.status_code, + ) + continue + + data = resp.json() + v = data.get("verse", {}) + translations = v.get("translations", []) + translation_text = "" + if translations and isinstance(translations, list): + translation_text = translations[0].get("text", "") + + result[key] = { + "arabic_text": v.get("text_uthmani", ""), + "translation": translation_text, + } + except httpx.HTTPError as e: + logger.error("Quran.com API verse fetch failed: {}", e) + + return result diff --git a/apps/api/app/modules/bookmarks/__init__.py b/apps/api/app/modules/bookmarks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/app/modules/bookmarks/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/app/modules/bookmarks/service.py b/apps/api/app/modules/bookmarks/service.py index 48574b8..68d2e5a 100644 --- a/apps/api/app/modules/bookmarks/service.py +++ b/apps/api/app/modules/bookmarks/service.py @@ -1,113 +1,191 @@ -from datetime import UTC, datetime +from datetime import datetime from typing import Any -from uuid import UUID -from sqlalchemy import select +from fastapi import HTTPException, status +from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession -from app.models.bookmark import Bookmark, Note +from app.api.bookmarks.serializer import BookmarkResponse +from app.core.settings import get_settings +from app.models.user import User +from app.modules.auth import qf_service -async def create_bookmark( - db: AsyncSession, - user_id: UUID, - ayah_key: str, - surah_name: str, - arabic_text: str, - translation: str, - note: str | None = None, - extra_data: dict | None = None, -) -> Bookmark: - bookmark = Bookmark( - user_id=user_id, - ayah_key=ayah_key, - surah_name=surah_name, - arabic_text=arabic_text, - translation=translation, - note=note, - extra_data=extra_data, - ) - db.add(bookmark) - await db.commit() - await db.refresh(bookmark) - return bookmark +def _get_surah_name(chapters: dict[int, str], number: int) -> str: + return chapters.get(number, f"Surah {number}") -async def get_bookmarks(db: AsyncSession, user_id: UUID) -> list[Bookmark]: - result = await db.execute( - select(Bookmark) - .where(Bookmark.user_id == user_id) - .order_by(Bookmark.created_at.desc()) +def _normalize_qf_bookmark( + raw: dict[str, Any], chapters: dict[int, str] +) -> BookmarkResponse: + surah_number = int(raw["key"]) + verse_number = int(raw["verseNumber"]) + created_at_raw = raw.get("createdAt") + created_at = ( + datetime.fromisoformat(created_at_raw.replace("Z", "+00:00")) + if isinstance(created_at_raw, str) + else datetime.now() ) - return list(result.scalars().all()) - -async def delete_bookmark(db: AsyncSession, bookmark_id: UUID, user_id: UUID) -> bool: - result = await db.execute( - select(Bookmark).where(Bookmark.id == bookmark_id, Bookmark.user_id == user_id) + return BookmarkResponse( + id=str(raw["id"]), + ayah_key=f"{surah_number}:{verse_number}", + type=str(raw.get("type", "ayah")), + surah_number=surah_number, + surah_name=_get_surah_name(chapters, surah_number), + verse_number=verse_number, + group=raw.get("group"), + is_in_default_collection=bool(raw.get("isInDefaultCollection", True)), + is_reading=raw.get("isReading"), + collections_count=raw.get("collectionsCount"), + created_at=created_at, ) - bookmark = result.scalar_one_or_none() - if not bookmark: - return False - await db.delete(bookmark) - await db.commit() - return True -async def create_note( +async def list_bookmarks( db: AsyncSession, - user_id: UUID, - topic: str, - content: str, - verses: list[dict[str, Any]] | None = None, -) -> Note: - note = Note( - user_id=user_id, - topic=topic, - content=content, - verses=verses, + current_user: User, +) -> list[BookmarkResponse]: + access_token = await qf_service.get_valid_qf_access_token(db, current_user) + if access_token is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Connect Quran Foundation to use bookmarks", + ) + + settings = get_settings() + data = await qf_service.call_qf_api( + access_token, + "/auth/v1/bookmarks", + params={"type": "ayah", "first": 20, "mushafId": settings.QF_MUSHAF_ID}, ) - db.add(note) - await db.commit() - await db.refresh(note) - return note + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to fetch bookmarks from Quran Foundation", + ) -async def get_notes(db: AsyncSession, user_id: UUID) -> list[Note]: - result = await db.execute( - select(Note).where(Note.user_id == user_id).order_by(Note.created_at.desc()) - ) - return list(result.scalars().all()) + bookmarks = data.get("data", []) + if not isinstance(bookmarks, list): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Unexpected bookmarks response", + ) + + chapters = await qf_service.fetch_chapters() or {} + + normalized = [ + _normalize_qf_bookmark(b, chapters) + for b in bookmarks + if b.get("type") == "ayah" and b.get("verseNumber") is not None + ] + + if normalized: + verse_keys = [b.ayah_key for b in normalized] + verses = await qf_service.fetch_verses_by_keys( + verse_keys, settings.QF_MUSHAF_ID + ) + for b in normalized: + verse_data = verses.get(b.ayah_key, {}) + b.arabic_text = verse_data.get("arabic_text", "") + b.translation = verse_data.get("translation", "") + return normalized -async def update_note( + +async def create_bookmark( db: AsyncSession, - note_id: UUID, - user_id: UUID, - content: str | None = None, -) -> Note | None: - result = await db.execute( - select(Note).where(Note.id == note_id, Note.user_id == user_id) + current_user: User, + ayah_key: str, +) -> BookmarkResponse: + parts = ayah_key.strip().split(":") + if len(parts) != 2: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ayah_key must use surah:ayah format, for example 2:255", + ) + + try: + surah_number = int(parts[0]) + verse_number = int(parts[1]) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ayah_key must contain numeric surah and ayah values", + ) from exc + + if surah_number < 1 or verse_number < 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="ayah_key values must be positive numbers", + ) + + access_token = await qf_service.get_valid_qf_access_token(db, current_user) + if access_token is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Connect Quran Foundation to use bookmarks", + ) + + settings = get_settings() + data = await qf_service.call_qf_api( + access_token, + "/auth/v1/bookmarks", + method="POST", + json_body={ + "type": "ayah", + "key": surah_number, + "verseNumber": verse_number, + "mushafId": settings.QF_MUSHAF_ID, + }, ) - note = result.scalar_one_or_none() - if not note: - return None - if content: - note.content = content - note.updated_at = datetime.now(UTC).replace(tzinfo=None) - await db.commit() - await db.refresh(note) - return note + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to create bookmark on Quran Foundation", + ) + + verses = await qf_service.fetch_verses_by_keys([ayah_key], settings.QF_MUSHAF_ID) + verse_data = verses.get(ayah_key, {}) + chapters = await qf_service.fetch_chapters() or {} + + return BookmarkResponse( + id=str(data.get("data", {}).get("id", "")), + ayah_key=ayah_key, + type="ayah", + surah_number=surah_number, + surah_name=_get_surah_name(chapters, surah_number), + verse_number=verse_number, + created_at=datetime.now(), + arabic_text=verse_data.get("arabic_text", ""), + translation=verse_data.get("translation", ""), + ) -async def delete_note(db: AsyncSession, note_id: UUID, user_id: UUID) -> bool: - result = await db.execute( - select(Note).where(Note.id == note_id, Note.user_id == user_id) +async def delete_bookmark( + db: AsyncSession, + current_user: User, + bookmark_id: str, +) -> None: + access_token = await qf_service.get_valid_qf_access_token(db, current_user) + if access_token is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Connect Quran Foundation to use bookmarks", + ) + + data = await qf_service.call_qf_api( + access_token, + f"/auth/v1/collections/__default__/bookmarks/{bookmark_id}", + method="DELETE", ) - note = result.scalar_one_or_none() - if not note: - return False - await db.delete(note) - await db.commit() - return True + + logger.info("QF delete response: {}", data) + + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to delete bookmark from Quran Foundation", + ) diff --git a/apps/api/app/modules/notes/__init__.py b/apps/api/app/modules/notes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/notes/service.py b/apps/api/app/modules/notes/service.py new file mode 100644 index 0000000..d37bba8 --- /dev/null +++ b/apps/api/app/modules/notes/service.py @@ -0,0 +1,67 @@ +from datetime import UTC, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.note import Note + + +async def create_note( + db: AsyncSession, + user_id: UUID, + topic: str, + content: str, + verses: list[dict[str, Any]] | None = None, +) -> Note: + note = Note( + user_id=user_id, + topic=topic, + content=content, + verses=verses, + ) + db.add(note) + await db.commit() + await db.refresh(note) + return note + + +async def get_notes(db: AsyncSession, user_id: UUID) -> list[Note]: + result = await db.execute( + select(Note).where(Note.user_id == user_id).order_by(Note.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def update_note( + db: AsyncSession, + note_id: UUID, + user_id: UUID, + content: str | None = None, +) -> Note | None: + result = await db.execute( + select(Note).where(Note.id == note_id, Note.user_id == user_id) + ) + note = result.scalar_one_or_none() + if not note: + return None + + if content: + note.content = content + note.updated_at = datetime.now(UTC).replace(tzinfo=None) + await db.commit() + await db.refresh(note) + return note + + +async def delete_note(db: AsyncSession, note_id: UUID, user_id: UUID) -> bool: + result = await db.execute( + select(Note).where(Note.id == note_id, Note.user_id == user_id) + ) + note = result.scalar_one_or_none() + if not note: + return False + await db.delete(note) + await db.commit() + return True diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index e9c13ac..ebf3ff8 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -5,6 +5,10 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function removeSuperscriptTags(text: string): string { + return text.replace(/]*>.*?<\/sup>/g, ""); +} + interface StaticVerse { text: string; reference: string; diff --git a/apps/web/src/modules/bookmarks/data/mutations.ts b/apps/web/src/modules/bookmarks/data/mutations.ts index 43c5664..28be407 100644 --- a/apps/web/src/modules/bookmarks/data/mutations.ts +++ b/apps/web/src/modules/bookmarks/data/mutations.ts @@ -5,23 +5,19 @@ import { api, queryKeys } from "@/lib/api"; type BookmarkResponse = components["schemas"]["BookmarkResponse"]; type BookmarkCreate = components["schemas"]["BookmarkCreate"]; -type NoteResponse = components["schemas"]["NoteResponse"]; -type NoteCreate = components["schemas"]["NoteCreate"]; +type BookmarkListResponse = components["schemas"]["BookmarkListResponse"]; export function useCreateBookmark() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { - const res = await api.bookmarks.create({ - ...body, - note: body.note ?? undefined, - extra_data: body.extra_data ?? undefined, - }); + const res = await api.bookmarks.create(body); if (res.error) throw new Error("Failed to create bookmark"); return res.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.bookmarks.all }); + toast.success("Bookmark saved"); }, onError: (error) => { toast.error(error.message); @@ -31,50 +27,39 @@ export function useCreateBookmark() { export function useDeleteBookmark() { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + void, + Error, + string, + { previous: BookmarkListResponse | undefined } + >({ mutationFn: async (id) => { - await api.bookmarks.delete(id); + const res = await api.bookmarks.delete(id); + if (res.error) throw new Error("Failed to delete bookmark"); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.bookmarks.all }); - }, - onError: (error) => { - toast.error(error.message); - }, - }); -} - -export function useCreateNote() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (body) => { - const res = await api.bookmarks.createNote({ - ...body, - verses: body.verses ?? undefined, - }); - if (res.error) throw new Error("Failed to create note"); - return res.data; + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: queryKeys.bookmarks.all }); + const previous = queryClient.getQueryData( + queryKeys.bookmarks.all + ); + if (previous) { + queryClient.setQueryData( + queryKeys.bookmarks.all, + { + bookmarks: previous.bookmarks.filter((b) => b.id !== id), + } + ); + } + return { previous }; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); - }, - onError: (error) => { + onError: (error, _id, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.bookmarks.all, context.previous); + } toast.error(error.message); }, - }); -} - -export function useDeleteNote() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (id) => { - await api.bookmarks.deleteNote(id); - }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); - }, - onError: (error) => { - toast.error(error.message); + toast.success("Bookmark removed"); }, }); } diff --git a/apps/web/src/modules/bookmarks/data/queries.ts b/apps/web/src/modules/bookmarks/data/queries.ts index c2dd289..da52265 100644 --- a/apps/web/src/modules/bookmarks/data/queries.ts +++ b/apps/web/src/modules/bookmarks/data/queries.ts @@ -4,7 +4,6 @@ import { api, queryKeys } from "@/lib/api"; import { useIsLoggedIn } from "@/modules/auth/stores/auth-store"; type BookmarkListResponse = components["schemas"]["BookmarkListResponse"]; -type NoteListResponse = components["schemas"]["NoteListResponse"]; export function useBookmarks() { const isLoggedIn = useIsLoggedIn(); @@ -18,16 +17,3 @@ export function useBookmarks() { enabled: isLoggedIn, }); } - -export function useNotes() { - const isLoggedIn = useIsLoggedIn(); - return useQuery({ - queryKey: queryKeys.notes.all, - queryFn: async () => { - const res = await api.bookmarks.listNotes(); - if (res.error) throw new Error("Failed to fetch notes"); - return res.data; - }, - enabled: isLoggedIn, - }); -} diff --git a/apps/web/src/modules/notes/data/mutations.ts b/apps/web/src/modules/notes/data/mutations.ts new file mode 100644 index 0000000..394d89e --- /dev/null +++ b/apps/web/src/modules/notes/data/mutations.ts @@ -0,0 +1,39 @@ +import type { components } from "@repo/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api, queryKeys } from "@/lib/api"; + +type NoteResponse = components["schemas"]["NoteResponse"]; +type NoteCreate = components["schemas"]["NoteCreate"]; + +export function useCreateNote() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const res = await api.notes.create(body); + if (res.error) throw new Error("Failed to create note"); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} + +export function useDeleteNote() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id) => { + await api.notes.delete(id); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/apps/web/src/modules/notes/data/queries.ts b/apps/web/src/modules/notes/data/queries.ts new file mode 100644 index 0000000..83c17f0 --- /dev/null +++ b/apps/web/src/modules/notes/data/queries.ts @@ -0,0 +1,19 @@ +import type { components } from "@repo/core"; +import { useQuery } from "@tanstack/react-query"; +import { api, queryKeys } from "@/lib/api"; +import { useIsLoggedIn } from "@/modules/auth/stores/auth-store"; + +type NoteListResponse = components["schemas"]["NoteListResponse"]; + +export function useNotes() { + const isLoggedIn = useIsLoggedIn(); + return useQuery({ + queryKey: queryKeys.notes.all, + queryFn: async () => { + const res = await api.notes.list(); + if (res.error) throw new Error("Failed to fetch notes"); + return res.data; + }, + enabled: isLoggedIn, + }); +} diff --git a/apps/web/src/modules/search/components/verse-card.tsx b/apps/web/src/modules/search/components/verse-card.tsx index dd2b66f..447d944 100644 --- a/apps/web/src/modules/search/components/verse-card.tsx +++ b/apps/web/src/modules/search/components/verse-card.tsx @@ -101,12 +101,7 @@ export function VerseCard({ } try { - await createBookmark.mutateAsync({ - ayah_key: verse.ayah_key, - surah_name: verse.surah_name, - arabic_text: verse.arabic_text, - translation: verse.translation, - }); + await createBookmark.mutateAsync({ ayah_key: verse.ayah_key }); setSaved(true); } catch { /* silently ignore duplicate / network errors for now */ @@ -206,7 +201,7 @@ export function VerseCard({ ? "Saving…" : isCheckingBookmark ? "Checking…" - : "Save Verse"} + : "Bookmark"} diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 797725c..343ec3a 100644 --- a/apps/web/src/routes/bookmarks.tsx +++ b/apps/web/src/routes/bookmarks.tsx @@ -1,7 +1,7 @@ import type { components } from "@repo/core"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { Bookmark as BookmarkIcon, ChevronLeft } from "lucide-react"; +import { BookmarkCheck, ChevronLeft } from "lucide-react"; import { motion } from "motion/react"; import { useState } from "react"; @@ -20,6 +20,7 @@ import { import { Button } from "@/components/ui/button"; import { queryKeys } from "@/lib/api"; import { duration, easing, variants } from "@/lib/motions"; +import { removeSuperscriptTags } from "@/lib/utils"; import { LoginDrawer } from "@/modules/auth/components/login-drawer"; import { useAuth } from "@/modules/auth/hooks/use-auth"; import { useDeleteBookmark } from "@/modules/bookmarks/data/mutations"; @@ -36,21 +37,16 @@ type Bookmark = components["schemas"]["BookmarkResponse"]; function BookmarksPage() { const [loginSheetOpen, setLoginSheetOpen] = useState(false); - const auth = useAuth(); const queryClient = useQueryClient(); - - const isLoggedIn = auth.isLoggedIn || Boolean(auth.accessToken); - const bookmarks = useBookmarks(); const deleteBookmark = useDeleteBookmark(); - function handleDeleteBookmark(id: string) { - deleteBookmark.mutate(id); - } + const isLoggedIn = auth.isLoggedIn || Boolean(auth.accessToken); async function handleLoginSuccess() { queryClient.invalidateQueries({ queryKey: queryKeys.me }); + queryClient.invalidateQueries({ queryKey: queryKeys.bookmarks.all }); setLoginSheetOpen(false); } @@ -62,13 +58,12 @@ function BookmarksPage() { transition={{ duration: duration.normal, ease: easing.out }} className="mt-[20svh] flex flex-col items-center justify-center gap-4 text-center" > - +

- Sign in to view your saved verses + Sign in to view your bookmarks

- Save your favorite Quranic verses to access them anytime, even - offline. + Save your favorite Quranic verses to access them anytime.

Your Bookmarks

- Your saved Quranic verses from searches. + Verses saved to your Quran Foundation Favorites.

- {isLoading && ( + {bookmarks.isLoading && (

Loading...

)} - {isError && ( -
-

- Failed to load bookmarks. Please try again. + {bookmarks.isError && ( +

+

+ Failed to load bookmarks. Connect Quran Foundation again if this + account was created with another sign-in method.

+ +
)} - {!isLoading && !isError && ( + {!bookmarks.isLoading && !bookmarks.isError && ( deleteBookmark.mutate(id)} /> )} @@ -139,12 +141,12 @@ function BookmarksPage() { function BookmarksList({ bookmarks, - onDelete, isDeleting, + onDelete, }: { bookmarks: Bookmark[]; - onDelete: (id: string) => void; isDeleting: boolean; + onDelete: (id: string) => void; }) { if (bookmarks.length === 0) { return ( @@ -156,7 +158,7 @@ function BookmarksList({ >

No bookmarks yet.

- Save verses from your searches to see them here. + Save verses from your search results to see them here.

); @@ -170,77 +172,98 @@ function BookmarksList({ className="space-y-4" > {bookmarks.map((bookmark) => ( - -
-

- {bookmark.surah_name}{" "} - {bookmark.ayah_key} -

- - - } - > - Delete - - - - - Delete {bookmark.surah_name} {bookmark.ayah_key} - - - Are you sure you want to delete this from your bookmarks? - action cannot be undone. - - - - Cancel - onDelete(bookmark.id)} - > - Delete - - - - -
-

- {bookmark.arabic_text} -

-

- {bookmark.translation} -

-

- Saved on:{" "} - - {new Date(bookmark.created_at).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - timeZone: "UTC", - hour12: false, - })} - -

- {bookmark.note && ( -

- {bookmark.note} -

- )} -
+ bookmark={bookmark} + isDeleting={isDeleting} + onDelete={onDelete} + /> ))} ); } + +function BookmarkCard({ + bookmark, + isDeleting, + onDelete, +}: { + bookmark: Bookmark; + isDeleting: boolean; + onDelete: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( + +
+

+ {bookmark.surah_name}{" "} + + ({bookmark.ayah_key}) + +

+ + + + } + > + Delete + + + + + Delete bookmark {bookmark.ayah_key} + + + This removes the verse from your bookmarks. + + + + Cancel + { + onDelete(bookmark.id); + setOpen(false); + }} + > + Delete + + + + +
+ + {bookmark.arabic_text && ( +

+ {bookmark.arabic_text} +

+ )} + + {bookmark.translation && ( +

+ {removeSuperscriptTags(bookmark.translation)} +

+ )} + +

+ Saved on:{" "} + {new Date(bookmark.created_at).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + hour12: false, + })} +

+
+ ); +} diff --git a/apps/web/src/routes/search.$slug.tsx b/apps/web/src/routes/search.$slug.tsx index 7485920..ca47740 100644 --- a/apps/web/src/routes/search.$slug.tsx +++ b/apps/web/src/routes/search.$slug.tsx @@ -1,4 +1,3 @@ -import type { components } from "@repo/core"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { motion } from "motion/react"; @@ -28,8 +27,6 @@ export const Route = createFileRoute("/search/$slug")({ }), }); -type VerseResult = components["schemas"]["VerseResult"]; - function SearchPage() { const { slug } = Route.useParams(); const [loginSheetOpen, setLoginSheetOpen] = useState(false); @@ -63,23 +60,14 @@ function SearchPage() { async function handleLoginSuccess() { queryClient.invalidateQueries({ queryKey: queryKeys.me }); + queryClient.invalidateQueries({ queryKey: queryKeys.bookmarks.all }); setLoginSheetOpen(false); if (!pendingSaveAyah) return; - const verse = verseResults.find( - (item): item is VerseResult => item.ayah_key === pendingSaveAyah - ); setPendingSaveAyah(null); - if (!verse) return; - - await createBookmark.mutateAsync({ - ayah_key: verse.ayah_key, - surah_name: verse.surah_name, - arabic_text: verse.arabic_text, - translation: verse.translation, - }); + await createBookmark.mutateAsync({ ayah_key: pendingSaveAyah }); } return ( diff --git a/packages/core/src/api/bookmarks.ts b/packages/core/src/api/bookmarks.ts index 347fec5..8defe8a 100644 --- a/packages/core/src/api/bookmarks.ts +++ b/packages/core/src/api/bookmarks.ts @@ -3,38 +3,12 @@ import { createApi } from "@/client"; type Client = ReturnType; export const createBookmarksApi = (client: Client) => ({ - create: (body: { - ayah_key: string; - surah_name: string; - arabic_text: string; - translation: string; - note?: string; - extra_data?: Record; - }) => client.POST("/bookmarks", { body }), - list: () => client.GET("/bookmarks"), + create: (body: { ayah_key: string }) => client.POST("/bookmarks", { body }), + delete: (id: string) => client.DELETE("/bookmarks/{bookmark_id}", { params: { path: { bookmark_id: id } }, }), - - createNote: (body: { - topic: string; - content: string; - verses?: Record[]; - }) => client.POST("/bookmarks/notes", { body }), - - listNotes: () => client.GET("/bookmarks/notes"), - - updateNote: (id: string, content: string) => - client.PATCH("/bookmarks/notes/{note_id}", { - params: { path: { note_id: id } }, - body: content, - }), - - deleteNote: (id: string) => - client.DELETE("/bookmarks/notes/{note_id}", { - params: { path: { note_id: id } }, - }), }); diff --git a/packages/core/src/api/notes.ts b/packages/core/src/api/notes.ts new file mode 100644 index 0000000..7ef3f08 --- /dev/null +++ b/packages/core/src/api/notes.ts @@ -0,0 +1,24 @@ +import { createApi } from "@/client"; + +type Client = ReturnType; + +export const createNotesApi = (client: Client) => ({ + create: (body: { + topic: string; + content: string; + verses?: Record[] | null; + }) => client.POST("/notes", { body }), + + list: () => client.GET("/notes"), + + update: (id: string, content: string) => + client.PATCH("/notes/{note_id}", { + params: { path: { note_id: id } }, + body: content, + }), + + delete: (id: string) => + client.DELETE("/notes/{note_id}", { + params: { path: { note_id: id } }, + }), +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b92b861..43ca370 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ import { createAuthApi } from "./api/auth"; import { createBookmarksApi } from "./api/bookmarks"; +import { createNotesApi } from "./api/notes"; import { createSearchApi } from "./api/search"; import { createUsersApi } from "./api/users"; import { createApi } from "./client"; @@ -18,6 +19,7 @@ export const createApiWithModules = ( auth: createAuthApi(client), search: createSearchApi(client), bookmarks: createBookmarksApi(client), + notes: createNotesApi(client), users: createUsersApi(client), }; }; diff --git a/packages/core/src/schema.d.ts b/packages/core/src/schema.d.ts index a619a59..31cd5aa 100644 --- a/packages/core/src/schema.d.ts +++ b/packages/core/src/schema.d.ts @@ -277,25 +277,25 @@ export interface paths { patch?: never; trace?: never; }; - "/bookmarks": { + "/notes": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Bookmarks */ - get: operations["get_bookmarks_bookmarks_get"]; + /** Get Notes */ + get: operations["get_notes_notes_get"]; put?: never; - /** Create Bookmark */ - post: operations["create_bookmark_bookmarks_post"]; + /** Create Note */ + post: operations["create_note_notes_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bookmarks/{bookmark_id}": { + "/notes/{note_id}": { parameters: { query?: never; header?: never; @@ -305,32 +305,33 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete Bookmark */ - delete: operations["delete_bookmark_bookmarks__bookmark_id__delete"]; + /** Delete Note */ + delete: operations["delete_note_notes__note_id__delete"]; options?: never; head?: never; - patch?: never; + /** Update Note */ + patch: operations["update_note_notes__note_id__patch"]; trace?: never; }; - "/bookmarks/notes": { + "/bookmarks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Notes */ - get: operations["get_notes_bookmarks_notes_get"]; + /** Get Bookmarks */ + get: operations["get_bookmarks_bookmarks_get"]; put?: never; - /** Create Note */ - post: operations["create_note_bookmarks_notes_post"]; + /** Create Bookmark */ + post: operations["create_bookmark_bookmarks_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bookmarks/notes/{note_id}": { + "/bookmarks/{bookmark_id}": { parameters: { query?: never; header?: never; @@ -340,12 +341,11 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete Note */ - delete: operations["delete_note_bookmarks_notes__note_id__delete"]; + /** Delete Bookmark */ + delete: operations["delete_bookmark_bookmarks__bookmark_id__delete"]; options?: never; head?: never; - /** Update Note */ - patch: operations["update_note_bookmarks_notes__note_id__patch"]; + patch?: never; trace?: never; }; } @@ -356,18 +356,6 @@ export interface components { BookmarkCreate: { /** Ayah Key */ ayah_key: string; - /** Surah Name */ - surah_name: string; - /** Arabic Text */ - arabic_text: string; - /** Translation */ - translation: string; - /** Note */ - note?: string | null; - /** Extra Data */ - extra_data?: { - [key: string]: unknown; - } | null; }; /** BookmarkListResponse */ BookmarkListResponse: { @@ -376,30 +364,44 @@ export interface components { }; /** BookmarkResponse */ BookmarkResponse: { - /** - * Id - * Format: uuid - */ + /** Id */ id: string; /** Ayah Key */ ayah_key: string; + /** Type */ + type: string; + /** Surah Number */ + surah_number: number; /** Surah Name */ surah_name: string; - /** Arabic Text */ - arabic_text: string; - /** Translation */ - translation: string; - /** Note */ - note?: string | null; - /** Extra Data */ - extra_data?: { - [key: string]: unknown; - } | null; + /** Verse Number */ + verse_number: number; + /** Group */ + group?: string | null; + /** + * Is In Default Collection + * @default true + */ + is_in_default_collection: boolean; + /** Is Reading */ + is_reading?: boolean | null; + /** Collections Count */ + collections_count?: number | null; /** * Created At * Format: date-time */ created_at: string; + /** + * Arabic Text + * @default + */ + arabic_text: string; + /** + * Translation + * @default + */ + translation: string; }; /** GoogleAccessTokenRequest */ GoogleAccessTokenRequest: { @@ -1113,7 +1115,7 @@ export interface operations { }; }; }; - get_bookmarks_bookmarks_get: { + get_notes_notes_get: { parameters: { query?: never; header?: never; @@ -1128,12 +1130,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BookmarkListResponse"]; + "application/json": components["schemas"]["NoteListResponse"]; }; }; }; }; - create_bookmark_bookmarks_post: { + create_note_notes_post: { parameters: { query?: never; header?: never; @@ -1142,7 +1144,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["BookmarkCreate"]; + "application/json": components["schemas"]["NoteCreate"]; }; }; responses: { @@ -1152,7 +1154,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BookmarkResponse"]; + "application/json": components["schemas"]["NoteResponse"]; }; }; /** @description Validation Error */ @@ -1166,12 +1168,12 @@ export interface operations { }; }; }; - delete_bookmark_bookmarks__bookmark_id__delete: { + delete_note_notes__note_id__delete: { parameters: { query?: never; header?: never; path: { - bookmark_id: string; + note_id: string; }; cookie?: never; }; @@ -1195,14 +1197,20 @@ export interface operations { }; }; }; - get_notes_bookmarks_notes_get: { + update_note_notes__note_id__patch: { parameters: { query?: never; header?: never; - path?: never; + path: { + note_id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": string; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -1210,61 +1218,61 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["NoteListResponse"]; + "application/json": components["schemas"]["NoteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - create_note_bookmarks_notes_post: { + get_bookmarks_bookmarks_get: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["NoteCreate"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["NoteResponse"]; - }; - }; - /** @description Validation Error */ - 422: { + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HTTPValidationError"]; + "application/json": components["schemas"]["BookmarkListResponse"]; }; }; }; }; - delete_note_bookmarks_notes__note_id__delete: { + create_bookmark_bookmarks_post: { parameters: { query?: never; header?: never; - path: { - note_id: string; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["BookmarkCreate"]; + }; + }; responses: { /** @description Successful Response */ - 204: { + 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["BookmarkResponse"]; + }; }; /** @description Validation Error */ 422: { @@ -1277,29 +1285,23 @@ export interface operations { }; }; }; - update_note_bookmarks_notes__note_id__patch: { + delete_bookmark_bookmarks__bookmark_id__delete: { parameters: { query?: never; header?: never; path: { - note_id: string; + bookmark_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": string; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ - 200: { + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["NoteResponse"]; - }; + content?: never; }; /** @description Validation Error */ 422: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5b95c2..c9e95f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,19 +55,19 @@ importers: version: 4.2.2(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) '@tanstack/react-devtools': specifier: latest - version: 0.10.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.12) + version: 0.10.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.12) '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.4) '@tanstack/react-router': specifier: latest - version: 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: latest - version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.167.0(@tanstack/react-router@1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.171.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: ^1.132.0 - version: 1.167.12(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) + version: 1.167.12(@tanstack/react-router@1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -140,7 +140,7 @@ importers: version: 0.5.19(tailwindcss@4.2.2) '@tanstack/devtools-vite': specifier: latest - version: 0.6.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) + version: 0.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) '@types/node': specifier: ^22.10.2 version: 22.19.17 @@ -883,6 +883,15 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -1147,6 +1156,12 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -1180,6 +1195,128 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-parser/binding-android-arm-eabi@0.120.0': + resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.120.0': + resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.120.0': + resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.120.0': + resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.120.0': + resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.120.0': + resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.120.0': + resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.120.0': + resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1700,21 +1837,21 @@ packages: engines: {node: '>=18'} hasBin: true - '@tanstack/devtools-ui@0.5.1': - resolution: {integrity: sha512-T9JjAdqMSnxsVO6AQykD5vhxPF4iFLKtbYxee/bU3OLlk446F5C1220GdCmhDSz7y4lx+m8AvIS0bq6zzvdDUA==} + '@tanstack/devtools-ui@0.5.2': + resolution: {integrity: sha512-GtaMk8kaGZ9ZdR8Pu5RAfcse/ZrxzH/xsAIFtHMapLs2VMqSPFfb1NvIDO1MAAfUcub8Ix8XKQEP0uYSPzoFKw==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools-vite@0.6.0': - resolution: {integrity: sha512-h0r0ct7zlrgjkhmn4QW6wRjgUXd4JMs+r7gtx+BXo9f5H9Y+jtUdtvC0rnZcPto6gw/9yMUq7yOmMK5qDWRExg==} + '@tanstack/devtools-vite@0.7.0': + resolution: {integrity: sha512-VXki7K+Xwnpo3IKdNSWGe7YOvtZv33YlulGqaQ+YCpeQhYg8JFuxP50BXibDoRLj5EOX4r21Hs7COdxbRHXkTw==} engines: {node: '>=18'} hasBin: true peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@tanstack/devtools@0.12.0': - resolution: {integrity: sha512-KiRMl8lFTTpNEOU4kt9mdZqQ8mTVEPwRLE8I1g3IJe5y1WpOhwNUU1E5vCq8VUsWJukszI6QmPbMs4Gw+C3cyQ==} + '@tanstack/devtools@0.12.2': + resolution: {integrity: sha512-Xdl8pLzoDUvXaclQ0poY36WAPx0jEHk8vqUFd8FYFUm1BMshtB7RnTgD1HE9jCAXODxqw9I0gXBiUZLK3o3+Bw==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -1724,11 +1861,15 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + '@tanstack/query-core@5.96.2': resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==} - '@tanstack/react-devtools@0.10.3': - resolution: {integrity: sha512-yUCoG7GwnnDb/aaXnimPUTqES7ICMO9LwII27f4RVxFKRS8z7ePqKIGVpQhVu/L0pvArJ8SBAWL8fxuUE1HYqQ==} + '@tanstack/react-devtools@0.10.5': + resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -1741,20 +1882,20 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.13': - resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} + '@tanstack/react-router-devtools@1.167.0': + resolution: {integrity: sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.168.15 - '@tanstack/router-core': ^1.168.11 + '@tanstack/react-router': ^1.170.0 + '@tanstack/router-core': ^1.170.0 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.169.2': - resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} + '@tanstack/react-router@1.170.4': + resolution: {integrity: sha512-cusL4YCTuGGJhjfsXEBm6/SmOAs/G8wRVNadeyN3ofu4OZwX69KAybBEf217buxYzI+FohdJVoigEpJV+tGzIw==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1771,15 +1912,15 @@ packages: engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-core@1.169.2': - resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + '@tanstack/router-core@1.171.2': + resolution: {integrity: sha512-sUd+BhGYkBF64LVhmOHnYsc1AutPNch/huohEXiXL4IUgmk17Gy+RkUazvjQhptVdYW5QT+qtATrUr2cQZNHFA==} engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.167.3': - resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} + '@tanstack/router-devtools-core@1.168.0': + resolution: {integrity: sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.168.11 + '@tanstack/router-core': ^1.170.0 csstype: ^3.0.10 peerDependenciesMeta: csstype: @@ -1826,6 +1967,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3358,6 +3502,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.120.0: + resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} + engines: {node: ^20.19.0 || >=22.12.0} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5271,6 +5419,22 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -5465,6 +5629,13 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -5494,6 +5665,73 @@ snapshots: '@open-draft/until@2.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.120.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.120.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.120.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.120.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.120.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.120.0': + optional: true + + '@oxc-project/types@0.120.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': @@ -5929,7 +6167,7 @@ snapshots: '@tanstack/devtools-event-client@0.4.3': {} - '@tanstack/devtools-ui@0.5.1(csstype@3.2.3)(solid-js@1.9.12)': + '@tanstack/devtools-ui@0.5.2(csstype@3.2.3)(solid-js@1.9.12)': dependencies: clsx: 2.1.1 dayjs: 1.11.20 @@ -5938,32 +6176,30 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.6.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 '@tanstack/devtools-client': 0.0.6 '@tanstack/devtools-event-bus': 0.4.1 chalk: 5.6.2 launch-editor: 2.13.2 + magic-string: 0.30.21 + oxc-parser: 0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picomatch: 4.0.4 vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - bufferutil - - supports-color - utf-8-validate - '@tanstack/devtools@0.12.0(csstype@3.2.3)(solid-js@1.9.12)': + '@tanstack/devtools@0.12.2(csstype@3.2.3)(solid-js@1.9.12)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.12) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.12) '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.12) '@tanstack/devtools-client': 0.0.6 '@tanstack/devtools-event-bus': 0.4.1 - '@tanstack/devtools-ui': 0.5.1(csstype@3.2.3)(solid-js@1.9.12) + '@tanstack/devtools-ui': 0.5.2(csstype@3.2.3)(solid-js@1.9.12) clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.12 @@ -5974,11 +6210,13 @@ snapshots: '@tanstack/history@1.161.6': {} + '@tanstack/history@1.162.0': {} + '@tanstack/query-core@5.96.2': {} - '@tanstack/react-devtools@0.10.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.12)': + '@tanstack/react-devtools@0.10.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.12)': dependencies: - '@tanstack/devtools': 0.12.0(csstype@3.2.3)(solid-js@1.9.12) + '@tanstack/devtools': 0.12.2(csstype@3.2.3)(solid-js@1.9.12) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) react: 19.2.4 @@ -5994,22 +6232,22 @@ snapshots: '@tanstack/query-core': 5.96.2 react: 19.2.4 - '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.171.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3) + '@tanstack/react-router': 1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.2)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.169.2 + '@tanstack/router-core': 1.171.2 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/history': 1.161.6 + '@tanstack/history': 1.162.0 '@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.169.2 + '@tanstack/router-core': 1.171.2 isbot: 5.1.37 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -6028,16 +6266,16 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - '@tanstack/router-core@1.169.2': + '@tanstack/router-core@1.171.2': dependencies: - '@tanstack/history': 1.161.6 + '@tanstack/history': 1.162.0 cookie-es: 3.1.1 seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.2)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.169.2 + '@tanstack/router-core': 1.171.2 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -6056,7 +6294,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.12(@tanstack/react-router@1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -6072,7 +6310,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.169.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.170.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -6101,6 +6339,11 @@ snapshots: minimatch: 10.2.5 path-browserify: 1.0.1 + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -7620,6 +7863,34 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.120.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.120.0 + '@oxc-parser/binding-android-arm64': 0.120.0 + '@oxc-parser/binding-darwin-arm64': 0.120.0 + '@oxc-parser/binding-darwin-x64': 0.120.0 + '@oxc-parser/binding-freebsd-x64': 0.120.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 + '@oxc-parser/binding-linux-arm64-musl': 0.120.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-gnu': 0.120.0 + '@oxc-parser/binding-linux-x64-musl': 0.120.0 + '@oxc-parser/binding-openharmony-arm64': 0.120.0 + '@oxc-parser/binding-wasm32-wasi': 0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 + '@oxc-parser/binding-win32-x64-msvc': 0.120.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + package-json-from-dist@1.0.1: {} parent-module@1.0.1: