From 483774f59720159dfc52778ec99f3c8a8fc16e94 Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 6 Mar 2024 21:43:15 +0100 Subject: [PATCH 1/7] feat: improved book details view --- .../src/books/details/BookDetailsScreen.tsx | 227 ++++-------------- .../web/src/books/details/CollectionsPane.tsx | 30 +++ packages/web/src/books/details/CoverPane.tsx | 29 +++ .../src/books/details/DataSourceSection.tsx | 23 +- .../web/src/books/details/DescriptionRow.tsx | 15 ++ .../src/books/details/MetadataItemList.tsx | 45 ++++ .../web/src/books/details/MetadataPane.tsx | 54 +++++ ...dataSection.tsx => MetadataSourcePane.tsx} | 11 +- packages/web/src/books/details/TagsRow.tsx | 38 +++ packages/web/src/books/getMetadataFromBook.ts | 26 +- packages/web/src/collections/states.ts | 6 +- packages/web/src/tags/helpers.ts | 23 +- packages/web/src/theme/theme.tsx | 13 + 13 files changed, 326 insertions(+), 214 deletions(-) create mode 100644 packages/web/src/books/details/CollectionsPane.tsx create mode 100644 packages/web/src/books/details/CoverPane.tsx create mode 100644 packages/web/src/books/details/DescriptionRow.tsx create mode 100644 packages/web/src/books/details/MetadataItemList.tsx create mode 100644 packages/web/src/books/details/MetadataPane.tsx rename packages/web/src/books/details/{MetadataSection.tsx => MetadataSourcePane.tsx} (93%) create mode 100644 packages/web/src/books/details/TagsRow.tsx diff --git a/packages/web/src/books/details/BookDetailsScreen.tsx b/packages/web/src/books/details/BookDetailsScreen.tsx index 35941c73..783cba61 100644 --- a/packages/web/src/books/details/BookDetailsScreen.tsx +++ b/packages/web/src/books/details/BookDetailsScreen.tsx @@ -1,6 +1,6 @@ import { FC, useState, useEffect } from "react" import Button from "@mui/material/Button" -import { MoreVertRounded, EditRounded } from "@mui/icons-material" +import { EditRounded } from "@mui/icons-material" import { TopBarNavigation } from "../../navigation/TopBarNavigation" import { List, @@ -10,96 +10,96 @@ import { Dialog, DialogTitle, DialogActions, - Chip, Typography, Drawer, DialogContent, TextField, useTheme, Box, - Divider + Container, + Stack } from "@mui/material" -import makeStyles from "@mui/styles/makeStyles" import { useNavigate, useParams } from "react-router-dom" import { Alert } from "@mui/material" -import { Cover } from "../Cover" import { useDownloadBook } from "../../download/useDownloadBook" import { ROUTES } from "../../constants" -import { useManageBookCollectionsDialog } from "../ManageBookCollectionsDialog" -import { - useBookTagsState, - useBookCollectionsState, - useEnrichedBookState -} from "../states" +import { useEnrichedBookState } from "../states" import { useLink } from "../../links/states" import { useEditLink } from "../../links/helpers" -import { useCSS } from "../../common/utils" -import { useManageBookTagsDialog } from "../ManageBookTagsDialog" import { DataSourceSection } from "./DataSourceSection" import { isDebugEnabled } from "../../debug/isDebugEnabled.shared" import { useRemoveDownloadFile } from "../../download/useRemoveDownloadFile" -import { libraryStateSignal } from "../../library/states" import { booksDownloadStateSignal } from "../../download/states" -import { useLocalSettings } from "../../settings/states" import { useProtectedTagIds, useTagsByIds } from "../../tags/helpers" import { useSignalValue } from "reactjrx" import { getMetadataFromBook } from "../getMetadataFromBook" -import { MetadataSection } from "./MetadataSection" +import { MetadataSourcePane } from "./MetadataSourcePane" +import { CoverPane } from "./CoverPane" +import { MetadataPane } from "./MetadataPane" +import { DebugInfo } from "../../debug/DebugInfo" +import { useRefreshBookMetadata } from "../helpers" +import { CollectionsPane } from "./CollectionsPane" type ScreenParams = { id: string } export const BookDetailsScreen = () => { - const { styles, classes } = useStyles() const theme = useTheme() const navigate = useNavigate() const downloadFile = useDownloadBook() + const refreshBookMetadata = useRefreshBookMetadata() const [isLinkActionDrawerOpenWith, setIsLinkActionDrawerOpenWith] = useState< undefined | string >(undefined) const { id = `-1` } = useParams() - const libraryState = useSignalValue(libraryStateSignal) const book = useEnrichedBookState({ bookId: id, normalizedBookDownloadsState: useSignalValue(booksDownloadStateSignal), protectedTagIds: useProtectedTagIds().data, tags: useTagsByIds().data }) - const tags = useBookTagsState({ bookId: id, tags: useTagsByIds().data }) - - const { data: collections } = useBookCollectionsState({ - bookId: id, - libraryState, - localSettingsState: useLocalSettings(), - protectedTagIds: useProtectedTagIds().data, - tags: useTagsByIds().data - }) - const { openManageBookCollectionsDialog } = useManageBookCollectionsDialog() - const { openManageBookTagsDialog } = useManageBookTagsDialog() const removeDownloadFile = useRemoveDownloadFile() const metadata = getMetadataFromBook(book) return ( -
-
-
- {book && } -
-
-
+ + {isDebugEnabled() && ( + + )} + + {metadata?.title || "Unknown"} - - By {(metadata?.authors ?? [])[0] || "Unknown"} + + By {metadata?.authors?.join(", ") || "Unknown"} -
+ { We are still retrieving metadata information... )} - - - - - - More details - - - Date:  - - {metadata?.date && metadata.date.year} - - - - Publisher:  - {metadata?.publisher} - - - Creator:  - - {metadata?.authors?.join(`, `)} - - - - Genre:  - - {metadata?.subjects?.join(`, `)} - - - - Language:  - - {metadata?.languages?.join(`, `)} - - - {isDebugEnabled() && ( - - id:  - {book?._id} - - )} - - - - - - openManageBookTagsDialog(id)}> - 0 ? ( - <> - {tags?.map((tag) => { - return - })} - - ) : ( - "No tags yet" - ) - } - /> - - - - book?._id && openManageBookCollectionsDialog(book?._id) - } - > - 0 ? ( - <> - {collections?.map((item) => ( - - ))} - - ) : ( - "Not a part of any collection yet" - ) - } - /> - - - - - + + + + + + + + setIsLinkActionDrawerOpenWith(undefined)} /> -
+ ) } @@ -342,48 +256,3 @@ const EditLinkDialog: FC<{ ) } - -const useClasses = makeStyles((theme) => ({ - coverContainer: { - width: "80%", - [theme.breakpoints.down("md")]: { - width: "40%" - }, - maxWidth: theme.custom.maxWidthCenteredContent - } -})) - -const useStyles = () => { - const theme = useTheme() - const classes = useClasses() - - const styles = useCSS( - () => ({ - headerContent: { - paddingBottom: theme.spacing(2), - paddingTop: theme.spacing(3), - display: "flex", - alignItems: "center", - justifyContent: "center" - }, - titleContainer: { - maxWidth: theme.custom.maxWidthCenteredContent, - margin: "auto", - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - marginBottom: theme.spacing(1), - display: "flex", - alignItems: "center", - flexFlow: "column", - justifyContent: "center", - textAlign: "center" - }, - cover: { - height: "20vh" - } - }), - [theme] - ) - - return { styles, classes } -} diff --git a/packages/web/src/books/details/CollectionsPane.tsx b/packages/web/src/books/details/CollectionsPane.tsx new file mode 100644 index 00000000..7db4ab6b --- /dev/null +++ b/packages/web/src/books/details/CollectionsPane.tsx @@ -0,0 +1,30 @@ +import { useCollectionsWithPrivacy } from "../../collections/states" +import { useManageBookCollectionsDialog } from "../ManageBookCollectionsDialog" +import { useBook } from "../states" +import { MetadataItemList } from "./MetadataItemList" + +export const CollectionsPane = ({ bookId }: { bookId?: string }) => { + const { data: book } = useBook({ id: bookId }) + const { openManageBookCollectionsDialog } = useManageBookCollectionsDialog() + const { data: collections } = useCollectionsWithPrivacy({ + queryObj: { + selector: { + _id: { + $in: book?.collections + } + } + } + }) + + return ( + item.name)} + emptyLabel="None yet" + onEditClick={() => { + book?._id && openManageBookCollectionsDialog(book?._id) + }} + mt={2} + /> + ) +} diff --git a/packages/web/src/books/details/CoverPane.tsx b/packages/web/src/books/details/CoverPane.tsx new file mode 100644 index 00000000..ba105985 --- /dev/null +++ b/packages/web/src/books/details/CoverPane.tsx @@ -0,0 +1,29 @@ +import { useTheme, Box, BoxProps } from "@mui/material" +import { Cover } from "../Cover" + +export const CoverPane = ({ bookId, ...rest }: { bookId?: string } & BoxProps) => { + const theme = useTheme() + + return ( + + + {!!bookId && } + + + ) +} diff --git a/packages/web/src/books/details/DataSourceSection.tsx b/packages/web/src/books/details/DataSourceSection.tsx index 80ba2bd4..544d0342 100644 --- a/packages/web/src/books/details/DataSourceSection.tsx +++ b/packages/web/src/books/details/DataSourceSection.tsx @@ -4,7 +4,8 @@ import { ListItem, ListItemIcon, ListItemText, - ListSubheader + ListSubheader, + Stack } from "@mui/material" import { MoreVertRounded } from "@mui/icons-material" import { FC, useState } from "react" @@ -23,12 +24,11 @@ export const DataSourceSection: FC<{ bookId: string }> = ({ bookId }) => { const dataSourcePlugin = useDataSourcePlugin(link?.type) const [isSelectItemOpened, setIsSelectItemOpened] = useState(false) const dialog = useDialogManager() - const refreshBookMetadata = useRefreshBookMetadata() const createRequestPopupDialog = useCreateRequestPopupDialog() return ( <> - Data source}> + Link}> {!!link && !!dataSourcePlugin && ( = ({ bookId }) => { }} secondary={`This book has been created from ${dataSourcePlugin.name}. Click to edit the data source`} /> - + + + )} - - {import.meta.env.DEV && ( - - )} {dataSourcePlugin?.SelectItemComponent && ( { + const { data: book } = useBook({ id: bookId }) + + const metadata = getMetadataFromBook(book) + + if (!metadata.description) { + return No synopsis! Try to refresh metadata. + } + + return {metadata.description} +} diff --git a/packages/web/src/books/details/MetadataItemList.tsx b/packages/web/src/books/details/MetadataItemList.tsx new file mode 100644 index 00000000..7c09eb4f --- /dev/null +++ b/packages/web/src/books/details/MetadataItemList.tsx @@ -0,0 +1,45 @@ +import { EditOutlined } from "@mui/icons-material" +import { Button, Chip, Stack, StackProps, Typography } from "@mui/material" +import { DeepReadonlyArray } from "rxdb/dist/types/types" + +export const MetadataItemList = ({ + values, + label, + onEditClick, + emptyLabel = "Unknown", + ...rest +}: { + values?: DeepReadonlyArray + label: string + emptyLabel?: string + onEditClick?: () => void +} & StackProps) => { + return ( + + + {label} + {!!onEditClick && ( + + )} + + + {!values?.length && {emptyLabel}} + {values?.map((item, index) => ( + {}} + /> + ))} + + + ) +} diff --git a/packages/web/src/books/details/MetadataPane.tsx b/packages/web/src/books/details/MetadataPane.tsx new file mode 100644 index 00000000..fbf6a455 --- /dev/null +++ b/packages/web/src/books/details/MetadataPane.tsx @@ -0,0 +1,54 @@ +import { Box, Container, List, ListItem, ListItemText, Stack } from "@mui/material" +import { TagsRow } from "./TagsRow" +import { DescriptionRow } from "./DescriptionRow" +import { MetadataItemList } from "./MetadataItemList" +import { useBook } from "../states" +import { getMetadataFromBook } from "../getMetadataFromBook" + +export const MetadataPane = ({ bookId }: { bookId?: string }) => { + const { data: book } = useBook({ id: bookId }) + + const metadata = getMetadataFromBook(book) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/web/src/books/details/MetadataSection.tsx b/packages/web/src/books/details/MetadataSourcePane.tsx similarity index 93% rename from packages/web/src/books/details/MetadataSection.tsx rename to packages/web/src/books/details/MetadataSourcePane.tsx index 4ccb5711..a6898985 100644 --- a/packages/web/src/books/details/MetadataSection.tsx +++ b/packages/web/src/books/details/MetadataSourcePane.tsx @@ -1,8 +1,5 @@ import { - Alert, - Box, List, - ListItem, ListItemButton, ListItemIcon, ListItemText, @@ -23,7 +20,7 @@ import { Metadata } from "@oboku/shared" import { useLink } from "../../links/states" import { getPluginFromType } from "../../plugins/getPluginFromType" -export const MetadataSection: FC<{ bookId: string }> = ({ bookId }) => { +export const MetadataSourcePane: FC<{ bookId: string }> = ({ bookId }) => { const { data: book } = useBook({ id: bookId }) const { data: link } = useLink({ id: book?.links[0] }) const plugin = getPluginFromType(link?.type) @@ -31,7 +28,7 @@ export const MetadataSection: FC<{ bookId: string }> = ({ bookId }) => { return ( <> - Metadata}> + Metadata sources}> {types.map((type) => { const metadata = book?.metadata?.find((item) => item.type === type) @@ -57,7 +54,7 @@ export const MetadataSection: FC<{ bookId: string }> = ({ bookId }) => { ? "Google Book API" : type === "user" ? "User" - : "Source"} + : "Link"} {type === "link" && ( ({plugin?.name}) @@ -100,7 +97,7 @@ export const MetadataSection: FC<{ bookId: string }> = ({ bookId }) => { ) } /> - + diff --git a/packages/web/src/books/details/TagsRow.tsx b/packages/web/src/books/details/TagsRow.tsx new file mode 100644 index 00000000..36c71163 --- /dev/null +++ b/packages/web/src/books/details/TagsRow.tsx @@ -0,0 +1,38 @@ +import { Button, Chip, Stack } from "@mui/material" +import { useTags } from "../../tags/helpers" +import { useBook } from "../states" +import { EditOutlined } from "@mui/icons-material" +import { useManageBookTagsDialog } from "../ManageBookTagsDialog" + +export const TagsRow = ({ bookId }: { bookId?: string }) => { + const { data: book } = useBook({ id: bookId }) + const { openManageBookTagsDialog } = useManageBookTagsDialog() + const { data: tags } = useTags({ + enabled: !!book?.tags.length, + queryObj: { + selector: { + _id: { + $in: book?.tags + } + } + } + }) + + return ( + + {tags?.map((tag) => ( + {}} /> + ))} + + + ) +} diff --git a/packages/web/src/books/getMetadataFromBook.ts b/packages/web/src/books/getMetadataFromBook.ts index b5df786b..944f89ba 100644 --- a/packages/web/src/books/getMetadataFromBook.ts +++ b/packages/web/src/books/getMetadataFromBook.ts @@ -1,7 +1,10 @@ import { BookDocType, DeprecatedBookDocType, Metadata } from "@oboku/shared" import { DeepReadonlyObject } from "rxdb" -type Return = DeepReadonlyObject> +type Return = DeepReadonlyObject> & { + language?: string + displayableDate?: string +} // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericObject = { [key: string]: any } @@ -11,6 +14,7 @@ function mergeObjects(a: GenericObject, b: GenericObject): GenericObject { (acc, [key, value]) => { // If the value in `b` is not `undefined`, use it; otherwise, retain the value from `a`. acc[key] = value !== undefined ? value : a[key] + return acc }, { ...a } @@ -36,9 +40,25 @@ export const getMetadataFromBook = ( ) const reducedMetadata = orderedList.reduce((acc, item) => { + const mergedValue = mergeObjects(acc, item) as Return + + const date = Object.values(item.date ?? {}).length ? item.date : acc.date + const subjects = item.subjects?.length ? item.subjects : acc.subjects + return { - ...mergeObjects(acc, item), - authors: [...(acc.authors ?? []), ...(item.authors ?? [])] + ...mergedValue, + date, + subjects, + authors: [...(acc.authors ?? []), ...(item.authors ?? [])], + language: (mergedValue.languages ?? [])[0], + displayableDate: + Object.keys(date ?? {}).length === 3 + ? new Date(`${date?.year} ${date?.month} ${date?.day}`).toDateString() + : date?.year !== undefined && date.month !== undefined + ? `${date?.year} ${date?.month}` + : date?.year !== undefined + ? `${date?.year}` + : undefined } satisfies Return }, {} as Return) diff --git a/packages/web/src/collections/states.ts b/packages/web/src/collections/states.ts index e6def27e..373b2922 100644 --- a/packages/web/src/collections/states.ts +++ b/packages/web/src/collections/states.ts @@ -72,8 +72,10 @@ export const useCollection = ({ id }: { id?: string }) => { }) } -export const useCollectionsWithPrivacy = () => { - const { data: collections } = useCollections() +export const useCollectionsWithPrivacy = ({ + queryObj +}: { queryObj?: MangoQuery } = {}) => { + const { data: collections } = useCollections({ queryObj }) const visibleBookIds = useVisibleBookIds() const { showCollectionWithProtectedContent } = useLocalSettings() diff --git a/packages/web/src/tags/helpers.ts b/packages/web/src/tags/helpers.ts index 158e3d1a..4c22415e 100644 --- a/packages/web/src/tags/helpers.ts +++ b/packages/web/src/tags/helpers.ts @@ -2,11 +2,11 @@ import { TagsDocType } from "@oboku/shared" import { useCallback } from "react" import { useDatabase } from "../rxdb" import { useMutation } from "reactjrx" -import { map, switchMap } from "rxjs" +import { map, mergeMap, switchMap } from "rxjs" import { useForeverQuery } from "reactjrx" -import { latestDatabase$ } from "../rxdb/useCreateDatabase" +import { getLatestDatabase, latestDatabase$ } from "../rxdb/useCreateDatabase" import { Database } from "../rxdb" -import { DeepReadonlyObject } from "rxdb" +import { DeepReadonlyObject, MangoQuery } from "rxdb" export const useCreateTag = () => { const { db } = useDatabase() @@ -106,10 +106,21 @@ export const useTag = (id: string) => ) }) -export const useTags = () => +export const useTags = ({ + queryObj, + ...options +}: { + enabled?: boolean + queryObj?: MangoQuery | undefined +}) => useForeverQuery({ - queryFn: () => tags$, - queryKey: ["rxdb", "tags"] + queryKey: ["rxdb", "tags", queryObj], + queryFn: () => + getLatestDatabase().pipe( + mergeMap((database) => database.tag.find(queryObj).$), + map((items) => items.map((item) => item.toJSON())) + ), + ...options }) export const useTagsByIds = () => diff --git a/packages/web/src/theme/theme.tsx b/packages/web/src/theme/theme.tsx index eaa58e96..88553bd9 100644 --- a/packages/web/src/theme/theme.tsx +++ b/packages/web/src/theme/theme.tsx @@ -71,6 +71,19 @@ export const theme = createTheme({ minWidth: 260 } } + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 6 + }, + sizeSmall: { + borderRadius: 6 + }, + sizeMedium: { + borderRadius: 6 + } + } } }, custom: { From 2ce21525d750bda6db8f3a0b0c315feafbe6c888 Mon Sep 17 00:00:00 2001 From: maxime Date: Wed, 6 Mar 2024 22:42:39 +0100 Subject: [PATCH 2/7] fix: fixed sm size --- .../src/books/details/BookDetailsScreen.tsx | 49 ++++++++++++------- .../src/books/details/DataSourceSection.tsx | 19 +++++-- .../src/books/details/MetadataSourcePane.tsx | 24 +++++++-- packages/web/src/theme/theme.tsx | 2 + 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/packages/web/src/books/details/BookDetailsScreen.tsx b/packages/web/src/books/details/BookDetailsScreen.tsx index 783cba61..b7d98a8c 100644 --- a/packages/web/src/books/details/BookDetailsScreen.tsx +++ b/packages/web/src/books/details/BookDetailsScreen.tsx @@ -1,6 +1,14 @@ import { FC, useState, useEffect } from "react" import Button from "@mui/material/Button" -import { EditRounded } from "@mui/icons-material" +import { + CloudDownloadRounded, + DeleteOutlineRounded, + DeleteRounded, + EditRounded, + FolderDelete, + FolderDeleteOutlined, + MenuBookOutlined +} from "@mui/icons-material" import { TopBarNavigation } from "../../navigation/TopBarNavigation" import { List, @@ -100,9 +108,11 @@ export const BookDetailsScreen = () => { By {metadata?.authors?.join(", ") || "Unknown"} - { > {book?.downloadState === "none" && ( )} {book?.downloadState === "downloading" && ( - )} {book?.downloadState === "downloaded" && ( )} {book?.downloadState === "downloaded" && ( - - - + )} - + {book?.metadataUpdateStatus === "fetching" && ( We are still retrieving metadata information... diff --git a/packages/web/src/books/details/DataSourceSection.tsx b/packages/web/src/books/details/DataSourceSection.tsx index 544d0342..0aaf6990 100644 --- a/packages/web/src/books/details/DataSourceSection.tsx +++ b/packages/web/src/books/details/DataSourceSection.tsx @@ -10,10 +10,8 @@ import { import { MoreVertRounded } from "@mui/icons-material" import { FC, useState } from "react" import { useDataSourcePlugin } from "../../dataSources/helpers" -import { DebugInfo } from "../../debug/DebugInfo" import { Report } from "../../debug/report.shared" import { useDialogManager } from "../../dialog" -import { useRefreshBookMetadata } from "../helpers" import { useBookLinksState } from "../states" import { useCreateRequestPopupDialog } from "../../plugins/useCreateRequestPopupDialog" import { upsertBookLink } from "../triggers" @@ -28,11 +26,26 @@ export const DataSourceSection: FC<{ bookId: string }> = ({ bookId }) => { return ( <> - Link}> + + Link + + } + > {!!link && !!dataSourcePlugin && ( { if (!dataSourcePlugin?.SelectItemComponent) { dialog({ preset: "NOT_IMPLEMENTED" }) diff --git a/packages/web/src/books/details/MetadataSourcePane.tsx b/packages/web/src/books/details/MetadataSourcePane.tsx index a6898985..fbcef078 100644 --- a/packages/web/src/books/details/MetadataSourcePane.tsx +++ b/packages/web/src/books/details/MetadataSourcePane.tsx @@ -5,7 +5,8 @@ import { ListItemText, ListSubheader, Stack, - Typography + Typography, + useTheme } from "@mui/material" import { Google, @@ -28,7 +29,19 @@ export const MetadataSourcePane: FC<{ bookId: string }> = ({ bookId }) => { return ( <> - Metadata sources}> + + Metadata sources + + } + disablePadding + > {types.map((type) => { const metadata = book?.metadata?.find((item) => item.type === type) @@ -38,7 +51,12 @@ export const MetadataSourcePane: FC<{ bookId: string }> = ({ bookId }) => { : 0 return ( - + {type === "file" && } {type === "link" && } diff --git a/packages/web/src/theme/theme.tsx b/packages/web/src/theme/theme.tsx index 88553bd9..6b27857a 100644 --- a/packages/web/src/theme/theme.tsx +++ b/packages/web/src/theme/theme.tsx @@ -36,6 +36,7 @@ export const theme = createTheme({ MuiButton: { defaultProps: { focusRipple: false, + disableElevation: true, variant: "outlined" }, styleOverrides: { @@ -73,6 +74,7 @@ export const theme = createTheme({ } }, MuiChip: { + defaultProps: {}, styleOverrides: { root: { borderRadius: 6 From 89d608d4841ba6258b91df5ea5326c33093f00ac Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 7 Mar 2024 09:44:14 +0100 Subject: [PATCH 3/7] fix: fixed book details screen --- .../src/books/details/BookDetailsScreen.tsx | 107 ++++++++---------- packages/web/src/books/details/CoverPane.tsx | 10 +- .../src/books/drawer/BookActionsDrawer.tsx | 1 - packages/web/src/tags/helpers.ts | 2 +- 4 files changed, 52 insertions(+), 68 deletions(-) diff --git a/packages/web/src/books/details/BookDetailsScreen.tsx b/packages/web/src/books/details/BookDetailsScreen.tsx index b7d98a8c..84d9bd0a 100644 --- a/packages/web/src/books/details/BookDetailsScreen.tsx +++ b/packages/web/src/books/details/BookDetailsScreen.tsx @@ -2,12 +2,9 @@ import { FC, useState, useEffect } from "react" import Button from "@mui/material/Button" import { CloudDownloadRounded, - DeleteOutlineRounded, - DeleteRounded, EditRounded, - FolderDelete, - FolderDeleteOutlined, - MenuBookOutlined + MenuBookOutlined, + MoreVertOutlined } from "@mui/icons-material" import { TopBarNavigation } from "../../navigation/TopBarNavigation" import { @@ -22,10 +19,9 @@ import { Drawer, DialogContent, TextField, - useTheme, - Box, Container, - Stack + Stack, + IconButton } from "@mui/material" import { useNavigate, useParams } from "react-router-dom" import { Alert } from "@mui/material" @@ -35,8 +31,6 @@ import { useEnrichedBookState } from "../states" import { useLink } from "../../links/states" import { useEditLink } from "../../links/helpers" import { DataSourceSection } from "./DataSourceSection" -import { isDebugEnabled } from "../../debug/isDebugEnabled.shared" -import { useRemoveDownloadFile } from "../../download/useRemoveDownloadFile" import { booksDownloadStateSignal } from "../../download/states" import { useProtectedTagIds, useTagsByIds } from "../../tags/helpers" import { useSignalValue } from "reactjrx" @@ -45,18 +39,16 @@ import { MetadataSourcePane } from "./MetadataSourcePane" import { CoverPane } from "./CoverPane" import { MetadataPane } from "./MetadataPane" import { DebugInfo } from "../../debug/DebugInfo" -import { useRefreshBookMetadata } from "../helpers" import { CollectionsPane } from "./CollectionsPane" +import { bookActionDrawerSignal } from "../drawer/BookActionsDrawer" type ScreenParams = { id: string } export const BookDetailsScreen = () => { - const theme = useTheme() const navigate = useNavigate() const downloadFile = useDownloadBook() - const refreshBookMetadata = useRefreshBookMetadata() const [isLinkActionDrawerOpenWith, setIsLinkActionDrawerOpenWith] = useState< undefined | string >(undefined) @@ -67,8 +59,6 @@ export const BookDetailsScreen = () => { protectedTagIds: useProtectedTagIds().data, tags: useTagsByIds().data }) - const removeDownloadFile = useRemoveDownloadFile() - const metadata = getMetadataFromBook(book) return ( @@ -77,47 +67,44 @@ export const BookDetailsScreen = () => { flex: 1, overflow: "auto" }} + pb={4} gap={2} > - {isDebugEnabled() && ( - - )} - - {metadata?.title || "Unknown"} - - By {metadata?.authors?.join(", ") || "Unknown"} - + + + + {metadata?.title || "Unknown"} + + + By {metadata?.authors?.join(", ") || "Unknown"} + + - {book?.downloadState === "none" && ( @@ -135,22 +122,13 @@ export const BookDetailsScreen = () => { Downloading... )} - {book?.downloadState === "downloaded" && ( - - )} {book?.downloadState === "downloaded" && ( )} - + { + bookActionDrawerSignal.setValue({ openedWith: book?._id }) + }} + > + + + {book?.metadataUpdateStatus === "fetching" && ( We are still retrieving metadata information... @@ -168,10 +153,10 @@ export const BookDetailsScreen = () => { - + - + { +export const CoverPane = ({ + bookId, + ...rest +}: { bookId?: string } & BoxProps) => { const theme = useTheme() return ( @@ -15,10 +18,7 @@ export const CoverPane = ({ bookId, ...rest }: { bookId?: string } & BoxProps) = > diff --git a/packages/web/src/books/drawer/BookActionsDrawer.tsx b/packages/web/src/books/drawer/BookActionsDrawer.tsx index 598164f9..4805e2d4 100644 --- a/packages/web/src/books/drawer/BookActionsDrawer.tsx +++ b/packages/web/src/books/drawer/BookActionsDrawer.tsx @@ -82,7 +82,6 @@ export const BookActionsDrawer = memo(() => { anchor="bottom" open={opened} onClose={() => handleClose()} - transitionDuration={0} > {book && ( <> diff --git a/packages/web/src/tags/helpers.ts b/packages/web/src/tags/helpers.ts index 4c22415e..3a9836a8 100644 --- a/packages/web/src/tags/helpers.ts +++ b/packages/web/src/tags/helpers.ts @@ -112,7 +112,7 @@ export const useTags = ({ }: { enabled?: boolean queryObj?: MangoQuery | undefined -}) => +} = {}) => useForeverQuery({ queryKey: ["rxdb", "tags", queryObj], queryFn: () => From 411ce97e6b619dbe5a932836583fe748a05e08bd Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 7 Mar 2024 11:27:01 +0100 Subject: [PATCH 4/7] feat: improved dialog management --- .../src/books/details/BookDetailsScreen.tsx | 28 +++- .../src/books/drawer/BookActionsDrawer.tsx | 69 +++++++-- .../web/src/books/drawer/useRemoveHandler.ts | 142 +++++++++++------- packages/web/src/books/helpers.ts | 3 +- packages/web/src/dialog.tsx | 73 ++++----- packages/web/src/download/useDownloadBook.ts | 18 ++- 6 files changed, 209 insertions(+), 124 deletions(-) diff --git a/packages/web/src/books/details/BookDetailsScreen.tsx b/packages/web/src/books/details/BookDetailsScreen.tsx index 84d9bd0a..631cc888 100644 --- a/packages/web/src/books/details/BookDetailsScreen.tsx +++ b/packages/web/src/books/details/BookDetailsScreen.tsx @@ -40,7 +40,8 @@ import { CoverPane } from "./CoverPane" import { MetadataPane } from "./MetadataPane" import { DebugInfo } from "../../debug/DebugInfo" import { CollectionsPane } from "./CollectionsPane" -import { bookActionDrawerSignal } from "../drawer/BookActionsDrawer" +import { useBookActionDrawer } from "../drawer/BookActionsDrawer" +import { useSafeGoBack } from "../../navigation/useSafeGoBack" type ScreenParams = { id: string @@ -49,6 +50,7 @@ type ScreenParams = { export const BookDetailsScreen = () => { const navigate = useNavigate() const downloadFile = useDownloadBook() + const { goBack } = useSafeGoBack() const [isLinkActionDrawerOpenWith, setIsLinkActionDrawerOpenWith] = useState< undefined | string >(undefined) @@ -60,6 +62,11 @@ export const BookDetailsScreen = () => { tags: useTagsByIds().data }) const metadata = getMetadataFromBook(book) + const openBookActionDrawer = useBookActionDrawer({ + onDeleteBook: () => { + goBack() + } + }) return ( { )} {book?.downloadState === "downloading" && ( - )} @@ -138,7 +157,10 @@ export const BookDetailsScreen = () => { )} { - bookActionDrawerSignal.setValue({ openedWith: book?._id }) + openBookActionDrawer({ + openedWith: book?._id, + actionsBlackList: ["goToDetails"] + }) }} > diff --git a/packages/web/src/books/drawer/BookActionsDrawer.tsx b/packages/web/src/books/drawer/BookActionsDrawer.tsx index 4805e2d4..8d51e7cd 100644 --- a/packages/web/src/books/drawer/BookActionsDrawer.tsx +++ b/packages/web/src/books/drawer/BookActionsDrawer.tsx @@ -1,4 +1,4 @@ -import { memo } from "react" +import { memo, useCallback } from "react" import List from "@mui/material/List" import ListItem from "@mui/material/ListItem" import ListItemText from "@mui/material/ListItemText" @@ -36,19 +36,51 @@ import { useManageBookTagsDialog } from "../ManageBookTagsDialog" import { markAsInterested } from "../triggers" import { booksDownloadStateSignal } from "../../download/states" import { useProtectedTagIds, useTagsByIds } from "../../tags/helpers" -import { signal, useSignalValue } from "reactjrx" +import { signal, useLiveRef, useSignalValue } from "reactjrx" import { useRemoveHandler } from "./useRemoveHandler" import { getMetadataFromBook } from "../getMetadataFromBook" -export const bookActionDrawerSignal = signal<{ +type SignalState = { openedWith: undefined | string actions?: ("removeDownload" | "goToDetails")[] -}>({ key: "bookActionDrawerState", default: { openedWith: undefined } }) + actionsBlackList?: ("removeDownload" | "goToDetails")[] + onDeleteBook?: () => void +} + +export const bookActionDrawerSignal = signal({ + key: "bookActionDrawerState", + default: { openedWith: undefined } +}) + +export const useBookActionDrawer = ({ + onDeleteBook +}: { + onDeleteBook?: () => void +} = {}) => { + const onDeleteBookRef = useLiveRef(onDeleteBook) + + return useCallback( + (params: Omit) => { + bookActionDrawerSignal.setValue({ + ...params, + onDeleteBook: () => { + onDeleteBookRef.current?.() + } + }) + }, + [onDeleteBookRef] + ) +} export const BookActionsDrawer = memo(() => { const { openManageBookCollectionsDialog } = useManageBookCollectionsDialog() const { openManageBookTagsDialog } = useManageBookTagsDialog() - const { openedWith: bookId, actions } = useSignalValue(bookActionDrawerSignal) + const { + openedWith: bookId, + actions, + onDeleteBook, + actionsBlackList + } = useSignalValue(bookActionDrawerSignal) const navigate = useNavigate() const normalizedBookDownloadsState = useSignalValue(booksDownloadStateSignal) const book = useEnrichedBookState({ @@ -73,16 +105,23 @@ export const BookActionsDrawer = memo(() => { bookId ) - const { mutate: onRemovePress, status, data } = useRemoveHandler() + const { mutate: onRemovePress, ...rest } = useRemoveHandler({ + onError: () => { + handleClose() + }, + onSuccess: ({ isDeleted }) => { + if (isDeleted) { + handleClose(() => { + onDeleteBook?.() + }) + } + } + }) const metadata = getMetadataFromBook(book) return ( - handleClose()} - > + handleClose()}> {book && ( <>
@@ -99,7 +138,8 @@ export const BookActionsDrawer = memo(() => {
- {(actions?.includes("goToDetails") || !actions) && ( + {(actions?.includes("goToDetails") || + (!actions && !actionsBlackList?.includes("goToDetails"))) && ( { handleClose(() => { @@ -225,7 +265,6 @@ export const BookActionsDrawer = memo(() => { !book.isLocal && ( { - handleClose() bookId && removeDownloadFile(bookId) }} > @@ -258,9 +297,7 @@ export const BookActionsDrawer = memo(() => { - handleClose(() => bookId && onRemovePress({ bookId })) - } + onClick={() => bookId && onRemovePress({ bookId })} > diff --git a/packages/web/src/books/drawer/useRemoveHandler.ts b/packages/web/src/books/drawer/useRemoveHandler.ts index bdee0252..6ab1176d 100644 --- a/packages/web/src/books/drawer/useRemoveHandler.ts +++ b/packages/web/src/books/drawer/useRemoveHandler.ts @@ -1,20 +1,25 @@ import { getBookById, useRemoveBook } from "../helpers" import { useDialogManager } from "../../dialog" import { useMutation } from "reactjrx" -import { latestDatabase$ } from "../../rxdb/useCreateDatabase" -import { EMPTY, endWith, first, ignoreElements, mergeMap } from "rxjs" +import { getLatestDatabase } from "../../rxdb/useCreateDatabase" +import { catchError, from, map, mergeMap } from "rxjs" import { isRemovableFromDataSource } from "../../links/isRemovableFromDataSource" import { getDataSourcePlugin } from "../../dataSources/getDataSourcePlugin" import { getLinkById } from "../../links/helpers" -export const useRemoveHandler = () => { +type Return = { + isDeleted: boolean +} + +export const useRemoveHandler = ( + options: { onSuccess?: (data: Return) => void; onError?: () => void } = {} +) => { const removeBook = useRemoveBook() const dialog = useDialogManager() return useMutation({ mutationFn: ({ bookId }: { bookId: string }) => { - return latestDatabase$.pipe( - first(), + return getLatestDatabase().pipe( mergeMap((database) => { return getBookById({ database, id: bookId }).pipe( mergeMap((book) => { @@ -23,21 +28,32 @@ export const useRemoveHandler = () => { const linkId = book.links[0] if (!book?.isAttachedToDataSource || !linkId) { - dialog({ - preset: "CONFIRM", - title: "Delete a book", - content: `You are about to delete a book, are you sure ?`, - onConfirm: () => { - removeBook({ id: book._id }) - } - }) - - return EMPTY + return from( + new Promise((resolve, reject) => { + dialog({ + preset: "CONFIRM", + title: "Delete a book", + content: `You are about to delete a book, are you sure ?`, + onConfirm: () => { + removeBook({ id: book._id }) + .then(() => resolve({ isDeleted: true })) + .catch(reject) + }, + onCancel: () => { + resolve({ isDeleted: false }) + } + }) + }) + ) } return getLinkById(database, linkId).pipe( mergeMap((firstLink) => { - if (!firstLink) return EMPTY + if (!firstLink) { + return from(removeBook({ id: book._id })).pipe( + map(() => ({ isDeleted: true })) + ) + } const plugin = getDataSourcePlugin(firstLink?.type) @@ -45,50 +61,72 @@ export const useRemoveHandler = () => { book?.isAttachedToDataSource && !isRemovableFromDataSource({ link: firstLink }) ) { - dialog({ - preset: "CONFIRM", - title: "Delete a book", - content: `This book has been synchronized with one of your ${plugin?.name} data source. Oboku does not support deletion from ${plugin?.name} directly so consider deleting it there manually if you don't want the book to be synced again`, - onConfirm: () => { - removeBook({ id: book._id }) - } - }) - } else { - dialog({ - preset: "CONFIRM", - title: "Delete a book", - content: `This book has been synchronized with one of your ${plugin?.name} data source. You can delete it from both oboku and ${plugin?.name} which will prevent the book to be synced again`, - actions: [ - { - type: "confirm", - title: "both", - onClick: () => { - removeBook({ - id: book._id, - deleteFromDataSource: true - }) - } - }, - { - type: "confirm", - title: "only oboku", - onClick: () => { + return from( + new Promise((resolve, reject) => { + dialog({ + preset: "CONFIRM", + title: "Delete a book", + content: `This book has been synchronized with one of your ${plugin?.name} data source. Oboku does not support deletion from ${plugin?.name} directly so consider deleting it there manually if you don't want the book to be synced again`, + onConfirm: () => { removeBook({ id: book._id }) + .then(() => resolve({ isDeleted: true })) + .catch(reject) + }, + onCancel: () => { + resolve({ isDeleted: false }) } - } - ] - }) + }) + }) + ) + } else { + return from( + new Promise((resolve, reject) => { + dialog({ + preset: "CONFIRM", + title: "Delete a book", + content: `This book has been synchronized with one of your ${plugin?.name} data source. You can delete it from both oboku and ${plugin?.name} which will prevent the book to be synced again`, + actions: [ + { + type: "confirm", + title: "both", + onClick: () => { + removeBook({ + id: book._id, + deleteFromDataSource: true + }) + .then(() => resolve({ isDeleted: true })) + .catch(reject) + } + }, + { + type: "confirm", + title: "only oboku", + onClick: () => { + removeBook({ id: book._id }) + .then(() => resolve({ isDeleted: true })) + .catch(reject) + } + } + ], + onCancel: () => { + resolve({ isDeleted: false }) + } + }) + }) + ) } - - return EMPTY }) ) }) ) }), - ignoreElements(), - endWith(null) + catchError((e) => { + console.error(e) + + throw e + }) ) - } + }, + ...options }) } diff --git a/packages/web/src/books/helpers.ts b/packages/web/src/books/helpers.ts index 244bccb9..228f2e7a 100644 --- a/packages/web/src/books/helpers.ts +++ b/packages/web/src/books/helpers.ts @@ -39,12 +39,13 @@ export const useRemoveBook = () => { deleteFromDataSource?: boolean }) => { let unlock = () => {} + try { if (deleteFromDataSource) { if (!network.online) { return dialog({ preset: "OFFLINE" }) } - // unlock = lock() + try { await removeBookFromDataSource(id) } catch (e) { diff --git a/packages/web/src/dialog.tsx b/packages/web/src/dialog.tsx index 885e8c81..7fe8355d 100644 --- a/packages/web/src/dialog.tsx +++ b/packages/web/src/dialog.tsx @@ -12,12 +12,13 @@ import { ReactNode, useCallback, useContext, - useEffect, useMemo, useState } from "react" +import { signal } from "reactjrx" type Preset = "NOT_IMPLEMENTED" | "OFFLINE" | "CONFIRM" | "UNKNOWN_ERROR" + type DialogType = { title?: string content?: string @@ -33,10 +34,18 @@ type DialogType = { onCancel?: () => void } +const dialogUpdateSignal = signal<{ id: string; state: "closed" } | undefined>( + {} +) + const DialogContext = createContext([]) + const ManageDialogContext = createContext({ remove: (id: string) => {}, - add: (options: Omit) => "-1" as string + add: (options: Omit) => ({ + id: "-1" as string, + promise: Promise.resolve() + }) }) export const useDialogManager = () => { @@ -45,50 +54,6 @@ export const useDialogManager = () => { return useCallback(add, [add]) } -export const ObokuDialog: FC & { open: boolean }> = ({ - open, - cancellable, - content, - onConfirm, - preset, - title, - onClose -}) => { - const dialog = useDialogManager() - const { remove } = useContext(ManageDialogContext) - - useEffect(() => { - let id: string | undefined - - if (open) { - id = dialog({ - cancellable, - content, - onConfirm, - preset, - title, - onClose - }) - } - - return () => { - id && remove(id) - } - }, [ - open, - cancellable, - content, - onConfirm, - preset, - title, - dialog, - onClose, - remove - ]) - - return null -} - const enrichDialogWithPreset = ( dialog?: DialogType ): DialogType | undefined => { @@ -209,13 +174,27 @@ export const DialogProvider: FC<{ children: ReactNode }> = ({ children }) => { const remove = useCallback((id: string) => { setDialogs((old) => old.filter((dialog) => id !== dialog.id)) + + dialogUpdateSignal.setValue({ id, state: "closed" }) }, []) const add = useCallback((options: Omit) => { generatedId++ + setDialogs((old) => [...old, { ...options, id: generatedId.toString() }]) - return generatedId.toString() + const id = generatedId.toString() + + const promise = new Promise((resolve) => { + const sub = dialogUpdateSignal.subject.subscribe((value) => { + if (value?.id === id && value.state === "closed") { + sub.unsubscribe() + resolve() + } + }) + }) + + return { id: generatedId.toString(), promise } }, []) const controls = useMemo( diff --git a/packages/web/src/download/useDownloadBook.ts b/packages/web/src/download/useDownloadBook.ts index 181ab2f2..91fa6ee4 100644 --- a/packages/web/src/download/useDownloadBook.ts +++ b/packages/web/src/download/useDownloadBook.ts @@ -15,6 +15,8 @@ import { plugin } from "../plugins/local" import { isPluginError } from "../plugins/plugin-front" import { BookQueryResult } from "../books/states" +class NoLinkFound extends Error {} + export const useDownloadBook = () => { const downloadBook = useDownloadBookFromDataSource() const { db: database } = useDatabase() @@ -23,9 +25,7 @@ export const useDownloadBook = () => { const setDownloadData = useCallback( ( bookId: string, - data: ReturnType< - typeof booksDownloadStateSignal.getValue - >[number] + data: ReturnType[number] ) => { booksDownloadStateSignal.setValue((prev) => ({ ...prev, @@ -63,8 +63,13 @@ export const useDownloadBook = () => { }) if (!firstLink) { - // @todo add dialog to tell book is broken - throw new Error("invalid link") + dialog({ + title: "No link!", + content: + "Your book does not have a valid link to download the file. Please add one before proceeding" + }) + + throw new NoLinkFound() } // for some reason if the file exist we do not download it again @@ -112,6 +117,7 @@ export const useDownloadBook = () => { setDownloadData(bookId, { downloadState: DownloadState.None }) + // @todo shorten this description and redirect to the documentation dialog({ preset: `UNKNOWN_ERROR`, @@ -163,6 +169,8 @@ export const useDownloadBook = () => { if (isPluginError(e) && e.code === "cancelled") return + if (e instanceof NoLinkFound) return + Report.error(e) } }, From e79381506357505825f448e48eed37bc1306f505 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 7 Mar 2024 11:29:38 +0100 Subject: [PATCH 5/7] fix: fixed empty google metadata --- .../src/functions/refreshMetadataLongProcess/handler.ts | 3 +++ .../api/src/functions/refreshMetadataLongProcess/index.ts | 8 ++++++++ .../api/src/libs/metadata/google/getGoogleMetadata.ts | 8 ++++++-- packages/api/src/libs/supabase/deleteLock.ts | 3 +++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/api/src/functions/refreshMetadataLongProcess/handler.ts b/packages/api/src/functions/refreshMetadataLongProcess/handler.ts index 9987e166..1ddeb8ab 100644 --- a/packages/api/src/functions/refreshMetadataLongProcess/handler.ts +++ b/packages/api/src/functions/refreshMetadataLongProcess/handler.ts @@ -12,6 +12,7 @@ import { retrieveMetadataAndSaveCover } from "@libs/books/retrieveMetadataAndSav import { getParameterValue } from "@libs/ssm" import { deleteLock } from "@libs/supabase/deleteLock" import { supabase } from "@libs/supabase/client" +import { Logger } from "@libs/logger" const lambda: ValidatedEventAPIGatewayProxyEvent = async ( event @@ -120,6 +121,8 @@ const lambda: ValidatedEventAPIGatewayProxyEvent = async ( deleteLock(supabase, lockId) ]) + Logger.log(`lambda executed with success for ${book._id}`) + return { statusCode: 200, body: JSON.stringify({}) diff --git a/packages/api/src/functions/refreshMetadataLongProcess/index.ts b/packages/api/src/functions/refreshMetadataLongProcess/index.ts index 941c01b5..b797dc16 100644 --- a/packages/api/src/functions/refreshMetadataLongProcess/index.ts +++ b/packages/api/src/functions/refreshMetadataLongProcess/index.ts @@ -3,5 +3,13 @@ import { handlerPath } from "@libs/handler-resolver" export default { handler: `${handlerPath(__dirname)}/handler.main`, role: "lambdaDefault", + /** + * This lambda can be heavy on local development. + * To avoid memory issues and CPU overuse we lock + * it at 1 concurrency + */ + ...(process.env.OFFLINE === "true" && { + reservedConcurrency: 1 + }), timeout: 60 * 15 // 15mn } diff --git a/packages/api/src/libs/metadata/google/getGoogleMetadata.ts b/packages/api/src/libs/metadata/google/getGoogleMetadata.ts index 6f104303..7d289315 100644 --- a/packages/api/src/libs/metadata/google/getGoogleMetadata.ts +++ b/packages/api/src/libs/metadata/google/getGoogleMetadata.ts @@ -7,7 +7,7 @@ import { refineTitle } from "../refineTitle" export const getGoogleMetadata = async ( metadata: Metadata, apiKey: string -): Promise => { +): Promise => { let response = metadata.isbn ? await findByISBN(metadata.isbn, apiKey) : await findByTitle(metadata.title ?? "", apiKey) @@ -42,8 +42,12 @@ export const getGoogleMetadata = async ( } } + const parsedMetadata = parseGoogleMetadata(response) + + if (!Object.keys(parsedMetadata)) return undefined + return { - ...parseGoogleMetadata(response), + ...parsedMetadata, type: "googleBookApi" } } diff --git a/packages/api/src/libs/supabase/deleteLock.ts b/packages/api/src/libs/supabase/deleteLock.ts index 9e6ddfb9..71383ce0 100644 --- a/packages/api/src/libs/supabase/deleteLock.ts +++ b/packages/api/src/libs/supabase/deleteLock.ts @@ -1,5 +1,8 @@ +import { Logger } from "@libs/logger" import { SupabaseClient } from "@supabase/supabase-js" export const deleteLock = async (supabase: SupabaseClient, lockId: string) => { + Logger.log(`releasing lock ${lockId}`) + return await supabase.from("lock").delete().eq("lock_id", lockId) } From 69b0c48159d6500ea59046a696c4791a94fe2fc7 Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 7 Mar 2024 11:34:26 +0100 Subject: [PATCH 6/7] feat: fixed book detail screen --- .../web/src/books/details/BookDetailsScreen.tsx | 14 +++++++------- packages/web/src/books/details/CollectionsPane.tsx | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/web/src/books/details/BookDetailsScreen.tsx b/packages/web/src/books/details/BookDetailsScreen.tsx index 631cc888..0f3b8a44 100644 --- a/packages/web/src/books/details/BookDetailsScreen.tsx +++ b/packages/web/src/books/details/BookDetailsScreen.tsx @@ -166,14 +166,14 @@ export const BookDetailsScreen = () => {
- {book?.metadataUpdateStatus === "fetching" && ( - - We are still retrieving metadata information... - - )} - - + + {book?.metadataUpdateStatus === "fetching" && ( + Retrieving metadata information... + )} + + + diff --git a/packages/web/src/books/details/CollectionsPane.tsx b/packages/web/src/books/details/CollectionsPane.tsx index 7db4ab6b..06f8d6dd 100644 --- a/packages/web/src/books/details/CollectionsPane.tsx +++ b/packages/web/src/books/details/CollectionsPane.tsx @@ -24,7 +24,6 @@ export const CollectionsPane = ({ bookId }: { bookId?: string }) => { onEditClick={() => { book?._id && openManageBookCollectionsDialog(book?._id) }} - mt={2} /> ) } From 99ef304357aaaa328347ed97eeb9e91a6f57674f Mon Sep 17 00:00:00 2001 From: maxime Date: Thu, 7 Mar 2024 12:31:57 +0100 Subject: [PATCH 7/7] feat: added lock on sync datasource --- .../src/functions/refreshMetadata/handler.ts | 14 ++---- .../src/functions/syncDataSource/handler.ts | 47 ++++++++++++++++++- .../api/src/functions/syncDataSource/index.ts | 8 ++++ .../api/src/libs/supabase/isLockOutdated.ts | 10 ++++ packages/api/src/libs/supabase/types.ts | 5 ++ 5 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 packages/api/src/libs/supabase/isLockOutdated.ts create mode 100644 packages/api/src/libs/supabase/types.ts diff --git a/packages/api/src/functions/refreshMetadata/handler.ts b/packages/api/src/functions/refreshMetadata/handler.ts index 0e9e32db..d33a6baf 100644 --- a/packages/api/src/functions/refreshMetadata/handler.ts +++ b/packages/api/src/functions/refreshMetadata/handler.ts @@ -6,8 +6,9 @@ import { InvokeCommand } from "@aws-sdk/client-lambda" import { STAGE } from "src/constants" import { Logger } from "@libs/logger" import { supabase } from "@libs/supabase/client" +import { isLockOutdated } from "@libs/supabase/isLockOutdated" -const LOCK_MAX_DURATION_MN = 10 +const LOCK_MAX_DURATION_MN = 5 const lambda: ValidatedEventAPIGatewayProxyEvent = async ( event @@ -37,18 +38,9 @@ const lambda: ValidatedEventAPIGatewayProxyEvent = async ( const response = await supabase.from("lock").select().eq("lock_id", lockId) const lock = (response.data ?? [])[0] - const created_at = new Date(lock.created_at) const now = new Date() - const differenceInMilliseconds = now.getTime() - created_at.getTime() - const differenceInMinutes = Math.floor( - differenceInMilliseconds / (1000 * 60) - ) - - Logger.log( - `${lockId} has already a lock created at ${lock.created_at} and is ${differenceInMinutes}mn old` - ) - if (differenceInMinutes > LOCK_MAX_DURATION_MN) { + if (isLockOutdated(lock, LOCK_MAX_DURATION_MN)) { Logger.log(`${lockId} lock is assumed lost and will be recreated`) const updatedResponse = await supabase diff --git a/packages/api/src/functions/syncDataSource/handler.ts b/packages/api/src/functions/syncDataSource/handler.ts index ee2b0753..2263a2d1 100644 --- a/packages/api/src/functions/syncDataSource/handler.ts +++ b/packages/api/src/functions/syncDataSource/handler.ts @@ -4,11 +4,17 @@ import { getNormalizedHeader } from "@libs/utils" import { STAGE } from "../../constants" import schema from "./schema" import { InvokeCommand } from "@aws-sdk/client-lambda" +import { Logger } from "@libs/logger" +import { supabase } from "@libs/supabase/client" +import { isLockOutdated } from "@libs/supabase/isLockOutdated" + +const LOCK_MAX_DURATION_MN = 10 const lambda: ValidatedEventAPIGatewayProxyEvent = async ( event ) => { - const lambda = getAwsLambda() + const client = getAwsLambda() + const command = new InvokeCommand({ InvocationType: "Event", FunctionName: `oboku-api-${STAGE}-syncDataSourceLongProcess`, @@ -21,7 +27,44 @@ const lambda: ValidatedEventAPIGatewayProxyEvent = async ( }) }) - await lambda.send(command) + const lockId = `sync_${event.body.bookId}` + + const response = await supabase + .from("lock") + .insert({ id: 1, lock_id: lockId }) + .select() + + if (response.status === 409) { + const response = await supabase.from("lock").select().eq("lock_id", lockId) + + const lock = (response.data ?? [])[0] + const now = new Date() + + if (isLockOutdated(lock, LOCK_MAX_DURATION_MN)) { + Logger.log(`${lockId} lock is outdated and will be recreated`) + + const updatedResponse = await supabase + .from("lock") + .upsert({ id: lock.id, created_at: now, lock_id: lockId }) + .select() + + if (updatedResponse.status === 200) { + Logger.log(`${lockId} lock correctly updated, command will be sent`) + + await client.send(command) + } + } else { + Logger.log(`${lockId} invocation is ignored`) + } + } + + if (response.status === 201) { + Logger.log( + `New lock created for ${lockId} with id ${(response.data ?? [])[0].id}. Command will be sent` + ) + + await client.send(command) + } return { statusCode: 202, diff --git a/packages/api/src/functions/syncDataSource/index.ts b/packages/api/src/functions/syncDataSource/index.ts index 40ebef28..8da44432 100644 --- a/packages/api/src/functions/syncDataSource/index.ts +++ b/packages/api/src/functions/syncDataSource/index.ts @@ -3,6 +3,14 @@ import { handlerPath } from "@libs/handler-resolver" export default { handler: `${handlerPath(__dirname)}/handler.main`, role: "lambdaDefault", + /** + * Because this lambda check and lock the process + * we need to avoid concurrent access. This way we ensure + * the lock is always checked in sync. + * + * This lambda should stay simple and fast (check/lock) + */ + reservedConcurrency: 1, events: [ { http: { diff --git a/packages/api/src/libs/supabase/isLockOutdated.ts b/packages/api/src/libs/supabase/isLockOutdated.ts new file mode 100644 index 00000000..d18aa303 --- /dev/null +++ b/packages/api/src/libs/supabase/isLockOutdated.ts @@ -0,0 +1,10 @@ +import { Lock } from "./types" + +export const isLockOutdated = (lock: Lock, maximumTime: number) => { + const created_at = new Date(lock.created_at) + const now = new Date() + const differenceInMilliseconds = now.getTime() - created_at.getTime() + const differenceInMinutes = Math.floor(differenceInMilliseconds / (1000 * 60)) + + return differenceInMinutes > maximumTime +} diff --git a/packages/api/src/libs/supabase/types.ts b/packages/api/src/libs/supabase/types.ts new file mode 100644 index 00000000..87fe8e8c --- /dev/null +++ b/packages/api/src/libs/supabase/types.ts @@ -0,0 +1,5 @@ +export type Lock = { + id: string + lock_id: string + created_at: string +} \ No newline at end of file