From 34d5244213b2b63eb16fc26fd51497f8c18e27d2 Mon Sep 17 00:00:00 2001 From: up2dul Date: Tue, 19 May 2026 10:59:41 +0700 Subject: [PATCH 1/6] feat(qf-bookmarks): add quran foundation bookmark flow --- .env.example | 1 + apps/api/app/api/qf_bookmarks/__init__.py | 1 + apps/api/app/api/qf_bookmarks/router.py | 50 ++++ apps/api/app/api/qf_bookmarks/serializer.py | 24 ++ apps/api/app/core/settings.py | 1 + apps/api/app/main.py | 2 + apps/api/app/modules/auth/qf_service.py | 16 +- apps/api/app/modules/qf_bookmarks/__init__.py | 1 + apps/api/app/modules/qf_bookmarks/service.py | 166 +++++++++++ apps/web/src/components/layout/header.tsx | 12 + apps/web/src/lib/api.ts | 3 + .../modules/qf-bookmarks/data/mutations.ts | 42 +++ .../src/modules/qf-bookmarks/data/queries.ts | 19 ++ .../modules/search/components/verse-card.tsx | 61 ++++- apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/qf-bookmarks.tsx | 258 ++++++++++++++++++ packages/core/src/api/qf-bookmarks.ts | 15 + packages/core/src/index.ts | 2 + packages/core/src/schema.d.ts | 156 +++++++++++ 19 files changed, 847 insertions(+), 4 deletions(-) create mode 100644 apps/api/app/api/qf_bookmarks/__init__.py create mode 100644 apps/api/app/api/qf_bookmarks/router.py create mode 100644 apps/api/app/api/qf_bookmarks/serializer.py create mode 100644 apps/api/app/modules/qf_bookmarks/__init__.py create mode 100644 apps/api/app/modules/qf_bookmarks/service.py create mode 100644 apps/web/src/modules/qf-bookmarks/data/mutations.ts create mode 100644 apps/web/src/modules/qf-bookmarks/data/queries.ts create mode 100644 apps/web/src/routes/qf-bookmarks.tsx create mode 100644 packages/core/src/api/qf-bookmarks.ts 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/app/api/qf_bookmarks/__init__.py b/apps/api/app/api/qf_bookmarks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/app/api/qf_bookmarks/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/app/api/qf_bookmarks/router.py b/apps/api/app/api/qf_bookmarks/router.py new file mode 100644 index 0000000..366e993 --- /dev/null +++ b/apps/api/app/api/qf_bookmarks/router.py @@ -0,0 +1,50 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth.router import get_current_user +from app.api.qf_bookmarks.serializer import ( + QfBookmarkCreate, + QfBookmarkListResponse, + QfBookmarkResponse, +) +from app.core.database import get_db +from app.models.user import User +from app.modules.qf_bookmarks import service + +router = APIRouter(prefix="/qf-bookmarks", tags=["qf-bookmarks"]) + +CurrentUserDep = Annotated[User, Depends(get_current_user)] +DbDep = Annotated[AsyncSession, Depends(get_db)] + + +@router.get("", response_model=QfBookmarkListResponse) +async def get_qf_bookmarks( + db: DbDep, + current_user: CurrentUserDep, +) -> QfBookmarkListResponse: + bookmarks = await service.list_qf_bookmarks(db, current_user) + return QfBookmarkListResponse(bookmarks=bookmarks) + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=QfBookmarkResponse, +) +async def create_qf_bookmark( + body: QfBookmarkCreate, + db: DbDep, + current_user: CurrentUserDep, +) -> QfBookmarkResponse: + return await service.create_qf_bookmark(db, current_user, body.ayah_key) + + +@router.delete("/{bookmark_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_qf_bookmark( + bookmark_id: str, + db: DbDep, + current_user: CurrentUserDep, +) -> None: + await service.delete_qf_bookmark(db, current_user, bookmark_id) diff --git a/apps/api/app/api/qf_bookmarks/serializer.py b/apps/api/app/api/qf_bookmarks/serializer.py new file mode 100644 index 0000000..d4a2934 --- /dev/null +++ b/apps/api/app/api/qf_bookmarks/serializer.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class QfBookmarkCreate(BaseModel): + ayah_key: str + + +class QfBookmarkResponse(BaseModel): + id: str + ayah_key: str + type: str + surah_number: int + 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 + + +class QfBookmarkListResponse(BaseModel): + bookmarks: list[QfBookmarkResponse] 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..80cc8d8 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.qf_bookmarks.router import router as qf_bookmarks_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 @@ -66,6 +67,7 @@ async def health(): app.include_router(users_router) app.include_router(search_router) app.include_router(bookmarks_router) +app.include_router(qf_bookmarks_router) @app.get("/scalar", include_in_schema=False) diff --git a/apps/api/app/modules/auth/qf_service.py b/apps/api/app/modules/auth/qf_service.py index 57a37de..56f1ea3 100644 --- a/apps/api/app/modules/auth/qf_service.py +++ b/apps/api/app/modules/auth/qf_service.py @@ -152,12 +152,22 @@ 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"], @@ -166,7 +176,7 @@ async def call_qf_api(access_token: str, path: str) -> dict[str, Any] | None: resp.raise_for_status() return resp.json() 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 diff --git a/apps/api/app/modules/qf_bookmarks/__init__.py b/apps/api/app/modules/qf_bookmarks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/app/modules/qf_bookmarks/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/app/modules/qf_bookmarks/service.py b/apps/api/app/modules/qf_bookmarks/service.py new file mode 100644 index 0000000..369d8f6 --- /dev/null +++ b/apps/api/app/modules/qf_bookmarks/service.py @@ -0,0 +1,166 @@ +from datetime import datetime +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.qf_bookmarks.serializer import QfBookmarkResponse +from app.core.settings import get_settings +from app.models.user import User +from app.modules.auth import qf_service + +DEFAULT_COLLECTION_ID = "__default__" + + +def parse_ayah_key(ayah_key: str) -> tuple[int, int]: + 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", + ) + + return surah_number, verse_number + + +async def get_qf_access_token(db: AsyncSession, current_user: User) -> str: + 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 QF bookmarks", + ) + return access_token + + +def normalize_qf_bookmark(raw: dict[str, Any]) -> QfBookmarkResponse: + 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 QfBookmarkResponse( + id=str(raw["id"]), + ayah_key=f"{surah_number}:{verse_number}", + type=str(raw.get("type", "ayah")), + surah_number=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, + ) + + +async def list_qf_bookmarks( + db: AsyncSession, + current_user: User, +) -> list[QfBookmarkResponse]: + access_token = await get_qf_access_token(db, current_user) + settings = get_settings() + data = await qf_service.call_qf_api( + access_token, + "/v1/bookmarks", + params={ + "type": "ayah", + "mushafId": settings.QF_MUSHAF_ID, + "first": 20, + }, + ) + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to fetch QF bookmarks", + ) + + bookmarks = data.get("data", []) + if not isinstance(bookmarks, list): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Unexpected QF bookmarks response", + ) + + return [ + normalize_qf_bookmark(bookmark) + for bookmark in bookmarks + if bookmark.get("type") == "ayah" + and bookmark.get("verseNumber") is not None + and bookmark.get("isInDefaultCollection", True) + ] + + +async def create_qf_bookmark( + db: AsyncSession, + current_user: User, + ayah_key: str, +) -> QfBookmarkResponse: + surah_number, verse_number = parse_ayah_key(ayah_key) + access_token = await get_qf_access_token(db, current_user) + settings = get_settings() + data = await qf_service.call_qf_api( + access_token, + f"/v1/collections/{DEFAULT_COLLECTION_ID}/bookmarks", + method="POST", + json_body={ + "type": "ayah", + "key": surah_number, + "verseNumber": verse_number, + "mushafId": settings.QF_MUSHAF_ID, + }, + ) + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to create QF bookmark", + ) + + bookmarks = await list_qf_bookmarks(db, current_user) + for bookmark in bookmarks: + if ( + bookmark.surah_number == surah_number + and bookmark.verse_number == verse_number + ): + return bookmark + + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="QF bookmark was created but could not be read back", + ) + + +async def delete_qf_bookmark( + db: AsyncSession, + current_user: User, + bookmark_id: str, +) -> None: + access_token = await get_qf_access_token(db, current_user) + data = await qf_service.call_qf_api( + access_token, + f"/v1/collections/{DEFAULT_COLLECTION_ID}/bookmarks/{bookmark_id}", + method="DELETE", + ) + if data is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to delete QF bookmark", + ) diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index ba9c6ee..c812d60 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -79,6 +79,10 @@ export function Header() { nativeButton={false} render={Bookmarks} /> + QF Bookmarks} + /> {isLoggedIn ? ( Sign out} @@ -134,6 +138,14 @@ export function Header() { render={Bookmarks} /> +
  • +
  • {isLoggedIn ? ( diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a423735..049ce78 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -26,6 +26,9 @@ export const queryKeys = { bookmarks: { all: ["bookmarks"] as const, }, + qfBookmarks: { + all: ["qf-bookmarks"] as const, + }, notes: { all: ["notes"] as const, }, diff --git a/apps/web/src/modules/qf-bookmarks/data/mutations.ts b/apps/web/src/modules/qf-bookmarks/data/mutations.ts new file mode 100644 index 0000000..72cc92b --- /dev/null +++ b/apps/web/src/modules/qf-bookmarks/data/mutations.ts @@ -0,0 +1,42 @@ +import type { components } from "@repo/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { api, queryKeys } from "@/lib/api"; + +type QfBookmarkCreate = components["schemas"]["QfBookmarkCreate"]; +type QfBookmarkResponse = components["schemas"]["QfBookmarkResponse"]; + +export function useCreateQfBookmark() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const res = await api.qfBookmarks.create(body); + if (res.error) throw new Error("Failed to save QF bookmark"); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); + toast.success("Saved to Quran Foundation"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} + +export function useDeleteQfBookmark() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id) => { + const res = await api.qfBookmarks.delete(id); + if (res.error) throw new Error("Failed to delete QF bookmark"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); + toast.success("Removed from Quran Foundation"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/apps/web/src/modules/qf-bookmarks/data/queries.ts b/apps/web/src/modules/qf-bookmarks/data/queries.ts new file mode 100644 index 0000000..6d10604 --- /dev/null +++ b/apps/web/src/modules/qf-bookmarks/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 QfBookmarkListResponse = components["schemas"]["QfBookmarkListResponse"]; + +export function useQfBookmarks() { + const isLoggedIn = useIsLoggedIn(); + return useQuery({ + queryKey: queryKeys.qfBookmarks.all, + queryFn: async () => { + const res = await api.qfBookmarks.list(); + if (res.error) throw new Error("Failed to fetch QF bookmarks"); + 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..faba530 100644 --- a/apps/web/src/modules/search/components/verse-card.tsx +++ b/apps/web/src/modules/search/components/verse-card.tsx @@ -1,5 +1,5 @@ import type { components } from "@repo/core"; -import { BookOpenCheck, Share2 } from "lucide-react"; +import { BookOpenCheck, Cloud, Share2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useCopyToClipboard } from "react-use"; import { toast } from "sonner"; @@ -14,6 +14,8 @@ import { useBookmarks } from "@/modules/bookmarks/data/queries"; import { ReadingSettingsSidebar } from "@/modules/preferences/components/reading-settings-sidebar"; import { ARABIC_FONT_STACK } from "@/modules/preferences/lib/arabic-font-stacks"; import { useFontPreferencesStore } from "@/modules/preferences/stores/font-preferences-store"; +import { useCreateQfBookmark } from "@/modules/qf-bookmarks/data/mutations"; +import { useQfBookmarks } from "@/modules/qf-bookmarks/data/queries"; import { useExplainVerse, useVersePage } from "@/modules/search/data/queries"; type VerseResult = components["schemas"]["VerseResult"]; @@ -61,6 +63,8 @@ export function VerseCard({ const versePage = useVersePage(slug, rank + 1, true); const bookmarks = useBookmarks(); const createBookmark = useCreateBookmark(); + const qfBookmarks = useQfBookmarks(); + const createQfBookmark = useCreateQfBookmark(); const loadedVerse = versePage.data?.verse; const whyText = @@ -78,6 +82,14 @@ export function VerseCard({ ) )); const isCheckingBookmark = isLoggedIn && bookmarks.isPending; + const isAlreadyQfBookmarked = + isLoggedIn && + Boolean( + qfBookmarks.data?.bookmarks.some( + (bookmark) => bookmark.ayah_key === verse.ayah_key + ) + ); + const isCheckingQfBookmark = isLoggedIn && qfBookmarks.isPending; useEffect(() => { const explanation = explainVerse.data?.why_this_verse; @@ -113,6 +125,31 @@ export function VerseCard({ } } + async function handleQfSave() { + if (!isLoggedIn) { + onSaveRequest?.(verse.ayah_key); + return; + } + if ( + isAlreadyQfBookmarked || + isCheckingQfBookmark || + createQfBookmark.isPending + ) { + return; + } + + if (!navigator.onLine) { + toast.error("Sync when back online"); + return; + } + + try { + await createQfBookmark.mutateAsync({ ayah_key: verse.ayah_key }); + } catch { + /* mutation hook surfaces the error toast */ + } + } + function handleShare() { const text = `"${verse.translation}" — ${verse.surah_name} (${verse.ayah_key}) via Qalbwise`; if (typeof navigator.share === "function") { @@ -209,6 +246,28 @@ export function VerseCard({ : "Save Verse"} + diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 8d5f634..c0d616e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as TermsRouteImport } from './routes/terms' import { Route as QfCallbackRouteImport } from './routes/qf-callback' +import { Route as QfBookmarksRouteImport } from './routes/qf-bookmarks' import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as BookmarksRouteImport } from './routes/bookmarks' import { Route as IndexRouteImport } from './routes/index' @@ -26,6 +27,11 @@ const QfCallbackRoute = QfCallbackRouteImport.update({ path: '/qf-callback', getParentRoute: () => rootRouteImport, } as any) +const QfBookmarksRoute = QfBookmarksRouteImport.update({ + id: '/qf-bookmarks', + path: '/qf-bookmarks', + getParentRoute: () => rootRouteImport, +} as any) const PrivacyRoute = PrivacyRouteImport.update({ id: '/privacy', path: '/privacy', @@ -51,6 +57,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute + '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -59,6 +66,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute + '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -68,6 +76,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute + '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -78,6 +87,7 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' + | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -86,6 +96,7 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' + | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -94,6 +105,7 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' + | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -103,6 +115,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute BookmarksRoute: typeof BookmarksRoute PrivacyRoute: typeof PrivacyRoute + QfBookmarksRoute: typeof QfBookmarksRoute QfCallbackRoute: typeof QfCallbackRoute TermsRoute: typeof TermsRoute SearchSlugRoute: typeof SearchSlugRoute @@ -124,6 +137,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof QfCallbackRouteImport parentRoute: typeof rootRouteImport } + '/qf-bookmarks': { + id: '/qf-bookmarks' + path: '/qf-bookmarks' + fullPath: '/qf-bookmarks' + preLoaderRoute: typeof QfBookmarksRouteImport + parentRoute: typeof rootRouteImport + } '/privacy': { id: '/privacy' path: '/privacy' @@ -159,6 +179,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BookmarksRoute: BookmarksRoute, PrivacyRoute: PrivacyRoute, + QfBookmarksRoute: QfBookmarksRoute, QfCallbackRoute: QfCallbackRoute, TermsRoute: TermsRoute, SearchSlugRoute: SearchSlugRoute, diff --git a/apps/web/src/routes/qf-bookmarks.tsx b/apps/web/src/routes/qf-bookmarks.tsx new file mode 100644 index 0000000..3272b60 --- /dev/null +++ b/apps/web/src/routes/qf-bookmarks.tsx @@ -0,0 +1,258 @@ +import type { components } from "@repo/core"; +import { useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { BookmarkCheck, ChevronLeft, ExternalLink } from "lucide-react"; +import { motion } from "motion/react"; +import { useState } from "react"; + +import { Dots } from "@/components/loading-ui/dots"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { queryKeys } from "@/lib/api"; +import { duration, easing, variants } from "@/lib/motions"; +import { LoginDrawer } from "@/modules/auth/components/login-drawer"; +import { useAuth } from "@/modules/auth/hooks/use-auth"; +import { useDeleteQfBookmark } from "@/modules/qf-bookmarks/data/mutations"; +import { useQfBookmarks } from "@/modules/qf-bookmarks/data/queries"; + +export const Route = createFileRoute("/qf-bookmarks")({ + component: QfBookmarksPage, + head: () => ({ + meta: [{ title: "QF Bookmarks | Qalbwise" }], + }), +}); + +type QfBookmark = components["schemas"]["QfBookmarkResponse"]; + +function quranComEnUrl(ayahKey: string): string { + const parts = ayahKey.trim().split(":"); + if (parts.length !== 2) return "https://quran.com/en"; + return `https://quran.com/en/${parts[0]}/${parts[1]}`; +} + +function QfBookmarksPage() { + const [loginSheetOpen, setLoginSheetOpen] = useState(false); + const auth = useAuth(); + const queryClient = useQueryClient(); + const qfBookmarks = useQfBookmarks(); + const deleteQfBookmark = useDeleteQfBookmark(); + + const isLoggedIn = auth.isLoggedIn || Boolean(auth.accessToken); + + async function handleLoginSuccess() { + queryClient.invalidateQueries({ queryKey: queryKeys.me }); + queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); + setLoginSheetOpen(false); + } + + if (!isLoggedIn) { + return ( + + +

    Sign in with Quran Foundation

    +

    + View and manage verses saved to your Quran Foundation Favorites. +

    + + +
    + ); + } + + return ( + +
    +
    + + {qfBookmarks.isLoading && ( +
    + +

    Loading...

    +
    + )} + + {qfBookmarks.isError && ( +
    +

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

    + + +
    + )} + + {!qfBookmarks.isLoading && !qfBookmarks.isError && ( + deleteQfBookmark.mutate(id)} + /> + )} +
    + ); +} + +function QfBookmarksList({ + bookmarks, + isDeleting, + onDelete, +}: { + bookmarks: QfBookmark[]; + isDeleting: boolean; + onDelete: (id: string) => void; +}) { + if (bookmarks.length === 0) { + return ( + +

    No Quran Foundation bookmarks yet.

    +

    + Save verses to QF Favorites from your search results. +

    +
    + ); + } + + return ( + + {bookmarks.map((bookmark) => ( + +
    +
    +

    + Surah {bookmark.surah_number}{" "} + + {bookmark.ayah_key} + +

    +

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

    +
    + +
    +
    +
    +
    + ))} +
    + ); +} diff --git a/packages/core/src/api/qf-bookmarks.ts b/packages/core/src/api/qf-bookmarks.ts new file mode 100644 index 0000000..f17408a --- /dev/null +++ b/packages/core/src/api/qf-bookmarks.ts @@ -0,0 +1,15 @@ +import { createApi } from "@/client"; + +type Client = ReturnType; + +export const createQfBookmarksApi = (client: Client) => ({ + list: () => client.GET("/qf-bookmarks"), + + create: (body: { ayah_key: string }) => + client.POST("/qf-bookmarks", { body }), + + delete: (id: string) => + client.DELETE("/qf-bookmarks/{bookmark_id}", { + params: { path: { bookmark_id: id } }, + }), +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b92b861..32ce25c 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 { createQfBookmarksApi } from "./api/qf-bookmarks"; 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), + qfBookmarks: createQfBookmarksApi(client), users: createUsersApi(client), }; }; diff --git a/packages/core/src/schema.d.ts b/packages/core/src/schema.d.ts index a619a59..f40342d 100644 --- a/packages/core/src/schema.d.ts +++ b/packages/core/src/schema.d.ts @@ -348,6 +348,41 @@ export interface paths { patch: operations["update_note_bookmarks_notes__note_id__patch"]; trace?: never; }; + "/qf-bookmarks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Qf Bookmarks */ + get: operations["get_qf_bookmarks_qf_bookmarks_get"]; + put?: never; + /** Create Qf Bookmark */ + post: operations["create_qf_bookmark_qf_bookmarks_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/qf-bookmarks/{bookmark_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Qf Bookmark */ + delete: operations["delete_qf_bookmark_qf_bookmarks__bookmark_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -465,6 +500,45 @@ export interface components { /** State */ state: string; }; + /** QfBookmarkCreate */ + QfBookmarkCreate: { + /** Ayah Key */ + ayah_key: string; + }; + /** QfBookmarkListResponse */ + QfBookmarkListResponse: { + /** Bookmarks */ + bookmarks: components["schemas"]["QfBookmarkResponse"][]; + }; + /** QfBookmarkResponse */ + QfBookmarkResponse: { + /** Id */ + id: string; + /** Ayah Key */ + ayah_key: string; + /** Type */ + type: string; + /** Surah Number */ + surah_number: number; + /** 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; + }; /** QfExchangeRequest */ QfExchangeRequest: { /** Session Code */ @@ -1312,4 +1386,86 @@ export interface operations { }; }; }; + get_qf_bookmarks_qf_bookmarks_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QfBookmarkListResponse"]; + }; + }; + }; + }; + create_qf_bookmark_qf_bookmarks_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["QfBookmarkCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["QfBookmarkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_qf_bookmark_qf_bookmarks__bookmark_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + bookmark_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } From d1bb4f4b6ee68b614fb5d726bbe860f2abc9dc88 Mon Sep 17 00:00:00 2001 From: Radit Date: Wed, 20 May 2026 01:53:58 +0700 Subject: [PATCH 2/6] refactor: consolidate bookmark functionality, remove qf-bookmarks, and introduce new notes module --- ...9_1749-788ac94a8323_add_bookmarks_table.py | 246 ++++++++++++ .../20260520_0000-drop_bookmarks_table.py | 39 ++ apps/api/app/api/auth/router.py | 2 +- .../{qf_bookmarks => bookmarks}/__init__.py | 0 apps/api/app/api/bookmarks/router.py | 148 ++----- apps/api/app/api/bookmarks/serializer.py | 44 +-- apps/api/app/api/notes/__init__.py | 0 apps/api/app/api/notes/router.py | 85 ++++ apps/api/app/api/notes/serializer.py | 25 ++ apps/api/app/api/qf_bookmarks/router.py | 50 --- apps/api/app/api/qf_bookmarks/serializer.py | 24 -- apps/api/app/main.py | 4 +- apps/api/app/models/__init__.py | 4 +- apps/api/app/models/{bookmark.py => note.py} | 20 - apps/api/app/modules/auth/qf_service.py | 92 +++++ .../{qf_bookmarks => bookmarks}/__init__.py | 0 apps/api/app/modules/bookmarks/service.py | 363 ++++++++++++----- apps/api/app/modules/notes/__init__.py | 0 apps/api/app/modules/notes/service.py | 67 ++++ apps/api/app/modules/qf_bookmarks/service.py | 166 -------- apps/web/src/components/layout/header.tsx | 12 - apps/web/src/lib/api.ts | 3 - .../src/modules/bookmarks/data/mutations.ts | 48 +-- .../web/src/modules/bookmarks/data/queries.ts | 14 - apps/web/src/modules/notes/data/mutations.ts | 42 ++ .../{qf-bookmarks => notes}/data/queries.ts | 12 +- .../modules/qf-bookmarks/data/mutations.ts | 42 -- .../modules/search/components/verse-card.tsx | 70 +--- apps/web/src/routeTree.gen.ts | 21 - apps/web/src/routes/bookmarks.tsx | 109 +++--- apps/web/src/routes/qf-bookmarks.tsx | 258 ------------ apps/web/src/routes/search.$slug.tsx | 16 +- packages/core/src/api/bookmarks.ts | 19 +- packages/core/src/api/qf-bookmarks.ts | 15 - packages/core/src/index.ts | 2 - packages/core/src/schema.d.ts | 250 ++---------- pnpm-lock.yaml | 369 +++++++++++++++--- 37 files changed, 1350 insertions(+), 1331 deletions(-) create mode 100644 apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py create mode 100644 apps/api/alembic/versions/20260520_0000-drop_bookmarks_table.py rename apps/api/app/api/{qf_bookmarks => bookmarks}/__init__.py (100%) create mode 100644 apps/api/app/api/notes/__init__.py create mode 100644 apps/api/app/api/notes/router.py create mode 100644 apps/api/app/api/notes/serializer.py delete mode 100644 apps/api/app/api/qf_bookmarks/router.py delete mode 100644 apps/api/app/api/qf_bookmarks/serializer.py rename apps/api/app/models/{bookmark.py => note.py} (57%) rename apps/api/app/modules/{qf_bookmarks => bookmarks}/__init__.py (100%) create mode 100644 apps/api/app/modules/notes/__init__.py create mode 100644 apps/api/app/modules/notes/service.py delete mode 100644 apps/api/app/modules/qf_bookmarks/service.py create mode 100644 apps/web/src/modules/notes/data/mutations.ts rename apps/web/src/modules/{qf-bookmarks => notes}/data/queries.ts (51%) delete mode 100644 apps/web/src/modules/qf-bookmarks/data/mutations.ts delete mode 100644 apps/web/src/routes/qf-bookmarks.tsx delete mode 100644 packages/core/src/api/qf-bookmarks.ts 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..769dd04 --- /dev/null +++ b/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py @@ -0,0 +1,246 @@ +"""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: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "topics", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("slug", sa.String(length=80), nullable=False), + sa.Column("canonical_query", sa.String(length=255), nullable=False), + sa.Column("embedding", sa.Text(), nullable=True), + sa.Column("search_count", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("step", sa.String(length=200), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_topics_canonical_query"), "topics", ["canonical_query"], unique=True + ) + op.create_index(op.f("ix_topics_slug"), "topics", ["slug"], unique=True) + op.create_index(op.f("ix_topics_status"), "topics", ["status"], unique=False) + op.create_table( + "topic_results", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("topic_id", sa.UUID(), nullable=False), + sa.Column("ayah_key", sa.String(length=20), nullable=False), + sa.Column("surah_name", sa.String(length=100), nullable=False), + sa.Column("arabic_text", sa.Text(), nullable=False), + sa.Column("translation", sa.Text(), nullable=False), + sa.Column("why_this_verse", sa.Text(), nullable=True), + sa.Column("rank", sa.Integer(), nullable=False), + sa.Column("relevance_score", sa.Float(), nullable=False), + sa.Column("url", sa.Text(), nullable=False), + sa.Column("tafsir_excerpt", sa.Text(), nullable=True), + sa.Column("tafsir_author", sa.String(length=255), nullable=True), + sa.Column("tafsir_edition", sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint(["topic_id"], ["topics.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_topic_results_topic_id"), "topic_results", ["topic_id"], unique=False + ) + op.create_table( + "user_searches", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=True), + sa.Column("session_id", sa.String(length=255), nullable=True), + sa.Column("topic_id", sa.UUID(), nullable=False), + sa.Column("user_query", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["topic_id"], ["topics.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_user_searches_topic_id"), "user_searches", ["topic_id"], unique=False + ) + op.drop_table("profiles") + op.drop_index(op.f("ix_searches_slug"), table_name="searches") + op.drop_index(op.f("ix_searches_status"), table_name="searches") + op.drop_table("searches") + op.add_column("bookmarks", sa.Column("surah_number", sa.Integer(), nullable=False)) + op.add_column("bookmarks", sa.Column("verse_number", sa.Integer(), 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" + ) + op.add_column( + "users", + sa.Column( + "preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + ) + op.add_column("users", sa.Column("qf_sub", sa.String(length=255), nullable=True)) + op.add_column( + "users", sa.Column("qf_refresh_token", sa.String(length=512), nullable=True) + ) + op.add_column( + "users", sa.Column("qf_id_token", sa.String(length=2048), nullable=True) + ) + op.create_unique_constraint(None, "users", ["qf_sub"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "users", type_="unique") + op.drop_column("users", "qf_id_token") + op.drop_column("users", "qf_refresh_token") + op.drop_column("users", "qf_sub") + op.drop_column("users", "preferences") + 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") + op.create_table( + "searches", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("slug", sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column("topic", sa.TEXT(), autoincrement=False, nullable=False), + sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column("step", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column( + "raw_results", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + sa.Column( + "results", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + sa.Column("user_id", sa.UUID(), autoincrement=False, nullable=True), + sa.Column( + "session_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.Column( + "updated_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("searches_user_id_fkey"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("searches_pkey")), + sa.UniqueConstraint( + "slug", + name=op.f("searches_slug_key"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.create_index(op.f("ix_searches_status"), "searches", ["status"], unique=False) + op.create_index(op.f("ix_searches_slug"), "searches", ["slug"], unique=True) + op.create_table( + "profiles", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column( + "headline", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column("summary", sa.TEXT(), autoincrement=False, nullable=True), + sa.Column("phone", sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.Column( + "location", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "linkedin_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "github_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "portfolio_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True + ), + sa.Column( + "created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.Column( + "updated_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("profiles_user_id_fkey"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id", name=op.f("profiles_pkey")), + sa.UniqueConstraint( + "user_id", + name=op.f("profiles_user_id_key"), + postgresql_include=[], + postgresql_nulls_not_distinct=False, + ), + ) + op.drop_index(op.f("ix_user_searches_topic_id"), table_name="user_searches") + op.drop_table("user_searches") + op.drop_index(op.f("ix_topic_results_topic_id"), table_name="topic_results") + op.drop_table("topic_results") + op.drop_index(op.f("ix_topics_status"), table_name="topics") + op.drop_index(op.f("ix_topics_slug"), table_name="topics") + op.drop_index(op.f("ix_topics_canonical_query"), table_name="topics") + op.drop_table("topics") + # ### end Alembic commands ### 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/qf_bookmarks/__init__.py b/apps/api/app/api/bookmarks/__init__.py similarity index 100% rename from apps/api/app/api/qf_bookmarks/__init__.py rename to apps/api/app/api/bookmarks/__init__.py 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/api/qf_bookmarks/router.py b/apps/api/app/api/qf_bookmarks/router.py deleted file mode 100644 index 366e993..0000000 --- a/apps/api/app/api/qf_bookmarks/router.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.auth.router import get_current_user -from app.api.qf_bookmarks.serializer import ( - QfBookmarkCreate, - QfBookmarkListResponse, - QfBookmarkResponse, -) -from app.core.database import get_db -from app.models.user import User -from app.modules.qf_bookmarks import service - -router = APIRouter(prefix="/qf-bookmarks", tags=["qf-bookmarks"]) - -CurrentUserDep = Annotated[User, Depends(get_current_user)] -DbDep = Annotated[AsyncSession, Depends(get_db)] - - -@router.get("", response_model=QfBookmarkListResponse) -async def get_qf_bookmarks( - db: DbDep, - current_user: CurrentUserDep, -) -> QfBookmarkListResponse: - bookmarks = await service.list_qf_bookmarks(db, current_user) - return QfBookmarkListResponse(bookmarks=bookmarks) - - -@router.post( - "", - status_code=status.HTTP_201_CREATED, - response_model=QfBookmarkResponse, -) -async def create_qf_bookmark( - body: QfBookmarkCreate, - db: DbDep, - current_user: CurrentUserDep, -) -> QfBookmarkResponse: - return await service.create_qf_bookmark(db, current_user, body.ayah_key) - - -@router.delete("/{bookmark_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_qf_bookmark( - bookmark_id: str, - db: DbDep, - current_user: CurrentUserDep, -) -> None: - await service.delete_qf_bookmark(db, current_user, bookmark_id) diff --git a/apps/api/app/api/qf_bookmarks/serializer.py b/apps/api/app/api/qf_bookmarks/serializer.py deleted file mode 100644 index d4a2934..0000000 --- a/apps/api/app/api/qf_bookmarks/serializer.py +++ /dev/null @@ -1,24 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel - - -class QfBookmarkCreate(BaseModel): - ayah_key: str - - -class QfBookmarkResponse(BaseModel): - id: str - ayah_key: str - type: str - surah_number: int - 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 - - -class QfBookmarkListResponse(BaseModel): - bookmarks: list[QfBookmarkResponse] diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 80cc8d8..8ec7bfc 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -10,7 +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.qf_bookmarks.router import router as qf_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 @@ -66,8 +66,8 @@ 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) -app.include_router(qf_bookmarks_router) @app.get("/scalar", include_in_schema=False) 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 56f1ea3..fb6e252 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 @@ -175,6 +176,15 @@ async def call_qf_api( ) resp.raise_for_status() 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: {} {} {}", method, path, e) return None @@ -244,3 +254,85 @@ async def login_or_create_user( await db.commit() await db.refresh(user) return user + + +_CONTENT_TOKEN_KEY = "qf_content_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_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/qf_bookmarks/__init__.py b/apps/api/app/modules/bookmarks/__init__.py similarity index 100% rename from apps/api/app/modules/qf_bookmarks/__init__.py rename to apps/api/app/modules/bookmarks/__init__.py diff --git a/apps/api/app/modules/bookmarks/service.py b/apps/api/app/modules/bookmarks/service.py index 48574b8..b55aa43 100644 --- a/apps/api/app/modules/bookmarks/service.py +++ b/apps/api/app/modules/bookmarks/service.py @@ -1,113 +1,300 @@ -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 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 +SURAHS: dict[int, str] = { + 1: "Al-Fatihah", + 2: "Al-Baqarah", + 3: "Ali 'Imran", + 4: "An-Nisa", + 5: "Al-Ma'idah", + 6: "Al-An'am", + 7: "Al-A'raf", + 8: "Al-Anfal", + 9: "At-Tawbah", + 10: "Yunus", + 11: "Hud", + 12: "Yusuf", + 13: "Ar-Ra'd", + 14: "Ibrahim", + 15: "Al-Hijr", + 16: "An-Nahl", + 17: "Al-Isra", + 18: "Al-Kahf", + 19: "Maryam", + 20: "Taha", + 21: "Al-Anbya", + 22: "Al-Hajj", + 23: "Al-Mu'minun", + 24: "An-Nur", + 25: "Al-Furqan", + 26: "Ash-Shu'ara", + 27: "An-Naml", + 28: "Al-Qasas", + 29: "Al-'Ankabut", + 30: "Ar-Rum", + 31: "Luqman", + 32: "As-Sajdah", + 33: "Al-Ahzab", + 34: "Saba", + 35: "Fatir", + 36: "Ya-Sin", + 37: "As-Saffat", + 38: "Sad", + 39: "Az-Zumar", + 40: "Ghafir", + 41: "Fussilat", + 42: "Ash-Shuraa", + 43: "Az-Zukhruf", + 44: "Ad-Dukhan", + 45: "Al-Jathiyah", + 46: "Al-Ahqaf", + 47: "Muhammad", + 48: "Al-Fath", + 49: "Al-Hujurat", + 50: "Qaf", + 51: "Adh-Dhariyat", + 52: "At-Tur", + 53: "An-Najm", + 54: "Al-Qamar", + 55: "Ar-Rahman", + 56: "Al-Waqi'ah", + 57: "Al-Hadid", + 58: "Al-Mujadilah", + 59: "Al-Hashr", + 60: "Al-Mumtahanah", + 61: "As-Saf", + 62: "Al-Jumu'ah", + 63: "Al-Munafiqun", + 64: "At-Taghabun", + 65: "At-Talaq", + 66: "At-Tahrim", + 67: "Al-Mulk", + 68: "Al-Qalam", + 69: "Al-Haqqah", + 70: "Al-Ma'arij", + 71: "Nuh", + 72: "Al-Jinn", + 73: "Al-Muzzammil", + 74: "Al-Muddaththir", + 75: "Al-Qiyamah", + 76: "Al-Insan", + 77: "Al-Mursalat", + 78: "An-Naba", + 79: "An-Nazi'at", + 80: "'Abasa", + 81: "At-Takwir", + 82: "Al-Infitar", + 83: "Al-Mutaffifin", + 84: "Al-Inshiqaq", + 85: "Al-Buruj", + 86: "At-Tariq", + 87: "Al-A'la", + 88: "Al-Ghashiyah", + 89: "Al-Fajr", + 90: "Al-Balad", + 91: "Ash-Shams", + 92: "Al-Layl", + 93: "Ad-Duha", + 94: "Ash-Sharh", + 95: "At-Tin", + 96: "Al-'Alaq", + 97: "Al-Qadr", + 98: "Al-Bayyinah", + 99: "Az-Zalzalah", + 100: "Al-'Adiyat", + 101: "Al-Qari'ah", + 102: "At-Takathur", + 103: "Al-'Asr", + 104: "Al-Humazah", + 105: "Al-Fil", + 106: "Quraysh", + 107: "Al-Ma'un", + 108: "Al-Kawthar", + 109: "Al-Kafirun", + 110: "An-Nasr", + 111: "Al-Masad", + 112: "Al-Ikhlas", + 113: "Al-Falaq", + 114: "An-Nas", +} -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(number: int) -> str: + return SURAHS.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()) - ) - return list(result.scalars().all()) +def _normalize_qf_bookmark(raw: dict[str, Any]) -> 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() + ) -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(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", + ) + + normalized = [ + _normalize_qf_bookmark(b) + 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", "") -async def update_note( + return normalized + + +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, + }, + ) + + 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, {}) + + return BookmarkResponse( + id=str(data.get("data", {}).get("id", "")), + ayah_key=ayah_key, + type="ayah", + surah_number=surah_number, + surah_name=_get_surah_name(surah_number), + verse_number=verse_number, + created_at=datetime.now(), + arabic_text=verse_data.get("arabic_text", ""), + translation=verse_data.get("translation", ""), ) - 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_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", + ) -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) + data = await qf_service.call_qf_api( + access_token, + f"/auth/v1/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 + + 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/api/app/modules/qf_bookmarks/service.py b/apps/api/app/modules/qf_bookmarks/service.py deleted file mode 100644 index 369d8f6..0000000 --- a/apps/api/app/modules/qf_bookmarks/service.py +++ /dev/null @@ -1,166 +0,0 @@ -from datetime import datetime -from typing import Any - -from fastapi import HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.qf_bookmarks.serializer import QfBookmarkResponse -from app.core.settings import get_settings -from app.models.user import User -from app.modules.auth import qf_service - -DEFAULT_COLLECTION_ID = "__default__" - - -def parse_ayah_key(ayah_key: str) -> tuple[int, int]: - 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", - ) - - return surah_number, verse_number - - -async def get_qf_access_token(db: AsyncSession, current_user: User) -> str: - 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 QF bookmarks", - ) - return access_token - - -def normalize_qf_bookmark(raw: dict[str, Any]) -> QfBookmarkResponse: - 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 QfBookmarkResponse( - id=str(raw["id"]), - ayah_key=f"{surah_number}:{verse_number}", - type=str(raw.get("type", "ayah")), - surah_number=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, - ) - - -async def list_qf_bookmarks( - db: AsyncSession, - current_user: User, -) -> list[QfBookmarkResponse]: - access_token = await get_qf_access_token(db, current_user) - settings = get_settings() - data = await qf_service.call_qf_api( - access_token, - "/v1/bookmarks", - params={ - "type": "ayah", - "mushafId": settings.QF_MUSHAF_ID, - "first": 20, - }, - ) - if data is None: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to fetch QF bookmarks", - ) - - bookmarks = data.get("data", []) - if not isinstance(bookmarks, list): - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Unexpected QF bookmarks response", - ) - - return [ - normalize_qf_bookmark(bookmark) - for bookmark in bookmarks - if bookmark.get("type") == "ayah" - and bookmark.get("verseNumber") is not None - and bookmark.get("isInDefaultCollection", True) - ] - - -async def create_qf_bookmark( - db: AsyncSession, - current_user: User, - ayah_key: str, -) -> QfBookmarkResponse: - surah_number, verse_number = parse_ayah_key(ayah_key) - access_token = await get_qf_access_token(db, current_user) - settings = get_settings() - data = await qf_service.call_qf_api( - access_token, - f"/v1/collections/{DEFAULT_COLLECTION_ID}/bookmarks", - method="POST", - json_body={ - "type": "ayah", - "key": surah_number, - "verseNumber": verse_number, - "mushafId": settings.QF_MUSHAF_ID, - }, - ) - if data is None: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to create QF bookmark", - ) - - bookmarks = await list_qf_bookmarks(db, current_user) - for bookmark in bookmarks: - if ( - bookmark.surah_number == surah_number - and bookmark.verse_number == verse_number - ): - return bookmark - - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="QF bookmark was created but could not be read back", - ) - - -async def delete_qf_bookmark( - db: AsyncSession, - current_user: User, - bookmark_id: str, -) -> None: - access_token = await get_qf_access_token(db, current_user) - data = await qf_service.call_qf_api( - access_token, - f"/v1/collections/{DEFAULT_COLLECTION_ID}/bookmarks/{bookmark_id}", - method="DELETE", - ) - if data is None: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to delete QF bookmark", - ) diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index c812d60..ba9c6ee 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -79,10 +79,6 @@ export function Header() { nativeButton={false} render={Bookmarks} /> - QF Bookmarks} - /> {isLoggedIn ? ( Sign out} @@ -138,14 +134,6 @@ export function Header() { render={Bookmarks} />
  • -
  • -
  • {isLoggedIn ? ( diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 049ce78..a423735 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -26,9 +26,6 @@ export const queryKeys = { bookmarks: { all: ["bookmarks"] as const, }, - qfBookmarks: { - all: ["qf-bookmarks"] as const, - }, notes: { all: ["notes"] as const, }, diff --git a/apps/web/src/modules/bookmarks/data/mutations.ts b/apps/web/src/modules/bookmarks/data/mutations.ts index 43c5664..5780baa 100644 --- a/apps/web/src/modules/bookmarks/data/mutations.ts +++ b/apps/web/src/modules/bookmarks/data/mutations.ts @@ -5,23 +5,18 @@ 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"]; 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); @@ -33,45 +28,12 @@ export function useDeleteBookmark() { const queryClient = useQueryClient(); return useMutation({ 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; - }, - 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.bookmarks.deleteNote(id); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); + toast.success("Bookmark removed"); }, onError: (error) => { toast.error(error.message); 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..f9ca497 --- /dev/null +++ b/apps/web/src/modules/notes/data/mutations.ts @@ -0,0 +1,42 @@ +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.bookmarks.createNote({ + ...body, + verses: body.verses ?? undefined, + }); + 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.bookmarks.deleteNote(id); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); + }, + onError: (error) => { + toast.error(error.message); + }, + }); +} diff --git a/apps/web/src/modules/qf-bookmarks/data/queries.ts b/apps/web/src/modules/notes/data/queries.ts similarity index 51% rename from apps/web/src/modules/qf-bookmarks/data/queries.ts rename to apps/web/src/modules/notes/data/queries.ts index 6d10604..a73efec 100644 --- a/apps/web/src/modules/qf-bookmarks/data/queries.ts +++ b/apps/web/src/modules/notes/data/queries.ts @@ -3,15 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { api, queryKeys } from "@/lib/api"; import { useIsLoggedIn } from "@/modules/auth/stores/auth-store"; -type QfBookmarkListResponse = components["schemas"]["QfBookmarkListResponse"]; +type NoteListResponse = components["schemas"]["NoteListResponse"]; -export function useQfBookmarks() { +export function useNotes() { const isLoggedIn = useIsLoggedIn(); - return useQuery({ - queryKey: queryKeys.qfBookmarks.all, + return useQuery({ + queryKey: queryKeys.notes.all, queryFn: async () => { - const res = await api.qfBookmarks.list(); - if (res.error) throw new Error("Failed to fetch QF bookmarks"); + 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/qf-bookmarks/data/mutations.ts b/apps/web/src/modules/qf-bookmarks/data/mutations.ts deleted file mode 100644 index 72cc92b..0000000 --- a/apps/web/src/modules/qf-bookmarks/data/mutations.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { components } from "@repo/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { api, queryKeys } from "@/lib/api"; - -type QfBookmarkCreate = components["schemas"]["QfBookmarkCreate"]; -type QfBookmarkResponse = components["schemas"]["QfBookmarkResponse"]; - -export function useCreateQfBookmark() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (body) => { - const res = await api.qfBookmarks.create(body); - if (res.error) throw new Error("Failed to save QF bookmark"); - return res.data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); - toast.success("Saved to Quran Foundation"); - }, - onError: (error) => { - toast.error(error.message); - }, - }); -} - -export function useDeleteQfBookmark() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (id) => { - const res = await api.qfBookmarks.delete(id); - if (res.error) throw new Error("Failed to delete QF bookmark"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); - toast.success("Removed from Quran Foundation"); - }, - onError: (error) => { - toast.error(error.message); - }, - }); -} diff --git a/apps/web/src/modules/search/components/verse-card.tsx b/apps/web/src/modules/search/components/verse-card.tsx index faba530..447d944 100644 --- a/apps/web/src/modules/search/components/verse-card.tsx +++ b/apps/web/src/modules/search/components/verse-card.tsx @@ -1,5 +1,5 @@ import type { components } from "@repo/core"; -import { BookOpenCheck, Cloud, Share2 } from "lucide-react"; +import { BookOpenCheck, Share2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useCopyToClipboard } from "react-use"; import { toast } from "sonner"; @@ -14,8 +14,6 @@ import { useBookmarks } from "@/modules/bookmarks/data/queries"; import { ReadingSettingsSidebar } from "@/modules/preferences/components/reading-settings-sidebar"; import { ARABIC_FONT_STACK } from "@/modules/preferences/lib/arabic-font-stacks"; import { useFontPreferencesStore } from "@/modules/preferences/stores/font-preferences-store"; -import { useCreateQfBookmark } from "@/modules/qf-bookmarks/data/mutations"; -import { useQfBookmarks } from "@/modules/qf-bookmarks/data/queries"; import { useExplainVerse, useVersePage } from "@/modules/search/data/queries"; type VerseResult = components["schemas"]["VerseResult"]; @@ -63,8 +61,6 @@ export function VerseCard({ const versePage = useVersePage(slug, rank + 1, true); const bookmarks = useBookmarks(); const createBookmark = useCreateBookmark(); - const qfBookmarks = useQfBookmarks(); - const createQfBookmark = useCreateQfBookmark(); const loadedVerse = versePage.data?.verse; const whyText = @@ -82,14 +78,6 @@ export function VerseCard({ ) )); const isCheckingBookmark = isLoggedIn && bookmarks.isPending; - const isAlreadyQfBookmarked = - isLoggedIn && - Boolean( - qfBookmarks.data?.bookmarks.some( - (bookmark) => bookmark.ayah_key === verse.ayah_key - ) - ); - const isCheckingQfBookmark = isLoggedIn && qfBookmarks.isPending; useEffect(() => { const explanation = explainVerse.data?.why_this_verse; @@ -113,43 +101,13 @@ 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 */ } } - async function handleQfSave() { - if (!isLoggedIn) { - onSaveRequest?.(verse.ayah_key); - return; - } - if ( - isAlreadyQfBookmarked || - isCheckingQfBookmark || - createQfBookmark.isPending - ) { - return; - } - - if (!navigator.onLine) { - toast.error("Sync when back online"); - return; - } - - try { - await createQfBookmark.mutateAsync({ ayah_key: verse.ayah_key }); - } catch { - /* mutation hook surfaces the error toast */ - } - } - function handleShare() { const text = `"${verse.translation}" — ${verse.surah_name} (${verse.ayah_key}) via Qalbwise`; if (typeof navigator.share === "function") { @@ -243,31 +201,9 @@ export function VerseCard({ ? "Saving…" : isCheckingBookmark ? "Checking…" - : "Save Verse"} + : "Bookmark"} - diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c0d616e..8d5f634 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,7 +11,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as TermsRouteImport } from './routes/terms' import { Route as QfCallbackRouteImport } from './routes/qf-callback' -import { Route as QfBookmarksRouteImport } from './routes/qf-bookmarks' import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as BookmarksRouteImport } from './routes/bookmarks' import { Route as IndexRouteImport } from './routes/index' @@ -27,11 +26,6 @@ const QfCallbackRoute = QfCallbackRouteImport.update({ path: '/qf-callback', getParentRoute: () => rootRouteImport, } as any) -const QfBookmarksRoute = QfBookmarksRouteImport.update({ - id: '/qf-bookmarks', - path: '/qf-bookmarks', - getParentRoute: () => rootRouteImport, -} as any) const PrivacyRoute = PrivacyRouteImport.update({ id: '/privacy', path: '/privacy', @@ -57,7 +51,6 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute - '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -66,7 +59,6 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute - '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -76,7 +68,6 @@ export interface FileRoutesById { '/': typeof IndexRoute '/bookmarks': typeof BookmarksRoute '/privacy': typeof PrivacyRoute - '/qf-bookmarks': typeof QfBookmarksRoute '/qf-callback': typeof QfCallbackRoute '/terms': typeof TermsRoute '/search/$slug': typeof SearchSlugRoute @@ -87,7 +78,6 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' - | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -96,7 +86,6 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' - | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -105,7 +94,6 @@ export interface FileRouteTypes { | '/' | '/bookmarks' | '/privacy' - | '/qf-bookmarks' | '/qf-callback' | '/terms' | '/search/$slug' @@ -115,7 +103,6 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute BookmarksRoute: typeof BookmarksRoute PrivacyRoute: typeof PrivacyRoute - QfBookmarksRoute: typeof QfBookmarksRoute QfCallbackRoute: typeof QfCallbackRoute TermsRoute: typeof TermsRoute SearchSlugRoute: typeof SearchSlugRoute @@ -137,13 +124,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof QfCallbackRouteImport parentRoute: typeof rootRouteImport } - '/qf-bookmarks': { - id: '/qf-bookmarks' - path: '/qf-bookmarks' - fullPath: '/qf-bookmarks' - preLoaderRoute: typeof QfBookmarksRouteImport - parentRoute: typeof rootRouteImport - } '/privacy': { id: '/privacy' path: '/privacy' @@ -179,7 +159,6 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BookmarksRoute: BookmarksRoute, PrivacyRoute: PrivacyRoute, - QfBookmarksRoute: QfBookmarksRoute, QfCallbackRoute: QfCallbackRoute, TermsRoute: TermsRoute, SearchSlugRoute: SearchSlugRoute, diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 797725c..068395e 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"; @@ -36,21 +36,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 +57,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 +140,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 +157,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.

    ); @@ -173,13 +174,13 @@ function BookmarksList({
    -

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

    + {bookmark.surah_name} {bookmark.ayah_key}

    + - Delete {bookmark.surah_name} {bookmark.ayah_key} + Delete bookmark {bookmark.ayah_key} - Are you sure you want to delete this from your bookmarks? - action cannot be undone. + This removes the verse from your bookmarks. @@ -214,31 +214,30 @@ function BookmarksList({
    -

    - {bookmark.arabic_text} -

    -

    - {bookmark.translation} -

    + + {bookmark.arabic_text && ( +

    + {bookmark.arabic_text} +

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

    + {bookmark.translation} +

    + )} +

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

    - {bookmark.note && ( -

    - {bookmark.note} -

    - )}
    ))} diff --git a/apps/web/src/routes/qf-bookmarks.tsx b/apps/web/src/routes/qf-bookmarks.tsx deleted file mode 100644 index 3272b60..0000000 --- a/apps/web/src/routes/qf-bookmarks.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import type { components } from "@repo/core"; -import { useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { BookmarkCheck, ChevronLeft, ExternalLink } from "lucide-react"; -import { motion } from "motion/react"; -import { useState } from "react"; - -import { Dots } from "@/components/loading-ui/dots"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { queryKeys } from "@/lib/api"; -import { duration, easing, variants } from "@/lib/motions"; -import { LoginDrawer } from "@/modules/auth/components/login-drawer"; -import { useAuth } from "@/modules/auth/hooks/use-auth"; -import { useDeleteQfBookmark } from "@/modules/qf-bookmarks/data/mutations"; -import { useQfBookmarks } from "@/modules/qf-bookmarks/data/queries"; - -export const Route = createFileRoute("/qf-bookmarks")({ - component: QfBookmarksPage, - head: () => ({ - meta: [{ title: "QF Bookmarks | Qalbwise" }], - }), -}); - -type QfBookmark = components["schemas"]["QfBookmarkResponse"]; - -function quranComEnUrl(ayahKey: string): string { - const parts = ayahKey.trim().split(":"); - if (parts.length !== 2) return "https://quran.com/en"; - return `https://quran.com/en/${parts[0]}/${parts[1]}`; -} - -function QfBookmarksPage() { - const [loginSheetOpen, setLoginSheetOpen] = useState(false); - const auth = useAuth(); - const queryClient = useQueryClient(); - const qfBookmarks = useQfBookmarks(); - const deleteQfBookmark = useDeleteQfBookmark(); - - const isLoggedIn = auth.isLoggedIn || Boolean(auth.accessToken); - - async function handleLoginSuccess() { - queryClient.invalidateQueries({ queryKey: queryKeys.me }); - queryClient.invalidateQueries({ queryKey: queryKeys.qfBookmarks.all }); - setLoginSheetOpen(false); - } - - if (!isLoggedIn) { - return ( - - -

    Sign in with Quran Foundation

    -

    - View and manage verses saved to your Quran Foundation Favorites. -

    - - -
    - ); - } - - return ( - -
    -
    - - {qfBookmarks.isLoading && ( -
    - -

    Loading...

    -
    - )} - - {qfBookmarks.isError && ( -
    -

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

    - - -
    - )} - - {!qfBookmarks.isLoading && !qfBookmarks.isError && ( - deleteQfBookmark.mutate(id)} - /> - )} -
    - ); -} - -function QfBookmarksList({ - bookmarks, - isDeleting, - onDelete, -}: { - bookmarks: QfBookmark[]; - isDeleting: boolean; - onDelete: (id: string) => void; -}) { - if (bookmarks.length === 0) { - return ( - -

    No Quran Foundation bookmarks yet.

    -

    - Save verses to QF Favorites from your search results. -

    -
    - ); - } - - return ( - - {bookmarks.map((bookmark) => ( - -
    -
    -

    - Surah {bookmark.surah_number}{" "} - - {bookmark.ayah_key} - -

    -

    - 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..41330c3 100644 --- a/packages/core/src/api/bookmarks.ts +++ b/packages/core/src/api/bookmarks.ts @@ -3,17 +3,10 @@ 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 } }, @@ -23,18 +16,18 @@ export const createBookmarksApi = (client: Client) => ({ topic: string; content: string; verses?: Record[]; - }) => client.POST("/bookmarks/notes", { body }), + }) => client.POST("/notes", { body }), - listNotes: () => client.GET("/bookmarks/notes"), + listNotes: () => client.GET("/notes"), updateNote: (id: string, content: string) => - client.PATCH("/bookmarks/notes/{note_id}", { + client.PATCH("/notes/{note_id}", { params: { path: { note_id: id } }, body: content, }), deleteNote: (id: string) => - client.DELETE("/bookmarks/notes/{note_id}", { + client.DELETE("/notes/{note_id}", { params: { path: { note_id: id } }, }), }); diff --git a/packages/core/src/api/qf-bookmarks.ts b/packages/core/src/api/qf-bookmarks.ts deleted file mode 100644 index f17408a..0000000 --- a/packages/core/src/api/qf-bookmarks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createApi } from "@/client"; - -type Client = ReturnType; - -export const createQfBookmarksApi = (client: Client) => ({ - list: () => client.GET("/qf-bookmarks"), - - create: (body: { ayah_key: string }) => - client.POST("/qf-bookmarks", { body }), - - delete: (id: string) => - client.DELETE("/qf-bookmarks/{bookmark_id}", { - params: { path: { bookmark_id: id } }, - }), -}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32ce25c..b92b861 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,5 @@ import { createAuthApi } from "./api/auth"; import { createBookmarksApi } from "./api/bookmarks"; -import { createQfBookmarksApi } from "./api/qf-bookmarks"; import { createSearchApi } from "./api/search"; import { createUsersApi } from "./api/users"; import { createApi } from "./client"; @@ -19,7 +18,6 @@ export const createApiWithModules = ( auth: createAuthApi(client), search: createSearchApi(client), bookmarks: createBookmarksApi(client), - qfBookmarks: createQfBookmarksApi(client), users: createUsersApi(client), }; }; diff --git a/packages/core/src/schema.d.ts b/packages/core/src/schema.d.ts index f40342d..b5d1b0f 100644 --- a/packages/core/src/schema.d.ts +++ b/packages/core/src/schema.d.ts @@ -277,42 +277,7 @@ export interface paths { patch?: never; trace?: never; }; - "/bookmarks": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Bookmarks */ - get: operations["get_bookmarks_bookmarks_get"]; - put?: never; - /** Create Bookmark */ - post: operations["create_bookmark_bookmarks_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bookmarks/{bookmark_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Delete Bookmark */ - delete: operations["delete_bookmark_bookmarks__bookmark_id__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/bookmarks/notes": { + "/notes": { parameters: { query?: never; header?: never; @@ -320,17 +285,17 @@ export interface paths { cookie?: never; }; /** Get Notes */ - get: operations["get_notes_bookmarks_notes_get"]; + get: operations["get_notes_notes_get"]; put?: never; /** Create Note */ - post: operations["create_note_bookmarks_notes_post"]; + post: operations["create_note_notes_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/bookmarks/notes/{note_id}": { + "/notes/{note_id}": { parameters: { query?: never; header?: never; @@ -341,32 +306,32 @@ export interface paths { put?: never; post?: never; /** Delete Note */ - delete: operations["delete_note_bookmarks_notes__note_id__delete"]; + delete: operations["delete_note_notes__note_id__delete"]; options?: never; head?: never; /** Update Note */ - patch: operations["update_note_bookmarks_notes__note_id__patch"]; + patch: operations["update_note_notes__note_id__patch"]; trace?: never; }; - "/qf-bookmarks": { + "/bookmarks": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Get Qf Bookmarks */ - get: operations["get_qf_bookmarks_qf_bookmarks_get"]; + /** Get Bookmarks */ + get: operations["get_bookmarks_bookmarks_get"]; put?: never; - /** Create Qf Bookmark */ - post: operations["create_qf_bookmark_qf_bookmarks_post"]; + /** Create Bookmark */ + post: operations["create_bookmark_bookmarks_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/qf-bookmarks/{bookmark_id}": { + "/bookmarks/{bookmark_id}": { parameters: { query?: never; header?: never; @@ -376,8 +341,8 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete Qf Bookmark */ - delete: operations["delete_qf_bookmark_qf_bookmarks__bookmark_id__delete"]; + /** Delete Bookmark */ + delete: operations["delete_bookmark_bookmarks__bookmark_id__delete"]; options?: never; head?: never; patch?: never; @@ -391,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: { @@ -411,25 +364,27 @@ export interface components { }; /** BookmarkResponse */ BookmarkResponse: { - /** - * Id - * Format: uuid - */ + /** Id */ id: string; /** 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; + /** Type */ + type: string; + /** Surah Number */ + surah_number: number; + /** 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 @@ -500,45 +455,6 @@ export interface components { /** State */ state: string; }; - /** QfBookmarkCreate */ - QfBookmarkCreate: { - /** Ayah Key */ - ayah_key: string; - }; - /** QfBookmarkListResponse */ - QfBookmarkListResponse: { - /** Bookmarks */ - bookmarks: components["schemas"]["QfBookmarkResponse"][]; - }; - /** QfBookmarkResponse */ - QfBookmarkResponse: { - /** Id */ - id: string; - /** Ayah Key */ - ayah_key: string; - /** Type */ - type: string; - /** Surah Number */ - surah_number: number; - /** 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; - }; /** QfExchangeRequest */ QfExchangeRequest: { /** Session Code */ @@ -1187,89 +1103,7 @@ export interface operations { }; }; }; - get_bookmarks_bookmarks_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BookmarkListResponse"]; - }; - }; - }; - }; - create_bookmark_bookmarks_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BookmarkCreate"]; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BookmarkResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - delete_bookmark_bookmarks__bookmark_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - bookmark_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_notes_bookmarks_notes_get: { + get_notes_notes_get: { parameters: { query?: never; header?: never; @@ -1289,7 +1123,7 @@ export interface operations { }; }; }; - create_note_bookmarks_notes_post: { + create_note_notes_post: { parameters: { query?: never; header?: never; @@ -1322,7 +1156,7 @@ export interface operations { }; }; }; - delete_note_bookmarks_notes__note_id__delete: { + delete_note_notes__note_id__delete: { parameters: { query?: never; header?: never; @@ -1351,7 +1185,7 @@ export interface operations { }; }; }; - update_note_bookmarks_notes__note_id__patch: { + update_note_notes__note_id__patch: { parameters: { query?: never; header?: never; @@ -1386,7 +1220,7 @@ export interface operations { }; }; }; - get_qf_bookmarks_qf_bookmarks_get: { + get_bookmarks_bookmarks_get: { parameters: { query?: never; header?: never; @@ -1401,12 +1235,12 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["QfBookmarkListResponse"]; + "application/json": components["schemas"]["BookmarkListResponse"]; }; }; }; }; - create_qf_bookmark_qf_bookmarks_post: { + create_bookmark_bookmarks_post: { parameters: { query?: never; header?: never; @@ -1415,7 +1249,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["QfBookmarkCreate"]; + "application/json": components["schemas"]["BookmarkCreate"]; }; }; responses: { @@ -1425,7 +1259,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["QfBookmarkResponse"]; + "application/json": components["schemas"]["BookmarkResponse"]; }; }; /** @description Validation Error */ @@ -1439,7 +1273,7 @@ export interface operations { }; }; }; - delete_qf_bookmark_qf_bookmarks__bookmark_id__delete: { + delete_bookmark_bookmarks__bookmark_id__delete: { parameters: { query?: never; header?: never; 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: From 6c7a2c747b4e6feb3ae23224cb9c1509e619a9ea Mon Sep 17 00:00:00 2001 From: Radit Date: Wed, 20 May 2026 02:10:08 +0700 Subject: [PATCH 3/6] refactor(core): separate notes api from bookmarks module && delete hardcoded surah names --- apps/api/app/modules/auth/qf_service.py | 34 +++++ apps/api/app/modules/bookmarks/service.py | 134 ++----------------- apps/web/src/modules/notes/data/mutations.ts | 7 +- apps/web/src/modules/notes/data/queries.ts | 2 +- packages/core/src/api/bookmarks.ts | 19 --- packages/core/src/api/notes.ts | 24 ++++ packages/core/src/index.ts | 2 + packages/core/src/schema.d.ts | 12 ++ 8 files changed, 86 insertions(+), 148 deletions(-) create mode 100644 packages/core/src/api/notes.ts diff --git a/apps/api/app/modules/auth/qf_service.py b/apps/api/app/modules/auth/qf_service.py index fb6e252..6b708d8 100644 --- a/apps/api/app/modules/auth/qf_service.py +++ b/apps/api/app/modules/auth/qf_service.py @@ -257,6 +257,7 @@ async def login_or_create_user( _CONTENT_TOKEN_KEY = "qf_content_token" +_CHAPTERS_CACHE_KEY = "qf_chapters" async def get_content_api_token() -> str | None: @@ -296,6 +297,39 @@ async def get_content_api_token() -> str | None: 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, diff --git a/apps/api/app/modules/bookmarks/service.py b/apps/api/app/modules/bookmarks/service.py index b55aa43..97cc75a 100644 --- a/apps/api/app/modules/bookmarks/service.py +++ b/apps/api/app/modules/bookmarks/service.py @@ -9,129 +9,14 @@ from app.models.user import User from app.modules.auth import qf_service -SURAHS: dict[int, str] = { - 1: "Al-Fatihah", - 2: "Al-Baqarah", - 3: "Ali 'Imran", - 4: "An-Nisa", - 5: "Al-Ma'idah", - 6: "Al-An'am", - 7: "Al-A'raf", - 8: "Al-Anfal", - 9: "At-Tawbah", - 10: "Yunus", - 11: "Hud", - 12: "Yusuf", - 13: "Ar-Ra'd", - 14: "Ibrahim", - 15: "Al-Hijr", - 16: "An-Nahl", - 17: "Al-Isra", - 18: "Al-Kahf", - 19: "Maryam", - 20: "Taha", - 21: "Al-Anbya", - 22: "Al-Hajj", - 23: "Al-Mu'minun", - 24: "An-Nur", - 25: "Al-Furqan", - 26: "Ash-Shu'ara", - 27: "An-Naml", - 28: "Al-Qasas", - 29: "Al-'Ankabut", - 30: "Ar-Rum", - 31: "Luqman", - 32: "As-Sajdah", - 33: "Al-Ahzab", - 34: "Saba", - 35: "Fatir", - 36: "Ya-Sin", - 37: "As-Saffat", - 38: "Sad", - 39: "Az-Zumar", - 40: "Ghafir", - 41: "Fussilat", - 42: "Ash-Shuraa", - 43: "Az-Zukhruf", - 44: "Ad-Dukhan", - 45: "Al-Jathiyah", - 46: "Al-Ahqaf", - 47: "Muhammad", - 48: "Al-Fath", - 49: "Al-Hujurat", - 50: "Qaf", - 51: "Adh-Dhariyat", - 52: "At-Tur", - 53: "An-Najm", - 54: "Al-Qamar", - 55: "Ar-Rahman", - 56: "Al-Waqi'ah", - 57: "Al-Hadid", - 58: "Al-Mujadilah", - 59: "Al-Hashr", - 60: "Al-Mumtahanah", - 61: "As-Saf", - 62: "Al-Jumu'ah", - 63: "Al-Munafiqun", - 64: "At-Taghabun", - 65: "At-Talaq", - 66: "At-Tahrim", - 67: "Al-Mulk", - 68: "Al-Qalam", - 69: "Al-Haqqah", - 70: "Al-Ma'arij", - 71: "Nuh", - 72: "Al-Jinn", - 73: "Al-Muzzammil", - 74: "Al-Muddaththir", - 75: "Al-Qiyamah", - 76: "Al-Insan", - 77: "Al-Mursalat", - 78: "An-Naba", - 79: "An-Nazi'at", - 80: "'Abasa", - 81: "At-Takwir", - 82: "Al-Infitar", - 83: "Al-Mutaffifin", - 84: "Al-Inshiqaq", - 85: "Al-Buruj", - 86: "At-Tariq", - 87: "Al-A'la", - 88: "Al-Ghashiyah", - 89: "Al-Fajr", - 90: "Al-Balad", - 91: "Ash-Shams", - 92: "Al-Layl", - 93: "Ad-Duha", - 94: "Ash-Sharh", - 95: "At-Tin", - 96: "Al-'Alaq", - 97: "Al-Qadr", - 98: "Al-Bayyinah", - 99: "Az-Zalzalah", - 100: "Al-'Adiyat", - 101: "Al-Qari'ah", - 102: "At-Takathur", - 103: "Al-'Asr", - 104: "Al-Humazah", - 105: "Al-Fil", - 106: "Quraysh", - 107: "Al-Ma'un", - 108: "Al-Kawthar", - 109: "Al-Kafirun", - 110: "An-Nasr", - 111: "Al-Masad", - 112: "Al-Ikhlas", - 113: "Al-Falaq", - 114: "An-Nas", -} +def _get_surah_name(chapters: dict[int, str], number: int) -> str: + return chapters.get(number, f"Surah {number}") -def _get_surah_name(number: int) -> str: - return SURAHS.get(number, f"Surah {number}") - -def _normalize_qf_bookmark(raw: dict[str, Any]) -> BookmarkResponse: +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") @@ -146,7 +31,7 @@ def _normalize_qf_bookmark(raw: dict[str, Any]) -> BookmarkResponse: ayah_key=f"{surah_number}:{verse_number}", type=str(raw.get("type", "ayah")), surah_number=surah_number, - surah_name=_get_surah_name(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)), @@ -187,8 +72,10 @@ async def list_bookmarks( detail="Unexpected bookmarks response", ) + chapters = await qf_service.fetch_chapters() or {} + normalized = [ - _normalize_qf_bookmark(b) + _normalize_qf_bookmark(b, chapters) for b in bookmarks if b.get("type") == "ayah" and b.get("verseNumber") is not None ] @@ -261,13 +148,14 @@ async def create_bookmark( 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(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", ""), diff --git a/apps/web/src/modules/notes/data/mutations.ts b/apps/web/src/modules/notes/data/mutations.ts index f9ca497..394d89e 100644 --- a/apps/web/src/modules/notes/data/mutations.ts +++ b/apps/web/src/modules/notes/data/mutations.ts @@ -10,10 +10,7 @@ export function useCreateNote() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { - const res = await api.bookmarks.createNote({ - ...body, - verses: body.verses ?? undefined, - }); + const res = await api.notes.create(body); if (res.error) throw new Error("Failed to create note"); return res.data; }, @@ -30,7 +27,7 @@ export function useDeleteNote() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id) => { - await api.bookmarks.deleteNote(id); + await api.notes.delete(id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.notes.all }); diff --git a/apps/web/src/modules/notes/data/queries.ts b/apps/web/src/modules/notes/data/queries.ts index a73efec..83c17f0 100644 --- a/apps/web/src/modules/notes/data/queries.ts +++ b/apps/web/src/modules/notes/data/queries.ts @@ -10,7 +10,7 @@ export function useNotes() { return useQuery({ queryKey: queryKeys.notes.all, queryFn: async () => { - const res = await api.bookmarks.listNotes(); + const res = await api.notes.list(); if (res.error) throw new Error("Failed to fetch notes"); return res.data; }, diff --git a/packages/core/src/api/bookmarks.ts b/packages/core/src/api/bookmarks.ts index 41330c3..8defe8a 100644 --- a/packages/core/src/api/bookmarks.ts +++ b/packages/core/src/api/bookmarks.ts @@ -11,23 +11,4 @@ export const createBookmarksApi = (client: Client) => ({ client.DELETE("/bookmarks/{bookmark_id}", { params: { path: { bookmark_id: id } }, }), - - createNote: (body: { - topic: string; - content: string; - verses?: Record[]; - }) => client.POST("/notes", { body }), - - listNotes: () => client.GET("/notes"), - - updateNote: (id: string, content: string) => - client.PATCH("/notes/{note_id}", { - params: { path: { note_id: id } }, - body: content, - }), - - deleteNote: (id: string) => - client.DELETE("/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 b5d1b0f..31cd5aa 100644 --- a/packages/core/src/schema.d.ts +++ b/packages/core/src/schema.d.ts @@ -372,6 +372,8 @@ export interface components { type: string; /** Surah Number */ surah_number: number; + /** Surah Name */ + surah_name: string; /** Verse Number */ verse_number: number; /** Group */ @@ -390,6 +392,16 @@ export interface components { * Format: date-time */ created_at: string; + /** + * Arabic Text + * @default + */ + arabic_text: string; + /** + * Translation + * @default + */ + translation: string; }; /** GoogleAccessTokenRequest */ GoogleAccessTokenRequest: { From f8e9f5a6444ac2cd7a8b1750a13b418c196c372b Mon Sep 17 00:00:00 2001 From: up2dul Date: Wed, 20 May 2026 07:11:37 +0700 Subject: [PATCH 4/6] fix(api/migrations): strip redundant ops and backfill surah/verse number In bookmarks migration --- ...9_1749-788ac94a8323_add_bookmarks_table.py | 191 ++---------------- 1 file changed, 13 insertions(+), 178 deletions(-) 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 index 769dd04..67608c5 100644 --- a/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py +++ b/apps/api/alembic/versions/20260519_1749-788ac94a8323_add_bookmarks_table.py @@ -21,68 +21,20 @@ def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "topics", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("slug", sa.String(length=80), nullable=False), - sa.Column("canonical_query", sa.String(length=255), nullable=False), - sa.Column("embedding", sa.Text(), nullable=True), - sa.Column("search_count", sa.Integer(), nullable=False), - sa.Column("status", sa.String(length=20), nullable=False), - sa.Column("step", sa.String(length=200), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("completed_at", sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_topics_canonical_query"), "topics", ["canonical_query"], unique=True - ) - op.create_index(op.f("ix_topics_slug"), "topics", ["slug"], unique=True) - op.create_index(op.f("ix_topics_status"), "topics", ["status"], unique=False) - op.create_table( - "topic_results", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("topic_id", sa.UUID(), nullable=False), - sa.Column("ayah_key", sa.String(length=20), nullable=False), - sa.Column("surah_name", sa.String(length=100), nullable=False), - sa.Column("arabic_text", sa.Text(), nullable=False), - sa.Column("translation", sa.Text(), nullable=False), - sa.Column("why_this_verse", sa.Text(), nullable=True), - sa.Column("rank", sa.Integer(), nullable=False), - sa.Column("relevance_score", sa.Float(), nullable=False), - sa.Column("url", sa.Text(), nullable=False), - sa.Column("tafsir_excerpt", sa.Text(), nullable=True), - sa.Column("tafsir_author", sa.String(length=255), nullable=True), - sa.Column("tafsir_edition", sa.String(length=100), nullable=True), - sa.ForeignKeyConstraint(["topic_id"], ["topics.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_topic_results_topic_id"), "topic_results", ["topic_id"], unique=False - ) - op.create_table( - "user_searches", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=True), - sa.Column("session_id", sa.String(length=255), nullable=True), - sa.Column("topic_id", sa.UUID(), nullable=False), - sa.Column("user_query", sa.Text(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["topic_id"], ["topics.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_user_searches_topic_id"), "user_searches", ["topic_id"], unique=False + 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.drop_table("profiles") - op.drop_index(op.f("ix_searches_slug"), table_name="searches") - op.drop_index(op.f("ix_searches_status"), table_name="searches") - op.drop_table("searches") - op.add_column("bookmarks", sa.Column("surah_number", sa.Integer(), nullable=False)) - op.add_column("bookmarks", sa.Column("verse_number", sa.Integer(), nullable=False)) + + 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") @@ -93,31 +45,9 @@ def upgrade() -> None: op.create_foreign_key( None, "notes", "users", ["user_id"], ["id"], ondelete="CASCADE" ) - op.add_column( - "users", - sa.Column( - "preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=True - ), - ) - op.add_column("users", sa.Column("qf_sub", sa.String(length=255), nullable=True)) - op.add_column( - "users", sa.Column("qf_refresh_token", sa.String(length=512), nullable=True) - ) - op.add_column( - "users", sa.Column("qf_id_token", sa.String(length=2048), nullable=True) - ) - op.create_unique_constraint(None, "users", ["qf_sub"]) - # ### end Alembic commands ### def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, "users", type_="unique") - op.drop_column("users", "qf_id_token") - op.drop_column("users", "qf_refresh_token") - op.drop_column("users", "qf_sub") - op.drop_column("users", "preferences") op.drop_constraint(None, "notes", type_="foreignkey") op.create_index(op.f("ix_notes_user_id"), "notes", ["user_id"], unique=False) op.add_column( @@ -149,98 +79,3 @@ def downgrade() -> None: op.drop_constraint(None, "bookmarks", type_="foreignkey") op.drop_column("bookmarks", "verse_number") op.drop_column("bookmarks", "surah_number") - op.create_table( - "searches", - sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), - sa.Column("slug", sa.VARCHAR(length=50), autoincrement=False, nullable=False), - sa.Column("topic", sa.TEXT(), autoincrement=False, nullable=False), - sa.Column("status", sa.VARCHAR(length=20), autoincrement=False, nullable=False), - sa.Column("step", sa.TEXT(), autoincrement=False, nullable=True), - sa.Column( - "raw_results", - postgresql.JSON(astext_type=sa.Text()), - autoincrement=False, - nullable=True, - ), - sa.Column( - "results", - postgresql.JSON(astext_type=sa.Text()), - autoincrement=False, - nullable=True, - ), - sa.Column("user_id", sa.UUID(), autoincrement=False, nullable=True), - sa.Column( - "session_id", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column( - "created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False - ), - sa.Column( - "updated_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("searches_user_id_fkey"), - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id", name=op.f("searches_pkey")), - sa.UniqueConstraint( - "slug", - name=op.f("searches_slug_key"), - postgresql_include=[], - postgresql_nulls_not_distinct=False, - ), - ) - op.create_index(op.f("ix_searches_status"), "searches", ["status"], unique=False) - op.create_index(op.f("ix_searches_slug"), "searches", ["slug"], unique=True) - op.create_table( - "profiles", - sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), - sa.Column("user_id", sa.UUID(), autoincrement=False, nullable=False), - sa.Column( - "headline", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column("summary", sa.TEXT(), autoincrement=False, nullable=True), - sa.Column("phone", sa.VARCHAR(length=50), autoincrement=False, nullable=True), - sa.Column( - "location", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column( - "linkedin_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column( - "github_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column( - "portfolio_url", sa.VARCHAR(length=255), autoincrement=False, nullable=True - ), - sa.Column( - "created_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False - ), - sa.Column( - "updated_at", postgresql.TIMESTAMP(), autoincrement=False, nullable=False - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - name=op.f("profiles_user_id_fkey"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("profiles_pkey")), - sa.UniqueConstraint( - "user_id", - name=op.f("profiles_user_id_key"), - postgresql_include=[], - postgresql_nulls_not_distinct=False, - ), - ) - op.drop_index(op.f("ix_user_searches_topic_id"), table_name="user_searches") - op.drop_table("user_searches") - op.drop_index(op.f("ix_topic_results_topic_id"), table_name="topic_results") - op.drop_table("topic_results") - op.drop_index(op.f("ix_topics_status"), table_name="topics") - op.drop_index(op.f("ix_topics_slug"), table_name="topics") - op.drop_index(op.f("ix_topics_canonical_query"), table_name="topics") - op.drop_table("topics") - # ### end Alembic commands ### From 2dd0bceea67e0137c6ad17ce9e7d704391f44744 Mon Sep 17 00:00:00 2001 From: up2dul Date: Wed, 20 May 2026 07:12:10 +0700 Subject: [PATCH 5/6] feat(web): remove foot_note from bookmarks response --- apps/web/src/lib/utils.ts | 4 ++++ apps/web/src/routes/bookmarks.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 068395e..7f183a6 100644 --- a/apps/web/src/routes/bookmarks.tsx +++ b/apps/web/src/routes/bookmarks.tsx @@ -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"; @@ -178,7 +179,10 @@ function BookmarksList({ >

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

    @@ -217,7 +221,8 @@ function BookmarksList({ {bookmark.arabic_text && (

    {bookmark.arabic_text} @@ -226,7 +231,7 @@ function BookmarksList({ {bookmark.translation && (

    - {bookmark.translation} + {removeSuperscriptTags(bookmark.translation)}

    )} From bcb7875eec1ee4164caf6557bf674cabe1d27435 Mon Sep 17 00:00:00 2001 From: Radit Date: Wed, 20 May 2026 09:10:01 +0700 Subject: [PATCH 6/6] feat(token cache & delete bookmark): implement user token caching and fix bookmark deletion api --- apps/api/app/modules/auth/qf_service.py | 13 ++ apps/api/app/modules/bookmarks/service.py | 5 +- .../src/modules/bookmarks/data/mutations.ts | 33 +++- apps/web/src/routes/bookmarks.tsx | 161 ++++++++++-------- 4 files changed, 135 insertions(+), 77 deletions(-) diff --git a/apps/api/app/modules/auth/qf_service.py b/apps/api/app/modules/auth/qf_service.py index 6b708d8..979513b 100644 --- a/apps/api/app/modules/auth/qf_service.py +++ b/apps/api/app/modules/auth/qf_service.py @@ -175,6 +175,8 @@ async def call_qf_api( }, ) resp.raise_for_status() + if resp.status_code == 204: + return {"success": True} return resp.json() except httpx.HTTPStatusError as e: logger.error( @@ -194,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 @@ -258,6 +270,7 @@ async def login_or_create_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: diff --git a/apps/api/app/modules/bookmarks/service.py b/apps/api/app/modules/bookmarks/service.py index 97cc75a..68d2e5a 100644 --- a/apps/api/app/modules/bookmarks/service.py +++ b/apps/api/app/modules/bookmarks/service.py @@ -2,6 +2,7 @@ from typing import Any from fastapi import HTTPException, status +from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession from app.api.bookmarks.serializer import BookmarkResponse @@ -177,10 +178,12 @@ async def delete_bookmark( data = await qf_service.call_qf_api( access_token, - f"/auth/v1/bookmarks/{bookmark_id}", + f"/auth/v1/collections/__default__/bookmarks/{bookmark_id}", method="DELETE", ) + logger.info("QF delete response: {}", data) + if data is None: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, diff --git a/apps/web/src/modules/bookmarks/data/mutations.ts b/apps/web/src/modules/bookmarks/data/mutations.ts index 5780baa..28be407 100644 --- a/apps/web/src/modules/bookmarks/data/mutations.ts +++ b/apps/web/src/modules/bookmarks/data/mutations.ts @@ -5,6 +5,7 @@ import { api, queryKeys } from "@/lib/api"; type BookmarkResponse = components["schemas"]["BookmarkResponse"]; type BookmarkCreate = components["schemas"]["BookmarkCreate"]; +type BookmarkListResponse = components["schemas"]["BookmarkListResponse"]; export function useCreateBookmark() { const queryClient = useQueryClient(); @@ -26,17 +27,39 @@ export function useCreateBookmark() { export function useDeleteBookmark() { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + void, + Error, + string, + { previous: BookmarkListResponse | undefined } + >({ mutationFn: async (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 }); - toast.success("Bookmark removed"); + 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 }; }, - onError: (error) => { + onError: (error, _id, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.bookmarks.all, context.previous); + } toast.error(error.message); }, + onSuccess: () => { + toast.success("Bookmark removed"); + }, }); } diff --git a/apps/web/src/routes/bookmarks.tsx b/apps/web/src/routes/bookmarks.tsx index 7f183a6..343ec3a 100644 --- a/apps/web/src/routes/bookmarks.tsx +++ b/apps/web/src/routes/bookmarks.tsx @@ -172,79 +172,98 @@ function BookmarksList({ className="space-y-4" > {bookmarks.map((bookmark) => ( - -
    -

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

    - - - - } - > - Delete - - - - - Delete bookmark {bookmark.ayah_key} - - - This removes the verse from your bookmarks. - - - - Cancel - onDelete(bookmark.id)} - > - 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, - })} -

    -
    + 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, + })} +

    +
    + ); +}