diff --git a/package.json b/package.json index f87d186..ec921a2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@hookform/resolvers": "^3.9.0", "@marsidev/react-turnstile": "^1.0.2", "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -33,6 +34,7 @@ "clsx": "^2.1.1", "epubjs": "^0.3.93", "input-otp": "^1.2.4", + "jose": "^5.9.2", "lucide-react": "^0.441.0", "ofetch": "^1.3.4", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e03015..fda9b5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-aspect-ratio': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-collapsible': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -65,6 +68,9 @@ importers: input-otp: specifier: ^1.2.4 version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + jose: + specifier: ^5.9.2 + version: 5.9.2 lucide-react: specifier: ^0.441.0 version: 0.441.0(react@18.3.1) @@ -1315,6 +1321,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.0': + resolution: {integrity: sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.0': resolution: {integrity: sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==} peerDependencies: @@ -3099,6 +3118,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@5.9.2: + resolution: {integrity: sha512-ILI2xx/I57b20sd7rHZvgiiQrmp2mcotwsAH+5ajbpFQbrYVQdNHYlQhoA5cFb78CgtBOxtC05TeA+mcgkuCqQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5737,6 +5759,18 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-avatar@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-collapsible@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -7827,6 +7861,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@5.9.2: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: diff --git a/src/api/backend/auth/signin.ts b/src/api/backend/auth/signin.ts index 6f17d8f..2647cef 100644 --- a/src/api/backend/auth/signin.ts +++ b/src/api/backend/auth/signin.ts @@ -7,3 +7,12 @@ export const login = (body: { code: string; ttkn: string }) => { body, }); }; + +export const refresh = (refreshToken: string) => { + return client("/_secure/refresh", { + method: "POST", + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + }); +}; diff --git a/src/api/backend/auth/sync.ts b/src/api/backend/auth/sync.ts new file mode 100644 index 0000000..0e6f787 --- /dev/null +++ b/src/api/backend/auth/sync.ts @@ -0,0 +1,29 @@ +import { authClient } from "../base"; + +export const syncUserData = async ({ + username, + bookmarks, + preferences, + readingLists, +}: { + username?: string; + bookmarks?: string[]; + preferences?: Record; + readingLists?: Record; +}) => { + await authClient<{ message: string }>("/_secure/sync", { + method: "POST", + body: { + username, + bookmarks, + preferences, + reading_lists: readingLists, + }, + }); +}; + +export const getUserData = async () => { + return authClient<{ bookmarks: string[]; preferences: Record; reading_lists: Record }>("/_secure/get", { + method: "GET", + }); +}; diff --git a/src/api/backend/base.ts b/src/api/backend/base.ts index c38093a..f90f0fb 100644 --- a/src/api/backend/base.ts +++ b/src/api/backend/base.ts @@ -1,7 +1,9 @@ +import { useAuthStore } from "@/stores/auth"; import { useSettingsStore } from "@/stores/settings"; import { ofetch } from "ofetch"; +import { refresh } from "./auth/signin"; -export type BaseRequest = { +export type BaseResponse = { results: T[]; }; @@ -10,3 +12,49 @@ const backendURL = useSettingsStore.getState().backendURL; export const client = ofetch.create({ baseURL: backendURL + `/api`, }); + +export const authClient = ofetch.create({ + baseURL: backendURL + `/api`, + async onRequest(context) { + const { accessToken, refreshToken, tokenInfo } = useAuthStore.getState(); + + let accessTokenToSend = accessToken; + + if (!tokenInfo) { + useAuthStore.getState().reset(); + return; + } + + const currentTime = Math.floor(Date.now() / 1000); + const expirationTime = tokenInfo.exp; + const timeLeft = expirationTime - currentTime; + + if (timeLeft <= 10) { + try { + const response = await refresh(refreshToken); + if (response.access_token && response.refresh_token) { + const valid = useAuthStore.getState().setTokens(response.access_token, response.refresh_token); + + if (!valid) { + useAuthStore.getState().reset(); + return; + } + } + accessTokenToSend = response.access_token; + } catch { + useAuthStore.getState().reset(); + return; + } + } + + context.options.headers = { + ...context.options.headers, + Authorization: `Bearer ${accessTokenToSend}`, + }; + }, + onResponseError(context) { + if (context.response?.status === 401) { + useAuthStore.getState().reset(); + } + }, +}); diff --git a/src/api/backend/search/books.ts b/src/api/backend/search/books.ts new file mode 100644 index 0000000..3b34926 --- /dev/null +++ b/src/api/backend/search/books.ts @@ -0,0 +1,19 @@ +import { queryOptions } from "@tanstack/react-query"; +import { BaseResponse, client } from "../base"; +import { BookItem } from "../types"; + +export const searchBooksByMd5 = async (md5s: string[]) => { + if (!md5s.length) return null; + return client>("/_secure/translate", { + query: { + md5: md5s.join(","), + }, + }); +}; + +export const searchBooksByMd5QueryOptions = (md5s: string[]) => + queryOptions({ + queryKey: ["search", "books", md5s], + queryFn: () => searchBooksByMd5(md5s), + enabled: !!md5s.length, + }); diff --git a/src/api/backend/search/search.ts b/src/api/backend/search/search.ts index 6bb0ea1..dae6973 100644 --- a/src/api/backend/search/search.ts +++ b/src/api/backend/search/search.ts @@ -1,11 +1,11 @@ import { useQuery } from "@tanstack/react-query"; -import { BaseRequest, client } from "../base"; +import { BaseResponse, client } from "../base"; import { BookItem } from "../types"; import { SearchParams } from "./types"; import { getExternalDownloads } from "../downloads/external"; export const getBooks = (params: SearchParams) => { - return client>("/books", { + return client>("/books", { query: params, }); }; diff --git a/src/components/books/bookmark.tsx b/src/components/books/bookmark.tsx index 1b25353..b398baa 100644 --- a/src/components/books/bookmark.tsx +++ b/src/components/books/bookmark.tsx @@ -10,13 +10,13 @@ export function BookmarkButton({ book }: { book: BookItem }) { const addBookmark = useBookmarksStore((state) => state.addBookmark); const removeBookmark = useBookmarksStore((state) => state.removeBookmark); - const bookMarkedBook = useMemo(() => bookmarks.find((b) => b.md5 === book.md5), [bookmarks, book.md5]); + const bookMarkedBook = useMemo(() => bookmarks.find((b) => b === book.md5), [bookmarks, book.md5]); return ( - diff --git a/src/components/layout/clipboard-button.tsx b/src/components/layout/clipboard-button.tsx index bc6e3a9..06b45ef 100644 --- a/src/components/layout/clipboard-button.tsx +++ b/src/components/layout/clipboard-button.tsx @@ -17,7 +17,7 @@ export function ClipBoardButton(props: ClipBoardButtonProps) { navigator.clipboard.writeText(props.content); setClickedOnClipBoard(true); props.onClick?.(); - toast.success("Link copied to clipboard"); + toast.success("Copied to clipboard"); setTimeout(() => { setClickedOnClipBoard(false); diff --git a/src/components/layout/menu.tsx b/src/components/layout/menu.tsx index dcf2028..550c94f 100644 --- a/src/components/layout/menu.tsx +++ b/src/components/layout/menu.tsx @@ -1,13 +1,13 @@ -import React from "react"; -import { Ellipsis } from "lucide-react"; +import { Ellipsis, LogOut } from "lucide-react"; import { cn } from "@/lib/utils"; import { useLayout } from "@/hooks/use-layout"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"; -import { Link } from "@tanstack/react-router"; +import { Link, useRouteContext } from "@tanstack/react-router"; import { CollapseMenuButton } from "./collapse-menu-button"; +import { useAuth } from "@/hooks/auth/use-auth"; interface MenuProps { isOpen: boolean | undefined; @@ -16,6 +16,10 @@ interface MenuProps { export function Menu({ isOpen, closeSheetMenu }: MenuProps) { const { menuList } = useLayout(); + const { handleLogout } = useAuth(); + const routeContext = useRouteContext({ + from: "__root__", + }); return ( @@ -78,6 +82,24 @@ export function Menu({ isOpen, closeSheetMenu }: MenuProps) { )} ))} + + {routeContext.auth.isLoggedIn ? ( +
  • + + + + + + {isOpen === false && Log out} + + +
  • + ) : null}
    diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 8f7f6cf..6410029 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -3,6 +3,7 @@ import { SheetMenu } from "./sheet-menu"; import { useLayout } from "@/hooks/use-layout"; import { useLayoutStore } from "@/stores/layout"; import { ThemeToggle } from "./theme-toggle"; +import { UserNav } from "./user-nav"; export function Navbar() { const { pageTitle } = useLayout(); @@ -21,6 +22,7 @@ export function Navbar() {

    {pageTitle ?? pageTitleFromStore}

    +
    diff --git a/src/components/layout/user-nav.tsx b/src/components/layout/user-nav.tsx new file mode 100644 index 0000000..ae53c95 --- /dev/null +++ b/src/components/layout/user-nav.tsx @@ -0,0 +1,69 @@ +import { LogOut } from "lucide-react"; +import { useEffect } from "react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } from "../ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { useAuth } from "@/hooks/auth/use-auth"; +import { useAuthStore } from "@/stores/auth"; +import Logo from "@/assets/logo.svg"; +import { useRouteContext } from "@tanstack/react-router"; + +export function UserNav() { + const { handleLogout } = useAuth(); + const displayName = useAuthStore((state) => state.displayName); + + const routeContext = useRouteContext({ + from: "__root__", + }); + + useEffect(() => { + window.addEventListener("keydown", (e) => { + if (e.key === "q" && (e.ctrlKey || e.metaKey)) { + handleLogout(); + } + }); + + return () => { + window.removeEventListener("keydown", () => {}); + }; + }); + + if (!routeContext.auth.isLoggedIn) return null; + + return ( + + + + + + + + + Profile + + + + + +
    +

    {displayName}

    +
    +
    + + + + + Log out + ⇧⌘Q + + +
    +
    + ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/hooks/auth/use-auth.ts b/src/hooks/auth/use-auth.ts new file mode 100644 index 0000000..bf22ebe --- /dev/null +++ b/src/hooks/auth/use-auth.ts @@ -0,0 +1,21 @@ +import { useAuthStore } from "@/stores/auth"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +export const useAuth = () => { + const reset = useAuthStore((state) => state.reset); + const navigate = useNavigate(); + + const handleLogout = useCallback(() => { + reset(); + navigate({ + to: "/login", + search: { + redirect: "/", + }, + replace: true, + }); + }, [reset, navigate]); + + return { handleLogout } as const; +}; diff --git a/src/hooks/auth/use-user-data-sync.ts b/src/hooks/auth/use-user-data-sync.ts new file mode 100644 index 0000000..c4226a1 --- /dev/null +++ b/src/hooks/auth/use-user-data-sync.ts @@ -0,0 +1,22 @@ +import { getUserData, syncUserData as syncUserDataApi } from "@/api/backend/auth/sync"; +import { useBookmarksStore } from "@/stores/bookmarks"; +import { useCallback } from "react"; + +export const useUserDataSync = () => { + const bookmarks = useBookmarksStore((state) => state.bookmarks); + const setBookmarks = useBookmarksStore((state) => state.setBookmarks); + + const syncUserData = useCallback(async () => { + try { + const userData = await getUserData().catch(() => ({ bookmarks: [] })); + setBookmarks([...new Set([...bookmarks, ...userData.bookmarks])]); + await syncUserDataApi({ + bookmarks: bookmarks, + }); + } catch { + // Do nothing, let login continue + } + }, [bookmarks, setBookmarks]); + + return { syncUserData } as const; +}; diff --git a/src/hooks/use-layout.ts b/src/hooks/use-layout.ts index 867a8b3..4e831da 100644 --- a/src/hooks/use-layout.ts +++ b/src/hooks/use-layout.ts @@ -2,8 +2,10 @@ import { PAGE_TITLES } from "@/constants"; import { useMatches } from "@tanstack/react-router"; import { useMemo } from "react"; import { getMenuList } from "../lib/layout"; +import { useSettingsStore } from "@/stores/settings"; export function useLayout() { + const beta = useSettingsStore((state) => state.beta); const matches = useMatches(); const currentMatch = matches.at(-1); @@ -17,7 +19,7 @@ export function useLayout() { .replace(/\/$/, ""); }, [currentMatch]); - const memoizedMenuList = useMemo(() => getMenuList(routeId ?? ""), [routeId]); + const memoizedMenuList = useMemo(() => getMenuList(routeId ?? "", beta), [routeId, beta]); const pageTitle = useMemo(() => { return PAGE_TITLES[routeId as keyof typeof PAGE_TITLES]; diff --git a/src/lib/layout.ts b/src/lib/layout.ts index f2f3ce0..839e8ed 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -21,7 +21,7 @@ type Group = { menus: Menu[]; }; -export function getMenuList(pathname: FileRouteTypes["fullPaths"] | string): Group[] { +export function getMenuList(pathname: FileRouteTypes["fullPaths"] | string, beta: boolean): Group[] { return [ { groupLabel: "General", @@ -60,7 +60,7 @@ export function getMenuList(pathname: FileRouteTypes["fullPaths"] | string): Gro active: pathname === "/account", icon: House, submenus: [], - disabled: import.meta.env.PROD, + disabled: import.meta.env.DEV ? false : !beta, }, { href: "/lists", diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts new file mode 100644 index 0000000..cdb3c38 --- /dev/null +++ b/src/lib/sync/index.ts @@ -0,0 +1,2 @@ +// This file should only be imported once in the root of the application +import "./user-data"; diff --git a/src/lib/sync/user-data.ts b/src/lib/sync/user-data.ts new file mode 100644 index 0000000..0f3ef1e --- /dev/null +++ b/src/lib/sync/user-data.ts @@ -0,0 +1,21 @@ +import { syncUserData } from "@/api/backend/auth/sync"; +import { useAuthStore } from "@/stores/auth"; +import { useBookmarksStore } from "@/stores/bookmarks"; + +useBookmarksStore.subscribe( + (state) => state.bookmarks, + async (bookmarks) => { + try { + const accessToken = useAuthStore.getState().accessToken; + if (!accessToken) return bookmarks; + + console.log("Syncing user data", bookmarks); + await syncUserData({ + bookmarks, + }); + } catch (error) { + console.error("Failed to sync user data", error); + } + return bookmarks; + }, +); diff --git a/src/main.tsx b/src/main.tsx index 690a0e7..5951336 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,12 +7,13 @@ import { Toaster } from "@/components/ui/sonner"; import { useSettingsStore } from "./stores/settings"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; - -import "./styles/global.css"; import { useAuthStore } from "./stores/auth"; import { Loader2 } from "lucide-react"; import { ScrollToTopButton } from "./components/layout/scroll-to-top-button"; +import "./lib/sync"; +import "./styles/global.css"; + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -32,7 +33,7 @@ const queryClient = new QueryClient({ }, }); -const router = createRouter({ +export const router = createRouter({ routeTree, context: { auth: { @@ -62,7 +63,9 @@ declare module "@tanstack/react-router" { export function App() { const theme = useSettingsStore((state) => state.theme); - const isLoggedIn = useAuthStore((state) => state.isLoggedIn); + const { accessToken, refreshToken } = useAuthStore(); + + const isLoggedIn = Boolean(accessToken && refreshToken); useEffect(() => { const root = window.document.documentElement; diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 5c791e1..173c5d4 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -1,18 +1,22 @@ -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; -import { useAuthStore } from "@/stores/auth"; +import { createFileRoute, redirect } from "@tanstack/react-router"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Trash2 } from "lucide-react"; +import { useSettingsStore } from "@/stores/settings"; +import { useMutation } from "@tanstack/react-query"; +import { syncUserData } from "@/api/backend/auth/sync"; +import { toast } from "sonner"; +import { useAuthStore } from "@/stores/auth"; export const Route = createFileRoute("/account")({ component: Account, beforeLoad: (ctx) => { - if (import.meta.env.PROD) throw redirect({ to: "/", search: { q: "" } }); + const beta = useSettingsStore.getState().beta; + if (import.meta.env.PROD && !beta) throw redirect({ to: "/", search: { q: "" } }); if (!ctx.context.auth.isLoggedIn) throw redirect({ to: "/login" }); }, }); @@ -22,24 +26,35 @@ const updateDisplayNameSchema = z.object({ }); function Account() { - const navigate = useNavigate(); - const reset = useAuthStore((state) => state.reset); + const displayName = useAuthStore((state) => state.displayName); + const setDisplayName = useAuthStore((state) => state.setDisplayName); const form = useForm>({ resolver: zodResolver(updateDisplayNameSchema), defaultValues: { - displayName: "", + displayName, + }, + }); + + const { mutate, isPending } = useMutation({ + mutationKey: ["updateDisplayName"], + mutationFn: syncUserData, + onSuccess: () => { + setDisplayName(form.getValues().displayName); + toast.success("Display name updated"); + }, + onError: () => { + toast.error("Failed to update display name"); }, }); - const handleLogout = () => { - reset(); - navigate({ to: "/login" }); + const handleSubmit = (data: z.infer) => { + mutate({ username: data.displayName }); }; return (
    - + Account Manage your account @@ -47,8 +62,7 @@ function Account() {
    - {/* TODO: Connect updating with backend */} - console.log(data))}> + )} /> - +
    - - - -
    ); diff --git a/src/routes/lists.tsx b/src/routes/lists.tsx index 449fd3b..845b84e 100644 --- a/src/routes/lists.tsx +++ b/src/routes/lists.tsx @@ -1,31 +1,43 @@ +import { searchBooksByMd5QueryOptions } from "@/api/backend/search/books"; import { BookList } from "@/components/books/book-list"; import { NavLink } from "@/components/ui/nav-link"; import { useBookmarksStore } from "@/stores/bookmarks"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/lists")({ component: Lists, + async beforeLoad(ctx) { + const bookmarks = useBookmarksStore.getState().bookmarks; + await ctx.context.queryClient.ensureQueryData(searchBooksByMd5QueryOptions(bookmarks)); + }, }); export function Lists() { const bookmarks = useBookmarksStore((state) => state.bookmarks); + const { data } = useQuery(searchBooksByMd5QueryOptions(bookmarks)); + + if (bookmarks.length === 0 || !data) { + return ( +
    +
    +

    No Bookmarks

    +

    + Start adding some books using the bookmark button. Start searching + here +

    +
    +
    + ); + } + return (
    - {bookmarks.length > 0 ? ( -

    Your Bookmarks

    - ) : ( -
    -

    No Bookmarks

    -

    - Start adding some books using the bookmark button. Start searching - here -

    -
    - )} +

    Your Bookmarks

    - +
    ); diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 361a449..c8e5bb8 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -14,11 +14,16 @@ import { useAuthStore } from "@/stores/auth"; import { toast } from "sonner"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { TurnstileWidget } from "@/components/layout/turnstile"; +import { useSettingsStore } from "@/stores/settings"; +import { useUserDataSync } from "@/hooks/auth/use-user-data-sync"; export const Route = createFileRoute("/login")({ component: Login, - beforeLoad: () => { - if (import.meta.env.PROD) throw redirect({ to: "/", search: { q: "" } }); + beforeLoad: (opts) => { + const beta = useSettingsStore.getState().beta; + if (!beta) throw redirect({ to: "/", search: { q: "" } }); + + if (opts.context.auth.isLoggedIn) throw redirect({ to: "/account" }); }, }); @@ -51,13 +56,16 @@ function InputOTPGroups() { function Login() { const { navigate } = useRouter(); const setTokens = useAuthStore((state) => state.setTokens); + const { syncUserData } = useUserDataSync(); const { mutate, isPending } = useMutation({ mutationKey: ["login"], mutationFn: login, - onSuccess: (data) => { + onSuccess: async (data) => { toast.success("Logged in successfully"); setTokens(data.access_token, data.refresh_token); + await syncUserData(); + navigate({ to: "/", search: { q: "" } }); }, onError: () => { diff --git a/src/routes/register.tsx b/src/routes/register.tsx index 95554c6..09f067a 100644 --- a/src/routes/register.tsx +++ b/src/routes/register.tsx @@ -15,13 +15,15 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For import { toast } from "sonner"; import { ClipBoardButton } from "@/components/layout/clipboard-button"; import { TurnstileWidget } from "@/components/layout/turnstile"; +import { useSettingsStore } from "@/stores/settings"; export const Route = createFileRoute("/register")({ component: Register, - beforeLoad: () => { - if (import.meta.env.PROD) throw redirect({ to: "/", search: { q: "" } }); - }, - loader: async (opts) => { + beforeLoad: async (opts) => { + const beta = useSettingsStore.getState().beta; + if (!beta) throw redirect({ to: "/", search: { q: "" } }); + + if (opts.context.auth.isLoggedIn) throw redirect({ to: "/account" }); await opts.context.queryClient.ensureQueryData(randomWordsWithNumberQueryOptions); }, }); diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index b0bde79..daa0347 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -18,7 +18,7 @@ import { cn } from "@/lib/utils"; const settingsFormSchema = z.object({ language: z.string(), backendURL: z.string().url(), - thumbnailGeneration: z.boolean(), + beta: z.boolean(), }); export const Route = createFileRoute("/settings")({ @@ -34,7 +34,7 @@ function Settings() { defaultValues: { language, backendURL, - thumbnailGeneration: false, + beta: false, }, }); @@ -52,7 +52,7 @@ function Settings() { const handleSubmit = () => { form.handleSubmit((data) => { - useSettingsStore.setState({ language: data.language, backendURL: data.backendURL }); + useSettingsStore.setState(data); toast.success("Settings saved successfully", { position: "top-right" }); setShowSave(false); })(); @@ -123,20 +123,18 @@ function Settings() { - Generate thumbnails - - Most of the time, books don't have thumbnails. You can enable this setting to generate them on the fly but it may slow down the page. This setting is a work in progress. - + Beta + Enable the beta feature to get early access to new features. This might include unstable or incomplete features. ( - Enable Thumbnails generation + Enable beta - + diff --git a/src/stores/auth.ts b/src/stores/auth.ts index e67d7b9..8b9f70b 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,14 +1,23 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; +import { decodeJwt } from "jose"; +import { z } from "zod"; + +const tokenSchema = z.object({ + exp: z.number(), + uuid: z.string(), +}); interface AuthStoreState { accessToken: string; refreshToken: string; - isLoggedIn: boolean; + + tokenInfo: z.infer | null; displayName: string; - setTokens: (accessToken: string, refreshToken: string) => void; + setTokens: (accessToken: string, refreshToken: string) => boolean; + setDisplayName: (displayName: string) => void; reset: () => void; } @@ -17,17 +26,26 @@ export const useAuthStore = create()( (set) => ({ accessToken: "", refreshToken: "", - isLoggedIn: false, - displayName: "", + tokenInfo: null, + + setTokens: (accessToken, refreshToken) => { + const payload = decodeJwt(accessToken); + const parsedPayload = tokenSchema.safeParse(payload); + + if (parsedPayload.success) { + set({ accessToken, refreshToken, tokenInfo: parsedPayload.data }); + } - setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken, isLoggedIn: true }), - reset: () => set({ accessToken: "", refreshToken: "", isLoggedIn: false }), + return parsedPayload.success; + }, + setDisplayName: (displayName) => set({ displayName }), + reset: () => set({ accessToken: "", refreshToken: "", displayName: "" }), }), { name: "BR::auth", storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ accessToken: state.accessToken, refreshToken: state.refreshToken }), + partialize: (state) => ({ accessToken: state.accessToken, refreshToken: state.refreshToken, displayName: state.displayName }), }, ), ); diff --git a/src/stores/bookmarks.ts b/src/stores/bookmarks.ts index cdf1eb5..70f4b80 100644 --- a/src/stores/bookmarks.ts +++ b/src/stores/bookmarks.ts @@ -1,23 +1,40 @@ -import { BookItem } from "@/api/backend/types"; +import { z } from "zod"; import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, subscribeWithSelector } from "zustand/middleware"; interface BookmarksStoreState { - bookmarks: BookItem[]; - addBookmark: (bookmark: BookItem) => void; + bookmarks: string[]; + setBookmarks: (bookmarks: string[]) => void; + addBookmark: (md5: string) => void; removeBookmark: (md5: string) => void; } +const versionOneBookmarkSchema = z.object({ + bookmarks: z.array(z.object({ md5: z.string() })), +}); + export const useBookmarksStore = create()( - persist( - (set) => ({ - bookmarks: [], - addBookmark: (bookmark) => set((state) => ({ bookmarks: [...state.bookmarks, bookmark] })), - removeBookmark: (md5) => set((state) => ({ bookmarks: state.bookmarks.filter((b) => b.md5 !== md5) })), - }), - { - name: "BR::bookmarks", - getStorage: () => localStorage, - }, + subscribeWithSelector( + persist( + (set) => ({ + bookmarks: [], + setBookmarks: (bookmarks) => set({ bookmarks }), + addBookmark: (bookmark) => set((state) => ({ bookmarks: [...state.bookmarks, bookmark] })), + removeBookmark: (md5) => set((state) => ({ bookmarks: state.bookmarks.filter((b) => b !== md5) })), + }), + { + name: "BR::bookmarks", + getStorage: () => localStorage, + version: 1, + migrate(persistedState, version) { + if (version === 0) { + const parsed = versionOneBookmarkSchema.safeParse(persistedState); + if (parsed.success) { + return { bookmarks: parsed.data.bookmarks.map((b) => b.md5) }; + } + } + }, + }, + ), ), ); diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 39475e6..312d0a7 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -7,10 +7,12 @@ interface SettingsStoreState { language: string; backendURL: string; theme: Theme; + beta: boolean; setLanguage: (language: string) => void; setBackendURL: (url: string) => void; setTheme: (theme: Theme) => void; + setBeta: (beta: boolean) => void; } const isOldState = (oldState: unknown): oldState is SettingsStoreState => { @@ -27,10 +29,12 @@ export const useSettingsStore = create()( language: "en", backendURL: "https://backend.bookracy.ru", theme: "dark", + beta: false, setLanguage: (language) => set({ language }), setBackendURL: (url) => set({ backendURL: url }), setTheme: (theme) => set({ theme }), + setBeta: (beta) => set({ beta }), }), { name: "BR::settings", diff --git a/tsconfig.node.json b/tsconfig.node.json index 89ccfa5..449f527 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,6 +5,7 @@ "paths": { "@/*": ["src/*"] }, + "target": "ES2020", "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "skipLibCheck": true,