diff --git a/apps/frontend/app/components/common.tsx b/apps/frontend/app/components/common.tsx index 3a1c6c6255..32d0e909a1 100644 --- a/apps/frontend/app/components/common.tsx +++ b/apps/frontend/app/components/common.tsx @@ -8,16 +8,19 @@ import { Badge, Box, Button, + Center, Checkbox, Collapse, Divider, Flex, Group, Image, + type MantineStyleProp, Modal, MultiSelect, Paper, SimpleGrid, + Skeleton, Stack, Text, TextInput, @@ -47,7 +50,13 @@ import { type ReviewItem, UserReviewScale, } from "@ryot/generated/graphql/backend/graphql"; -import { changeCase, getInitials, isNumber, snakeCase } from "@ryot/ts-utils"; +import { + changeCase, + getInitials, + isNumber, + isString, + snakeCase, +} from "@ryot/ts-utils"; import { IconArrowBigUp, IconCheck, @@ -64,7 +73,7 @@ import { } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import Cookies from "js-cookie"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { useState } from "react"; import { $path } from "remix-routes"; import type { DeepPartial } from "ts-essentials"; @@ -312,6 +321,157 @@ export const ProRequiredAlert = (props: { tooltipLabel?: string }) => { ) : null; }; +const blackBgStyles = { + backgroundColor: "rgba(0, 0, 0, 0.75)", + borderRadius: 3, + padding: 2, +} satisfies MantineStyleProp; + +export const BaseMediaDisplayItem = (props: { + name?: string; + altName?: string; + progress?: string; + isLoading: boolean; + nameRight?: ReactNode; + imageUrl?: string | null; + innerRef?: Ref; + labels?: { right?: ReactNode; left?: ReactNode }; + onImageClickBehavior: string | (() => Promise); + imageOverlay?: { + topRight?: ReactNode; + topLeft?: ReactNode; + bottomRight?: ReactNode; + bottomLeft?: ReactNode; + }; +}) => { + const userPreferences = useUserPreferences(); + const gridPacking = userPreferences.general.gridPacking; + const SurroundingElement = (iProps: { children: ReactNode }) => + isString(props.onImageClickBehavior) ? ( + + {iProps.children} + + ) : ( + {iProps.children} + ); + const defaultOverlayProps = { + style: { zIndex: 10, ...blackBgStyles }, + pos: "absolute", + } as const; + + return ( + + + + + + ({ height: 260 })) + .with(GridPacking.Dense, () => ({ height: 180 })) + .exhaustive(), + }} + alt={`Image for ${props.name}`} + className={classes.mediaImage} + styles={{ + root: { + transitionProperty: "transform", + transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)", + transitionDuration: "150ms", + }, + }} + fallbackSrc={useFallbackImageUrl( + props.isLoading + ? "Loading..." + : props.name + ? getInitials(props.name) + : undefined, + )} + /> + {props.progress ? ( + + ) : null} + + + + {props.imageOverlay?.topLeft ? ( +
+ {props.imageOverlay.topLeft} +
+ ) : null} + {props.imageOverlay?.topRight ? ( +
+ {props.imageOverlay.topRight} +
+ ) : null} + {props.imageOverlay?.bottomLeft ? ( +
+ {props.imageOverlay.bottomLeft} +
+ ) : null} + {props.imageOverlay?.bottomRight ? ( +
+ {props.imageOverlay.bottomRight} +
+ ) : null} +
+ {props.isLoading ? ( + <> + + + + ) : ( + ({ base: 6, md: 3 })) + .with(GridPacking.Dense, () => ({ md: 2 })) + .exhaustive()} + > + + + {props.labels?.left} + + + {props.labels?.right} + + + + + {props.altName ?? props.name} + + {props.nameRight} + + + )} +
+ ); +}; + export const FiltersModal = (props: { opened: boolean; cookieName: string; diff --git a/apps/frontend/app/components/fitness.tsx b/apps/frontend/app/components/fitness.tsx index d44a14ad65..13b52af02c 100644 --- a/apps/frontend/app/components/fitness.tsx +++ b/apps/frontend/app/components/fitness.tsx @@ -44,6 +44,7 @@ import type { ComponentType, ReactNode } from "react"; import { $path } from "remix-routes"; import { match } from "ts-pattern"; import { withFragment } from "ufo"; +import { BaseMediaDisplayItem } from "~/components/common"; import { FitnessEntity, dayjsLib, getSetColor } from "~/lib/generals"; import { useGetRandomMantineColor, useUserUnitSystem } from "~/lib/hooks"; import { @@ -52,7 +53,6 @@ import { getWorkoutDetailsQuery, getWorkoutTemplateDetailsQuery, } from "~/lib/state/fitness"; -import { BaseMediaDisplayItem } from "./media"; export const getSetStatisticsTextToDisplay = ( lot: ExerciseLot, diff --git a/apps/frontend/app/components/media.tsx b/apps/frontend/app/components/media.tsx index 53ac30f68a..51a1571774 100644 --- a/apps/frontend/app/components/media.tsx +++ b/apps/frontend/app/components/media.tsx @@ -2,16 +2,10 @@ import { ActionIcon, Anchor, Avatar, - Box, - Center, - Flex, Group, - Image, Loader, - type MantineStyleProp, Menu, ScrollArea, - Skeleton, Text, ThemeIcon, Tooltip, @@ -22,14 +16,13 @@ import { useInViewport } from "@mantine/hooks"; import { Form, Link } from "@remix-run/react"; import { EntityLot, - GridPacking, MetadataGroupDetailsDocument, PersonDetailsDocument, SeenState, UserReviewScale, UserToMediaReason, } from "@ryot/generated/graphql/backend/graphql"; -import { changeCase, getInitials, isString, snakeCase } from "@ryot/ts-utils"; +import { changeCase, snakeCase } from "@ryot/ts-utils"; import { IconBackpack, IconBookmarks, @@ -38,10 +31,11 @@ import { IconStarFilled, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; -import type { ReactNode, Ref } from "react"; +import type { ReactNode } from "react"; import { match } from "ts-pattern"; import { withQuery } from "ufo"; import { + BaseMediaDisplayItem, DisplayThreePointReview, MEDIA_DETAILS_HEIGHT, } from "~/components/common"; @@ -54,7 +48,6 @@ import { } from "~/lib/generals"; import { useConfirmSubmit, - useFallbackImageUrl, useUserDetails, useUserMetadataDetails, useUserPreferences, @@ -110,137 +103,6 @@ export const MediaScrollArea = (props: { children: ReactNode }) => { ); }; -const blackBgStyles = { - backgroundColor: "rgba(0, 0, 0, 0.75)", - borderRadius: 3, - padding: 2, -} satisfies MantineStyleProp; - -export const BaseMediaDisplayItem = (props: { - isLoading: boolean; - name?: string; - altName?: string; - imageUrl?: string | null; - imageOverlay?: { - topRight?: ReactNode; - topLeft?: ReactNode; - bottomRight?: ReactNode; - bottomLeft?: ReactNode; - }; - labels?: { right?: ReactNode; left?: ReactNode }; - onImageClickBehavior: string | (() => Promise); - nameRight?: ReactNode; - innerRef?: Ref; -}) => { - const userPreferences = useUserPreferences(); - const gridPacking = userPreferences.general.gridPacking; - const SurroundingElement = (iProps: { children: ReactNode }) => - isString(props.onImageClickBehavior) ? ( - - {iProps.children} - - ) : ( - {iProps.children} - ); - const defaultOverlayProps = { - style: { zIndex: 10, ...blackBgStyles }, - pos: "absolute", - } as const; - - return ( - - - - - ({ height: 260 })) - .with(GridPacking.Dense, () => ({ height: 180 })) - .exhaustive(), - }} - alt={`Image for ${props.name}`} - className={classes.mediaImage} - styles={{ - root: { - transitionProperty: "transform", - transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)", - transitionDuration: "150ms", - }, - }} - fallbackSrc={useFallbackImageUrl( - props.isLoading - ? "Loading..." - : props.name - ? getInitials(props.name) - : undefined, - )} - /> - - - {props.imageOverlay?.topLeft ? ( -
- {props.imageOverlay.topLeft} -
- ) : null} - {props.imageOverlay?.topRight ? ( -
- {props.imageOverlay.topRight} -
- ) : null} - {props.imageOverlay?.bottomLeft ? ( -
- {props.imageOverlay.bottomLeft} -
- ) : null} - {props.imageOverlay?.bottomRight ? ( -
- {props.imageOverlay.bottomRight} -
- ) : null} -
- {props.isLoading ? ( - <> - - - - ) : ( - ({ base: 6, md: 3 })) - .with(GridPacking.Dense, () => ({ md: 2 })) - .exhaustive()} - > - - - {props.labels?.left} - - - {props.labels?.right} - - - - - {props.altName ?? props.name} - - {props.nameRight} - - - )} -
- ); -}; - export const MetadataDisplayItem = (props: { metadataId: string; name?: string; @@ -266,9 +128,12 @@ export const MetadataDisplayItem = (props: { inViewport, ); const averageRating = userMetadataDetails?.averageRating; - const history = (userMetadataDetails?.history || []).filter( + const completedHistory = (userMetadataDetails?.history || []).filter( (h) => h.state === SeenState.Completed, ); + const currentProgress = userMetadataDetails?.history.find( + (h) => h.state === SeenState.InProgress, + )?.progress; const surroundReason = ( idx: number, data: readonly [UserToMediaReason, ReactNode], @@ -291,11 +156,12 @@ export const MetadataDisplayItem = (props: { return ( 0 ? ( - `${history.length} time${history.length === 1 ? "" : "s"}` + completedHistory.length > 0 ? ( + `${completedHistory.length} time${completedHistory.length === 1 ? "" : "s"}` ) : null ) : ( diff --git a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx index 021e70bb4d..a7a7ce2055 100644 --- a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx +++ b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx @@ -55,11 +55,12 @@ import { z } from "zod"; import { zx } from "zodix"; import { ApplicationGrid, + BaseMediaDisplayItem, CollectionsFilter, DebouncedSearchInput, FiltersModal, } from "~/components/common"; -import { BaseMediaDisplayItem, MetadataDisplayItem } from "~/components/media"; +import { MetadataDisplayItem } from "~/components/media"; import { Verb, commaDelimitedString, diff --git a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx index 255b8455bb..d2555d11bc 100644 --- a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx +++ b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx @@ -47,10 +47,8 @@ import { DebouncedSearchInput, FiltersModal, } from "~/components/common"; -import { - BaseMediaDisplayItem, - MetadataGroupDisplayItem, -} from "~/components/media"; +import { BaseMediaDisplayItem } from "~/components/common"; +import { MetadataGroupDisplayItem } from "~/components/media"; import { commaDelimitedString, pageQueryParam } from "~/lib/generals"; import { useAppSearchParam } from "~/lib/hooks"; import { useBulkEditCollection } from "~/lib/state/collection"; diff --git a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx index 0a2a991915..46ae4c82fe 100644 --- a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx +++ b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx @@ -46,7 +46,8 @@ import { DebouncedSearchInput, FiltersModal, } from "~/components/common"; -import { BaseMediaDisplayItem, PersonDisplayItem } from "~/components/media"; +import { BaseMediaDisplayItem } from "~/components/common"; +import { PersonDisplayItem } from "~/components/media"; import { commaDelimitedString, pageQueryParam } from "~/lib/generals"; import { useAppSearchParam } from "~/lib/hooks"; import { useBulkEditCollection } from "~/lib/state/collection"; diff --git a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx index a6b909467c..9032ca7d5f 100644 --- a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx @@ -6,7 +6,6 @@ import { Button, Container, Divider, - Flex, Group, Input, JsonInput, @@ -32,7 +31,7 @@ import type { } from "@remix-run/node"; import { Form, data, useLoaderData } from "@remix-run/react"; import { - type DashboardElementLot, + DashboardElementLot, GridPacking, MediaLot, MediaStateChanged, @@ -646,6 +645,13 @@ export default function Page() { ); } +const EDITABLE_NUM_ELEMENTS = [ + DashboardElementLot.Upcoming, + DashboardElementLot.InProgress, + DashboardElementLot.Recommendations, +]; +const EDITABLE_DEDUPLICATE_MEDIA = [DashboardElementLot.Upcoming]; + const EditDashboardElement = (props: { isEditDisabled: boolean; lot: DashboardElementLot; @@ -668,7 +674,7 @@ const EditDashboardElement = (props: { {...provided.draggableProps} className={cn({ [classes.itemDragging]: snapshot.isDragging })} > - +
- {changeCase(props.lot)} + + {changeCase(props.lot)} +
- {isNumber(focusedElement.numElements) ? ( - + + {EDITABLE_NUM_ELEMENTS.includes(props.lot) ? ( { if (isNumber(num)) { const newDashboardData = Array.from( @@ -724,8 +732,30 @@ const EditDashboardElement = (props: { } }} /> - - ) : null} + ) : null} + {EDITABLE_DEDUPLICATE_MEDIA.includes(props.lot) ? ( + { + const newValue = ev.currentTarget.checked; + const newDashboardData = Array.from( + userPreferences.general.dashboard, + ); + newDashboardData[focusedElementIndex].deduplicateMedia = + newValue; + props.appendPref( + "general.dashboard", + JSON.stringify(newDashboardData), + ); + }} + /> + ) : null} +
)} @@ -734,11 +764,11 @@ const EditDashboardElement = (props: { const reorder = ( array: Array, - { from, to }: { from: number; to: number }, + details: { from: number; to: number }, ) => { const cloned = [...array]; - const item = array[from]; - cloned.splice(from, 1); - cloned.splice(to, 0, item); + const item = array[details.from]; + cloned.splice(details.from, 1); + cloned.splice(details.to, 0, item); return cloned; }; diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx index 24bc79f7ea..7648999d60 100644 --- a/apps/frontend/app/routes/_dashboard.tsx +++ b/apps/frontend/app/routes/_dashboard.tsx @@ -1573,6 +1573,7 @@ const AddEntityToCollectionForm = ({ method="POST" onSubmit={(e) => { submit(e); + refreshUserMetadataDetails(addEntityToCollectionData.entityId); closeAddEntityToCollectionModal(); }} action={withQuery("/actions", { intent: "addEntityToCollection" })} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4d67783249..f1bb925985 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -27,7 +27,7 @@ "@ryot/generated": "workspace:*", "@ryot/graphql": "workspace:*", "@ryot/ts-utils": "workspace:*", - "@tabler/icons-react": "3.21.0", + "@tabler/icons-react": "3.17.0", "@tanstack/react-query": "5.59.16", "@tanstack/react-query-devtools": "5.59.16", "buffer": "6.0.3", @@ -53,7 +53,7 @@ "react-dom": "18.3.1", "react-virtuoso": "4.12.0", "react-webcam": "7.2.0", - "recharts": "2.13.1", + "recharts": "2.13.3", "remix-routes": "1.7.7", "remix-utils": "7.7.0", "tailwind-merge": "2.5.4", @@ -79,8 +79,8 @@ "postcss": "8.4.47", "postcss-preset-mantine": "1.17.0", "postcss-simple-vars": "7.0.1", - "remix-development-tools": "4.7.3", - "ts-essentials": "10.0.2", + "remix-development-tools": "4.7.5", + "ts-essentials": "10.0.3", "typescript": "5.6.3", "typescript-plugin-css-modules": "5.1.0", "typescript-remix-routes-plugin": "1.0.1", diff --git a/apps/website/app/root.tsx b/apps/website/app/root.tsx index 7ce77b0b33..60988cc6fa 100644 --- a/apps/website/app/root.tsx +++ b/apps/website/app/root.tsx @@ -81,7 +81,7 @@ export default function App() {