diff --git a/src/components/linkButton.tsx b/src/components/linkButton.tsx new file mode 100644 index 00000000..5fefd798 --- /dev/null +++ b/src/components/linkButton.tsx @@ -0,0 +1,98 @@ +import { useContext, forwardRef } from "react"; +import Link from "next/link"; +import { createStyles } from "@mantine/core"; +import type { DifficultyColor } from "~/styles/difficultyColors"; +import { colorsForDifficultyIndex } from "~/styles/modsColors"; +import { currentDifficultyTabIndexContext } from "./mods/modsTable"; + + + + +const useStyles = createStyles( + ( + _theme, + { + colors, + }: { + colors: DifficultyColor; + } + ) => { + return ({ + button: { + backgroundColor: colors.primary.backgroundColor, + color: colors.primary.textColor, + /* left/right top/bottom */ + padding: "2px 10px", + border: "none", + borderRadius: "8px", + "&:hover": { + backgroundColor: colors.primaryHover.backgroundColor, + color: colors.primaryHover.textColor, + } + }, + }); + } +); + + + + +type LinkButtonProps = { + children: React.ReactNode; + href: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + linkWrapper?: boolean; +}; + + + + +export const LinkButton = forwardRef< + HTMLAnchorElement, + LinkButtonProps +>( + ( + { + children, + href, + onMouseEnter, + onMouseLeave, + linkWrapper = true, + }, + ref, + ) => { + const currentTabIndex = useContext(currentDifficultyTabIndexContext); + + const colors = colorsForDifficultyIndex(currentTabIndex ?? 0); // default to beginner colors if no context is provided. null would give the highest valid difficulty's color. + + const { classes } = useStyles({ colors }); + + + return ( + linkWrapper ? ( + + {children} + + ) : ( + + {children} + + ) + ); + } +); \ No newline at end of file diff --git a/src/components/mods/expandedMod.tsx b/src/components/mods/expandedMod.tsx index f514f847..9e34d4c4 100644 --- a/src/components/mods/expandedMod.tsx +++ b/src/components/mods/expandedMod.tsx @@ -1,11 +1,9 @@ -import { Flex, Group, Loader, Stack, createStyles } from "@mantine/core"; -import { Mod } from "~/components/mods/types"; -import Maps from "./maps/maps"; -import PublisherName from "./publisherName"; -import PublicationDate from "./publicationDate"; +import { Flex, Loader, Stack, Text, createStyles } from "@mantine/core"; +import type { ModWithInfo } from "~/components/mods/types"; +import { Maps } from "./maps/maps"; import { ModDownloadButton } from "./modDownloadButton/modDownloadButton"; -import Link from "next/link"; -import ModCarousel from "./modCarousel"; +import { ModCarousel } from "./modCarousel"; +import { LinkButton } from "~/components/linkButton"; import { COMING_SOON_PATHNAME } from "~/consts/pathnames"; import { expandedModColors } from "~/styles/expandedModColors"; import type { DifficultyColor } from "~/styles/difficultyColors"; @@ -29,12 +27,12 @@ const useStyles = createStyles( // We move the expanded mod up to make // the mod row and expanded mod look like a single row. transform: "translate(0, -45px)", - }, - moreInfo: { - fontSize: "1rem", + paddingTop: "10px", }, modDetails: { - padding: "10px 25px", + width: "100%", + /** top and bottom | left and right */ + padding: "0 20px", } }), ); @@ -43,15 +41,15 @@ const useStyles = createStyles( type ExpandedModProps = { - isLoading: boolean, - mod: Mod, - colors: DifficultyColor, + isLoading: boolean; + mod: ModWithInfo; + colors: DifficultyColor; }; -const ExpandedMod = ({ +export const ExpandedMod = ({ isLoading, mod, colors, @@ -59,41 +57,51 @@ const ExpandedMod = ({ const isMapperNameVisiblePermitted = false; - const publicationDateInSeconds = mod.timeCreatedGamebanana; - const publicationDate = publicationDateInSeconds > 0 ? new Date(publicationDateInSeconds * 1000) : undefined; - - const { classes } = useStyles(); if (isLoading) return ; return ( - - - - - - + + + - More Info - - - - id)} + + + + More Info + + + + - - - + + ); -}; - -export default ExpandedMod; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/mods/maps/maps.tsx b/src/components/mods/maps/maps.tsx index 58c064e4..add22402 100644 --- a/src/components/mods/maps/maps.tsx +++ b/src/components/mods/maps/maps.tsx @@ -1,133 +1,132 @@ import { Stack, Title } from "@mantine/core"; -import type { Difficulty, Length, Map, MapRatingData, MapYesRatingData, Quality } from "~/components/mods/types"; +import type { Difficulty, Length, MapRatingData, MapYesRatingData, MapWithTechInfo, MapWithTechAndRatingInfo, Quality, Mod } from "~/components/mods/types"; import { api } from "~/utils/api"; import { useMemo } from "react"; import { noRatingsFoundMessage } from "~/consts/noRatingsFoundMessage"; -import MapsTable from "./mapsTable"; +import { MapsTable } from "./mapsTable"; import type { DifficultyColor } from "~/styles/difficultyColors"; -type MapWithInfo = { - lengthName: string, - overallCount: number, - qualityName: string, - qualityCount: number, - difficultyName: string, - difficultyCount: number, - chapterSide?: string; -} & Map; - - export type MapsProps = { isLoadingMod: boolean; - isNormalMod: boolean; + modType: Mod["type"]; isMapperNameVisiblePermitted: boolean; - mapIds: number[]; + mapsWithTechInfo: MapWithTechInfo[]; colors: DifficultyColor; }; -const getMapsWithInfo = (isLoading: boolean, maps: Map[], ratingsFromMapIds: MapRatingData[], lengths: Length[], qualities: Quality[], difficulties: Difficulty[]): MapWithInfo[] => { +const getMapsWithTechAndRatingInfo = ( + isLoading: boolean, + mapsWithTechInfo: MapWithTechInfo[], + ratingsFromMapIds: MapRatingData[], + lengths: Length[], + qualities: Quality[], + difficulties: Difficulty[], +): MapWithTechAndRatingInfo[] => { if (isLoading) return []; - const mapsWithInfo: MapWithInfo[] = maps.map((map) => { - const rating = ratingsFromMapIds.find((rating) => rating.mapId === map.id); + const mapsWithTechAndRatingInfo: MapWithTechAndRatingInfo[] = mapsWithTechInfo.map( + (mapWithTechInfo) => { + const rating = ratingsFromMapIds.find((rating) => rating.mapId === mapWithTechInfo.id); - if (rating === undefined) throw `Map ${map.id} has an undefined rating - this should not happen.`; + if (rating === undefined) throw `Map ${mapWithTechInfo.id} has an undefined rating - this should not happen.`; - //get length info from map - const length = lengths.find((length) => length.id === map.lengthId); + //get length info from map + const length = lengths.find((length) => length.id === mapWithTechInfo.lengthId); - if (!length) throw `Length ${map.lengthId}, linked to by map ${map.id}, not found. This should not happen.`; + if (!length) throw `Length ${mapWithTechInfo.lengthId}, linked to map ${mapWithTechInfo.id}, not found. This should not happen.`; - //get quality and difficulty info from rating. this is more complicated than lengths because ratings are optional. - let overallCount = 0; - let qualityId = -1; - let qualityName: string; - let qualityCount = 0; - let difficultyId = -1; - let difficultyName: string; - let difficultyCount = 0; + //get quality and difficulty info from rating. this is more complicated than lengths because ratings are optional. + let overallCount = 0; + let qualityId = -1; + let qualityName: string; + let qualityCount = 0; + let difficultyId = -1; + let difficultyName: string; + let difficultyCount = 0; - if ("overallCount" in rating === false) { //no ratings exist for this map - qualityName = noRatingsFoundMessage; - difficultyName = noRatingsFoundMessage; - } else { //ratings exist for this map - const narrowedRating = rating as MapYesRatingData; + if ("overallCount" in rating === false) { //no ratings exist for this map + qualityName = noRatingsFoundMessage; + difficultyName = noRatingsFoundMessage; + } else { //ratings exist for this map + const narrowedRating = rating as MapYesRatingData; - overallCount = narrowedRating.overallCount; - qualityCount = narrowedRating.qualityCount; - difficultyCount = narrowedRating.difficultyCount; + overallCount = narrowedRating.overallCount; + qualityCount = narrowedRating.qualityCount; + difficultyCount = narrowedRating.difficultyCount; - if (narrowedRating.averageQualityId) qualityId = narrowedRating.averageQualityId; + if (narrowedRating.averageQualityId) qualityId = narrowedRating.averageQualityId; - if (narrowedRating.averageDifficultyId) difficultyId = narrowedRating.averageDifficultyId; - } + if (narrowedRating.averageDifficultyId) difficultyId = narrowedRating.averageDifficultyId; + } - if (qualityId === -1) qualityName = noRatingsFoundMessage; - else { - if (qualityCount === 0) throw `Quality count is 0 for map ${map.id} but qualityId is ${qualityId} (and not -1) - this should not happen.`; + if (qualityId === -1) qualityName = noRatingsFoundMessage; + else { + if (qualityCount === 0) throw `Quality count is 0 for map ${mapWithTechInfo.id} but qualityId is ${qualityId} (and not -1) - this should not happen.`; - const quality = qualities.find((quality) => quality.id === qualityId); + const quality = qualities.find((quality) => quality.id === qualityId); - if (!quality) throw `Quality ${qualityId} not found. This should not happen.`; + if (!quality) throw `Quality ${qualityId} not found. This should not happen.`; - qualityName = quality.name; - } + qualityName = quality.name; + } - if (difficultyId === -1) difficultyName = noRatingsFoundMessage; - else { - if (difficultyCount === 0) throw `Difficulty count is 0 for map ${map.id} but difficultyId is ${difficultyId} (and not -1) - this should not happen.`; + if (difficultyId === -1) difficultyName = noRatingsFoundMessage; + else { + if (difficultyCount === 0) throw `Difficulty count is 0 for map ${mapWithTechInfo.id} but difficultyId is ${difficultyId} (and not -1) - this should not happen.`; - const difficulty = difficulties.find((difficulty) => difficulty.id === difficultyId); + const difficulty = difficulties.find((difficulty) => difficulty.id === difficultyId); - if (!difficulty) throw `Difficulty ${difficultyId} not found. This should not happen.`; + if (!difficulty) throw `Difficulty ${difficultyId} not found. This should not happen.`; - difficultyName = difficulty.name; - } + difficultyName = difficulty.name; + } - const chapterSide = `${map.chapter ?? ""}${map.side ?? ""}`; + const chapterSide = `${mapWithTechInfo.chapter ?? ""}${mapWithTechInfo.side ?? ""}`; - return { - ...map, - lengthName: length.name, - overallCount, - qualityName, - qualityCount, - difficultyName, - difficultyCount, - chapterSide, - }; - }); + return { + ...mapWithTechInfo, + lengthName: length.name, + lengthDescription: length.description, + overallCount, + qualityName, + qualityCount, + difficultyName, + difficultyCount, + chapterSide, + }; + } + ); - return mapsWithInfo; + return mapsWithTechAndRatingInfo; }; -const Maps = ({ +export const Maps = ({ isLoadingMod, - isNormalMod, + modType, isMapperNameVisiblePermitted, - mapIds, + mapsWithTechInfo, colors, }: MapsProps) => { //get common data @@ -141,50 +140,21 @@ const Maps = ({ const lengths = lengthQuery.data ?? []; - //get maps data - const mapsQueries = api.useQueries( - (useQueriesApi) => mapIds.map( - (id) => useQueriesApi.map.getById( - { id }, - { queryKey: ["map.getById", { id, tableName: "Map" }] }, - ), - ), - ); - - const isLoadingMaps = isLoadingMod || mapsQueries.some((query) => query.isLoading); - - const maps = useMemo(() => { - if (isLoadingMaps) return []; - - - const maps_maybeEmpty: Map[] = []; - - mapsQueries.forEach((mapQuery) => { - const map = mapQuery.data; - - if (map) maps_maybeEmpty.push(map); - }); - - if (!maps_maybeEmpty.length) console.log(`maps_maybeEmpty is empty. mapIds = "${mapIds}"`); - - - if (isNormalMod) return maps_maybeEmpty.sort((a, b) => a.name.localeCompare(b.name)); - - return maps_maybeEmpty; - }, [isLoadingMaps, mapsQueries, mapIds, isNormalMod]); - - //get ratings data const ratingQueries = api.useQueries( - (useQueriesApi) => mapIds.map( - (id) => useQueriesApi.rating.getMapRatingData( - { mapId: id }, - { queryKey: ["rating.getMapRatingData", { mapId: id }] }, - ), - ), + (useQueriesApi) => { + if (isLoadingMod) return []; + + return mapsWithTechInfo.map( + (mapWithTechInfo) => useQueriesApi.rating.getMapRatingData( + { mapId: mapWithTechInfo.id }, + { queryKey: ["rating.getMapRatingData", { mapId: mapWithTechInfo.id }] }, + ), + ); + }, ); - const isLoadingRatings = isLoadingMaps || ratingQueries.some((query) => query.isLoading); + const isLoadingRatings = isLoadingMod || ratingQueries.some((query) => query.isLoading); const ratingsFromMapIds = useMemo(() => { if (isLoadingRatings) return []; @@ -197,28 +167,26 @@ const Maps = ({ if (rating !== undefined) ratings_maybeEmpty.push(rating); }); - if (!ratings_maybeEmpty.length) console.log(`ratings_maybeEmpty is empty. mapIds = "${mapIds}"`); + if (!ratings_maybeEmpty.length) console.log(`ratings_maybeEmpty is empty. mapsWithTechInfo = "${mapsWithTechInfo}"`); return ratings_maybeEmpty; - }, [isLoadingRatings, ratingQueries, mapIds]); //TODO: figure out if mapIds can be removed from this dependency array + }, [isLoadingRatings, ratingQueries, mapsWithTechInfo]); //TODO: figure out if mapsWithTechInfo can be removed from this dependency array //check that all data is loaded - const isLoading = isLoadingMaps || isLoadingRatings || qualityQuery.isLoading || difficultyQuery.isLoading || lengthQuery.isLoading; + const isLoading = isLoadingRatings || qualityQuery.isLoading || difficultyQuery.isLoading || lengthQuery.isLoading; //get maps with quality, difficulty, and length names - const mapsWithInfo = useMemo( - () => getMapsWithInfo(isLoading, maps, ratingsFromMapIds, lengths, qualities, difficulties), - [isLoading, maps, ratingsFromMapIds, qualities, difficulties, lengths], + const mapsWithTechAndRatingInfo = useMemo( + () => getMapsWithTechAndRatingInfo(isLoading, mapsWithTechInfo, ratingsFromMapIds, lengths, qualities, difficulties), + [isLoading, mapsWithTechInfo, ratingsFromMapIds, qualities, difficulties, lengths], ); return ( Maps - + ); -}; - -export default Maps; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/mods/maps/mapsTable.tsx b/src/components/mods/maps/mapsTable.tsx index 5d30c2e8..7665069e 100644 --- a/src/components/mods/maps/mapsTable.tsx +++ b/src/components/mods/maps/mapsTable.tsx @@ -1,11 +1,15 @@ -import { ActionIcon, createStyles } from "@mantine/core"; -import { DataTable, type DataTableSortStatus } from "mantine-datatable"; -import type { Map } from "~/components/mods/types"; import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { ActionIcon, Text, createStyles } from "@mantine/core"; +import { DataTable, type DataTableSortStatus } from "mantine-datatable"; +import type { MapWithTechAndRatingInfo, Mod } from "~/components/mods/types"; import { CirclePlus } from "tabler-icons-react"; +import { ModsTableTooltip } from "../modsTableTooltip"; import { expandedModColors } from "~/styles/expandedModColors"; import { TABLE_HEADER_ARROW_ZOOM } from "~/consts/tableHeaderArrowZoom"; import type { DifficultyColor } from "~/styles/difficultyColors"; +import { getOrdinal } from "~/utils/getOrdinal"; +import { COMING_SOON_PATHNAME } from "~/consts/pathnames"; @@ -22,11 +26,14 @@ const useStyles = createStyles( margin: `0 ${theme.spacing.sm} ${theme.spacing.xl}`, backgroundColor: expandedModColors.default.backgroundColor, }, - "&&&& table": { + "&&&&&& table": { borderSpacing: "0 20px", // Border spacing adds space before the header, so we move the table up transform: 'translate(0, -20px)', }, + "&&&&&& thead": { + top: "0", + }, "&&&& th": { fontWeight: "bold", border: "none", @@ -99,26 +106,15 @@ const useStyles = createStyles( -type MapWithInfo = { - lengthName: string, - overallCount: number, - qualityName: string, - qualityCount: number, - difficultyName: string, - difficultyCount: number, - chapterSide?: string; -} & Map; - - type MapsTableSortStatus = { - columnAccessor: keyof MapWithInfo; //narrow from "typeof string" + columnAccessor: keyof MapWithTechAndRatingInfo; //narrow from "typeof string" } & DataTableSortStatus; export type MapsTableProps = { - isNormalMod: boolean; + modType: Mod["type"]; isMapperNameVisiblePermitted: boolean; - mapsWithInfo: MapWithInfo[]; + mapsWithTechAndRatingInfo: MapWithTechAndRatingInfo[]; isLoading: boolean; colors: DifficultyColor; }; @@ -141,15 +137,18 @@ const getSortStatusFromIsNormalMod = (isNormalMod: boolean): MapsTableSortStatus -const MapsTable = ( +export const MapsTable = ( { - isNormalMod, + modType, isMapperNameVisiblePermitted, - mapsWithInfo, + mapsWithTechAndRatingInfo, isLoading, colors, }: MapsTableProps ) => { + const isNormalMod = modType === "Normal"; + + //handle sorting const [sortStatus, setSortStatus] = useState(getSortStatusFromIsNormalMod(isNormalMod)); @@ -159,7 +158,7 @@ const MapsTable = ( ); const sortedMapsWithInfo = useMemo(() => { - const sortedMaps = [...mapsWithInfo].sort( + const sortedMaps = [...mapsWithTechAndRatingInfo].sort( (a, b) => { const columnAccessor = sortStatus.columnAccessor; @@ -178,14 +177,14 @@ const MapsTable = ( return sortedMaps; - }, [mapsWithInfo, sortStatus]); + }, [mapsWithTechAndRatingInfo, sortStatus]); //handle mapper name visibility const isMapperNameVisible = !isNormalMod && isMapperNameVisiblePermitted; - const { cx, classes } = useStyles({ colors }); + const { classes } = useStyles({ colors }); return ( { + const { name, chapterSide, overallRank } = mapWithTechAndRatingInfo; + + + let dropdownBaseString: string | undefined = undefined; + + if (modType === "Normal") { + if (chapterSide === undefined) throw `chapterSide is undefined for map ${mapWithTechAndRatingInfo.id} in a Normal mod.`; + + dropdownBaseString = `Level: ${chapterSide}.`; + } else if (modType === "Contest") { + if (overallRank === null) dropdownBaseString = ""; + else dropdownBaseString = `Place: ${getOrdinal(overallRank, false)}.`; + } + + + const mapNameStringForTooltip = `Map: ${name}.`; + + return ( + dropdownBaseString === undefined ? ( + + {name} + + ) : ( + + ) + ); + }, titleClassName: classes.leftColumnTitle, cellsClassName: classes.leftColumnCells, }, { accessor: "qualityName", title: "Quality", + ellipsis: true, + render: (mapWithTechAndRatingInfo) => { + if (mapWithTechAndRatingInfo.qualityCount === 0) return ( + + {mapWithTechAndRatingInfo.qualityName} + + ); + + return ( + + ); + }, cellsClassName: classes.columnCells, }, { accessor: "difficultyName", title: "Difficulty", + ellipsis: true, + render: (mapWithTechAndRatingInfo) => { + const difficultyNameFromMap = mapWithTechAndRatingInfo.difficultyName; + + let difficultyStringForDisplay: string; + if (mapWithTechAndRatingInfo.difficultyCount === 0) { + difficultyStringForDisplay = mapWithTechAndRatingInfo.difficultyName; + } else { + const [parentDifficulty, childDifficulty] = difficultyNameFromMap.split(": "); + + if (parentDifficulty === undefined || childDifficulty === undefined) return ""; + + difficultyStringForDisplay = `${childDifficulty} ${parentDifficulty}`; + } + + + if (mapWithTechAndRatingInfo.difficultyCount === 0) return ( + + {mapWithTechAndRatingInfo.difficultyName} + + ); + + return ( + + ); + }, cellsClassName: classes.columnCells, }, { accessor: "lengthName", title: "Length", + ellipsis: true, + render: (mapWithTechAndRatingInfo) => ( + + ), cellsClassName: classes.columnCells, }, { accessor: "mapperNameString", title: "Mapper Name", + ellipsis: true, + render: (mapWithTechAndRatingInfo) => ( + + ), hidden: !isMapperNameVisible, cellsClassName: classes.columnCells, }, { accessor: "rate", title: "Rate", - render: (_) => ( - - - + ellipsis: true, + render: (_mapWithTechAndRatingInfo) => ( + + + + + + )} + /> + ), titleClassName: classes.rightColumnTitle, cellsClassName: classes.rightColumnCells, @@ -242,6 +356,4 @@ const MapsTable = ( onSortStatusChange={setSortStatus as Dispatch>} //un-narrow type to match types in DataTable /> ); -}; - -export default MapsTable; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/mods/modCarousel.tsx b/src/components/mods/modCarousel.tsx index 369b1c20..99419770 100644 --- a/src/components/mods/modCarousel.tsx +++ b/src/components/mods/modCarousel.tsx @@ -10,7 +10,7 @@ import type { DifficultyColor } from "~/styles/difficultyColors"; const useStyles = createStyles( ( - theme, + _theme, { colors }: { colors: DifficultyColor; }, ) => ({ carousel: { @@ -21,7 +21,8 @@ const useStyles = createStyles( flexDirection: "column", alignItems: "stretch", gap: "10px", - padding: "20px", + /** top | left and right | bottom */ + padding: "21px 20px 20px", }, }, viewport: { @@ -66,7 +67,7 @@ type modCarouselProps = { -const ModCarousel = ({ gamebananaModId, numberOfMaps, colors }: modCarouselProps) => { +export const ModCarousel = ({ gamebananaModId, numberOfMaps, colors }: modCarouselProps) => { const { imageUrls } = useGamebananaModImageUrls({ gamebananaModId }); @@ -99,7 +100,4 @@ const ModCarousel = ({ gamebananaModId, numberOfMaps, colors }: modCarouselProps ) ); -}; - - -export default ModCarousel; +}; \ No newline at end of file diff --git a/src/components/mods/modDownloadButton/modDownloadButton.tsx b/src/components/mods/modDownloadButton/modDownloadButton.tsx index c3cdfd91..e8ef47b2 100644 --- a/src/components/mods/modDownloadButton/modDownloadButton.tsx +++ b/src/components/mods/modDownloadButton/modDownloadButton.tsx @@ -1,15 +1,11 @@ - -import { useContext } from "react"; import Link from "next/link"; import Image from "next/image"; import { Group, Popover, Text, createStyles } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; +import { LinkButton } from "~/components/linkButton"; import { useGamebananaModDownloadUrl } from "~/hooks/gamebananaApi"; import { FAQ_PAGE_PATHNAME } from "~/consts/pathnames"; import { OLYMPUS_INSTALLATION_URL } from "~/consts/olympusInstallationUrl"; -import { type DifficultyColor } from "~/styles/difficultyColors"; -import { colorsForDifficultyIndex } from "~/styles/modsColors"; -import { currentDifficultyTabIndexContext } from "../modsTable"; import everestLogo from "../../../../public/images/everest-logo/everest-logo.png"; @@ -23,24 +19,8 @@ type ModDownloadButtonProps = { const useStyles = createStyles( - ( - theme, - { - colors, - isOpened, - }: { - colors: DifficultyColor; - isOpened: boolean; - } - ) => { + (theme) => { return ({ - downloadButton: { - backgroundColor: isOpened ? colors.primaryHover.backgroundColor : colors.primary.backgroundColor, - color: isOpened ? colors.primaryHover.textColor : colors.primary.textColor, - /* left/right top/bottom */ - padding: "2px 10px", - borderRadius: "8px", - }, dropdown: { '&&': { backgroundColor: theme.white, @@ -75,26 +55,23 @@ export const ModDownloadButton = ({ gamebananaModId }: ModDownloadButtonProps) = const [debouncedIsOpened] = useDebouncedValue(isOpened, 110); - const currentTabIndex = useContext(currentDifficultyTabIndexContext); - - - const colors = colorsForDifficultyIndex(currentTabIndex); - - const { classes } = useStyles({ colors, isOpened }); + const { classes } = useStyles(); return ( - + classNames={{ dropdown: classes.dropdown, arrow: classes.arrow }} + > - - + 1-Click Install - + - + onMouseLeave={close} + > + Install the mod directly using Olympus, a mod manager for Celeste. - + You can also use one of the other methods. diff --git a/src/components/mods/modsTable.tsx b/src/components/mods/modsTable.tsx index 597eabdf..1ee15bef 100644 --- a/src/components/mods/modsTable.tsx +++ b/src/components/mods/modsTable.tsx @@ -1,23 +1,25 @@ import { DataTable, DataTableSortStatus } from "mantine-datatable"; import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState, createContext } from "react"; import { createPortal } from "react-dom"; -import ExpandedMod from "~/components/mods/expandedMod"; -import { createStyles } from "@mantine/core"; +import { ExpandedMod } from "~/components/mods/expandedMod"; +import { createStyles, Text } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { Difficulty, Quality } from "~/components/mods/types"; -import { type ModType, ModType as modTypes } from "@prisma/client"; +import type { ModType, Publisher as PrismaPublisher, Mod } from "@prisma/client"; import { StringSearch } from "~/components/filterPopovers/stringSearch"; import { NumberSearch } from "~/components/filterPopovers/numberSearch"; import { ListSelect } from "~/components/filterPopovers/listSelect"; -import { getNonEmptyArray } from "~/utils/getNonEmptyArray"; -import type { ModWithInfo } from "~/components/mods/types"; +import { ModsTableTooltip } from "./modsTableTooltip"; +import { truncateString } from "~/utils/truncateString"; +import type { ModWithInfo, Tech } from "~/components/mods/types"; import { noRatingsFoundMessage } from "~/consts/noRatingsFoundMessage"; +import { defaultToLocaleDateStringOptions } from "~/consts/defaultToLocaleDateStringOptions"; import { colorsForDifficultyIndex, greatestValidDifficultyIndex } from "~/styles/modsColors"; import { canonicalDifficultyNames, difficultyColors, type DifficultyColor } from "~/styles/difficultyColors"; import { expandedModColors } from "~/styles/expandedModColors"; import { TABLE_HEADER_ARROW_ZOOM } from "~/consts/tableHeaderArrowZoom"; -import { blackBackgroundColor } from "~/styles/layoutColors"; import { pageContentHeightPixels } from "~/styles/pageContentHeightPixels"; +import { blackBackgroundColor } from "~/styles/layoutColors"; @@ -26,8 +28,18 @@ export const currentDifficultyTabIndexContext = createContext(nul const PAGE_SIZES = [5, 10, 15, 20, 25, 50, 100, 250, 500, 1000]; const DEFAULT_PAGE_SIZE_INDEX = 1; +const QUERY_DEBOUNCE_TIME_MILLISECONDS = 200; const ACTIVE_DIFFICULTY_TAB_BORDER_HEIGHT = "2px"; +// TODO: remove these parameters, limit the width of the columns in the table in some way, and let the datatable columns' `ellipsis` property handle the overflow. +const NAME_COLUMN_MAX_LETTERS = 35; +const PUBLISHER_COLUMN_MAX_LETTERS = 15; +const TECHS_COLUMN_MAX_LETTERS = 20; + + +/** Easiest difficulty first */ +const defaultDifficultyNamesForFallback = canonicalDifficultyNames.map(lowercaseDifficultyName => lowercaseDifficultyName.charAt(0).toUpperCase() + lowercaseDifficultyName.slice(1)); + const useStyles = createStyles( ( @@ -136,8 +148,8 @@ const useStyles = createStyles( modCell: { // 4 ampersands to increase selectivity of class to ensure it overrides any other css "&&&&": { - /* top | left and right | bottom */ - padding: `${theme.spacing.sm} ${theme.spacing.xl} ${theme.spacing.sm}`, + /* top and bottom | left and right */ + padding: `${theme.spacing.sm} ${theme.spacing.xl}`, backgroundColor: expandedModColors.default.backgroundColor, color: expandedModColors.default.textColor, borderWidth: 0, @@ -247,14 +259,23 @@ const useStyles = createStyles( +type AdditionalColumnAccessor = "qualityCount" | "difficultyCount"; + +type ColumnAccessor = keyof ModWithInfo | AdditionalColumnAccessor; + +type ExtendedModWithInfo = { + [key in AdditionalColumnAccessor]: number; +} & ModWithInfo; + type ModsTableSortStatus = { - columnAccessor: keyof ModWithInfo; //narrow from "typeof string" + columnAccessor: ColumnAccessor; // narrow from "typeof string" } & DataTableSortStatus; type ModsTableProps = { qualities: Quality[]; difficulties: Difficulty[]; + techs: Tech[]; modsWithInfo: ModWithInfo[]; isLoading: boolean; }; @@ -264,10 +285,32 @@ type ModsTableProps = { // We create a seperate ModsTable component to prevent the Mods queries // running again when the ModsTable state changes. -export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: ModsTableProps) => { +export const ModsTable = ({ qualities, difficulties, techs, modsWithInfo, isLoading }: ModsTableProps) => { const [currentTabIndex, setCurrentTabIndex] = useState(null); //track the currently selected parent difficulty + const techNames = useMemo( // get tech names for filter component + () => [...techs] + .sort( // sort first by difficulty order, then by name + (a, b) => { + const aDifficulty = difficulties.find(difficulty => difficulty.id === a.difficultyId); + if (!aDifficulty) throw `Difficulty ${a.difficultyId} doesn't exist. Tech ${a.id} is invalid.`; + + const bDifficulty = difficulties.find(difficulty => difficulty.id === b.difficultyId); + if (!bDifficulty) throw `Difficulty ${b.difficultyId} doesn't exist. Tech ${b.id} is invalid.`; + + if (aDifficulty.order !== bDifficulty.order) { // sort by difficulty order + return bDifficulty.order - aDifficulty.order; // harder difficulties have higher orders, and we want them to sort first + } + + return a.name.localeCompare(b.name); // sort by name + } + ) + .map((tech) => tech.name), + [qualities], + ); + + const qualityNames = useMemo( //get quality names for filter component () => [...qualities] .sort((a, b) => b.order - a.order) //better qualities have higher orders, so we want them to sort first @@ -278,10 +321,16 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: const parentDifficultyNames = useMemo( //get parent difficulty names for filter component - () => difficulties - .filter((difficulty) => difficulty.parentDifficultyId === 0) //parent difficulties all have the nullParent difficulty, with id = 0, as their parent - .sort((a, b) => a.order - b.order) //easier difficulties have lower orders, and we want them to sort first - .map((difficulty) => difficulty.name), + () => { + if (difficulties.length === 0) { + return defaultDifficultyNamesForFallback; + } + + return difficulties + .filter((difficulty) => difficulty.parentDifficultyId === 0) //parent difficulties all have the nullParent difficulty, with id = 0, as their parent + .sort((a, b) => a.order - b.order) //easier difficulties have lower orders, and we want them to sort first + .map((difficulty) => difficulty.name); + }, [difficulties], ); @@ -328,28 +377,67 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: [difficulties, modsWithInfo, parentDifficultyNames, currentTabIndex], ); + + + //handle filtering const [nameQuery, setNameQuery] = useState(""); - const [debouncedNameQuery, _cancelDebouncedNameQueryChange] = useDebouncedValue(nameQuery, 200); + const [debouncedNameQuery, _cancelDebouncedNameQueryChange] = useDebouncedValue(nameQuery, QUERY_DEBOUNCE_TIME_MILLISECONDS); const isNameFiltered = nameQuery !== ""; - const [mapCountRange, setMapCountRange] = useState<[number | undefined, number | undefined]>([undefined, undefined]); //[min, max] - const isMapCountFiltered = mapCountRange[0] !== undefined || mapCountRange[1] !== undefined; const [selectedModTypes, setSelectedModTypes] = useState([]); const isModTypeFiltered = selectedModTypes.length > 0; + + const [publisherQuery, setPublisherQuery] = useState(""); + const [debouncedPublisherQuery, _cancelDebouncedPublisherQueryChange] = useDebouncedValue(publisherQuery, QUERY_DEBOUNCE_TIME_MILLISECONDS); + const isPublishersFiltered = publisherQuery !== ""; + + + type PublicationDate = Mod["timeCreatedGamebanana"]; + /** [min, max] */ + type PublicationDateRange = [PublicationDate | undefined, PublicationDate | undefined]; + + const [publicationDateRange, setPublicationDateRange] = useState([undefined, undefined]); // [min, max] + const isPublicationDateFiltered = publicationDateRange[0] !== undefined || publicationDateRange[1] !== undefined; + + + const [selectedTechsAny, setSelectedTechsAny] = useState([]); + const isTechsAnyFiltered = selectedTechsAny.length > 0; + + const [selectedTechsFC, setSelectedTechsFC] = useState([]); + const isTechsFCFiltered = selectedTechsFC.length > 0; + + const [doesTechsFCFilterIncludeTechsAny, setDoesTechsFCFilterIncludeTechsAny] = useState(false); + + const [selectedQualities, setSelectedQualities] = useState([]); const isQualityFiltered = selectedQualities.length > 0; + + const [qualityRatingsCountRange, setQualityRatingsCountRange] = useState<[number | undefined, number | undefined]>([undefined, undefined]); // [min, max] + const isQualityRatingsCountFiltered = qualityRatingsCountRange[0] !== undefined || qualityRatingsCountRange[1] !== undefined; + + const [selectedChildDifficulties, setSelectedChildDifficulties] = useState([]); - const isChildDifficultyFiltered = selectedChildDifficulties.length > 0; + const isChildDifficultiesFiltered = selectedChildDifficulties.length > 0; + + + const [difficultyRatingsCountRange, setDifficultyRatingsCountRange] = useState<[number | undefined, number | undefined]>([undefined, undefined]); // [min, max] + const isDifficultyRatingsCountFiltered = difficultyRatingsCountRange[0] !== undefined || difficultyRatingsCountRange[1] !== undefined; + + + const [mapCountRange, setMapCountRange] = useState<[number | undefined, number | undefined]>([undefined, undefined]); // [min, max] + const isMapCountFiltered = mapCountRange[0] !== undefined || mapCountRange[1] !== undefined; + // Reset tab index if the difficulties change. useEffect(() => { setCurrentTabIndex(parentDifficultyNames.length > 0 ? 0 : null); }, [difficulties, parentDifficultyNames]); + // Check selected child difficulties when childDifficultyNames changes. useEffect(() => { const newSelectedChildDifficulties = selectedChildDifficulties.filter(childDifficulty => childDifficultyNames.includes(childDifficulty)); @@ -359,6 +447,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: } }, [selectedChildDifficulties, childDifficultyNames]); + const filteredModsWithInfo = useMemo(() => { return modsWithInfo.filter((modWithInfo) => { if ( @@ -370,28 +459,52 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: if ( - mapCountRange[0] !== undefined || - mapCountRange[1] !== undefined + selectedModTypes.length && + !selectedModTypes.includes(modWithInfo.type) ) { - if ( - mapCountRange[0] !== undefined && - modWithInfo.Map.length < mapCountRange[0] - ) { - return false; - } + return false; + } - if ( - mapCountRange[1] !== undefined && - modWithInfo.Map.length > mapCountRange[1] - ) { - return false; - } + + if ( + debouncedPublisherQuery && + !modWithInfo.publisherName.toLowerCase().includes(debouncedPublisherQuery.trim().toLowerCase()) + ) { + return false; } if ( - selectedModTypes.length && - !selectedModTypes.includes(modWithInfo.type) + publicationDateRange[0] !== undefined && + modWithInfo.timeCreatedGamebanana < publicationDateRange[0] + ) { + return false; + } + + if ( + publicationDateRange[1] !== undefined && + modWithInfo.timeCreatedGamebanana > publicationDateRange[1] + ) { + return false; + } + + + if ( + selectedTechsAny.length && + !selectedTechsAny.some(techId => modWithInfo.TechsAny.includes(techId)) + ) { + return false; + } + + + if ( + selectedTechsFC.length && ( + ( + doesTechsFCFilterIncludeTechsAny && !selectedTechsFC.some(techId => modWithInfo.TechsAny.includes(techId) || modWithInfo.TechsFC.includes(techId)) + ) || ( + !doesTechsFCFilterIncludeTechsAny && !selectedTechsFC.some(techId => modWithInfo.TechsFC.includes(techId)) + ) + ) ) { return false; } @@ -405,6 +518,21 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: } + if ( + qualityRatingsCountRange[0] !== undefined && + modWithInfo.Quality.count < qualityRatingsCountRange[0] + ) { + return false; + } + + if ( + qualityRatingsCountRange[1] !== undefined && + modWithInfo.Quality.count > qualityRatingsCountRange[1] + ) { + return false; + } + + if (currentTabIndex !== null) { // Check parent difficulty const parentDifficultyName = parentDifficultyNames[currentTabIndex]; @@ -422,12 +550,13 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: return false; } } - // Mod has a difficulty rating, so we check if it's difficulty is a child of parentDifficulty. + // Mod has a difficulty rating, so we check if its difficulty is a child of parentDifficulty. else if (!modWithInfo.Difficulty.name.startsWith(parentDifficultyName)) { return false; } } + if ( selectedChildDifficulties.length && !selectedChildDifficulties.some(childDifficulty => modWithInfo.Difficulty.name.endsWith(childDifficulty)) @@ -436,9 +565,39 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: } + if ( + difficultyRatingsCountRange[0] !== undefined && + modWithInfo.Difficulty.count < difficultyRatingsCountRange[0] + ) { + return false; + } + + if ( + difficultyRatingsCountRange[1] !== undefined && + modWithInfo.Difficulty.count > difficultyRatingsCountRange[1] + ) { + return false; + } + + + if ( + mapCountRange[0] !== undefined && + modWithInfo.MapsWithTechInfo.length < mapCountRange[0] + ) { + return false; + } + + if ( + mapCountRange[1] !== undefined && + modWithInfo.MapsWithTechInfo.length > mapCountRange[1] + ) { + return false; + } + + return true; }); - }, [modsWithInfo, difficulties, debouncedNameQuery, mapCountRange, selectedModTypes, selectedQualities, selectedChildDifficulties, currentTabIndex, parentDifficultyNames]); + }, [debouncedNameQuery, selectedModTypes, debouncedPublisherQuery, publicationDateRange, selectedTechsAny, selectedTechsFC, selectedQualities, qualityRatingsCountRange, selectedChildDifficulties, difficultyRatingsCountRange, mapCountRange, currentTabIndex, parentDifficultyNames, difficulties, modsWithInfo]); @@ -454,27 +613,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: const sortedModsWithInfo = [...filteredModsWithInfo]; - if (columnAccessor === "Map") { - sortedModsWithInfo.sort( - (a, b) => { - const propertyANum = Number(a.Map.length); - const propertyBNum = Number(b.Map.length); - - const aIsNan = isNaN(propertyANum); - const bIsNan = isNaN(propertyBNum); - - if (aIsNan && bIsNan) return 0; - if (aIsNan) return -1; - if (bIsNan) return 1; - - return ( - sortStatus.direction === "asc" ? - propertyANum - propertyBNum : - propertyBNum - propertyANum - ); - }, - ); - } else if (columnAccessor === "Quality") { + if (columnAccessor === "Quality") { sortedModsWithInfo.sort( (a, b) => { if (a === b) return 0; @@ -488,7 +627,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: return ( sortStatus.direction === "asc" ? - bQuality.order - aQuality.order : //b-a because better qualities have higher orders, but we want them to sort first when ascending + bQuality.order - aQuality.order : // b-a because better qualities have higher orders, but we want them to sort first when ascending aQuality.order - bQuality.order ); }, @@ -507,12 +646,35 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: return ( sortStatus.direction === "asc" ? - aDifficulty.order - bDifficulty.order : + aDifficulty.order - bDifficulty.order : // a-b because easier difficulties have lower orders, but we want them to sort first when ascending bDifficulty.order - aDifficulty.order ); }, ); - } else { + } else if (columnAccessor === "mapCount") { // map count + sortedModsWithInfo.sort( + (a, b) => { + const propertyAString = String((a as ExtendedModWithInfo)[columnAccessor]); + const propertyBString = String((b as ExtendedModWithInfo)[columnAccessor]); + + const propertyANum = Number(propertyAString); + const propertyBNum = Number(propertyBString); + + const aIsNan = isNaN(propertyANum); + const bIsNan = isNaN(propertyBNum); + + if (aIsNan && bIsNan) return 0; + if (aIsNan) return -1; + if (bIsNan) return 1; + + return ( + sortStatus.direction === "asc" ? + propertyANum - propertyBNum : + propertyBNum - propertyANum + ); + }, + ); + } else if (columnAccessor === "name" || columnAccessor === "publisherName") { // handles name, publisherName sortedModsWithInfo.sort( (a, b) => { const propertyAString = String(a[columnAccessor]); @@ -525,6 +687,8 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: ); }, ); + } else { + throw `Invalid sorting column accessor: "${columnAccessor}"`; } return sortedModsWithInfo; @@ -560,7 +724,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: //reset page when required useEffect(() => { setPage(1); - }, [sortStatus, pageSize, debouncedNameQuery, mapCountRange, selectedModTypes, selectedQualities, selectedChildDifficulties, currentTabIndex]); + }, [sortStatus, pageSize, debouncedNameQuery, selectedModTypes, debouncedPublisherQuery, publicationDateRange, selectedTechsAny, selectedTechsFC, selectedQualities, qualityRatingsCountRange, selectedChildDifficulties, difficultyRatingsCountRange, mapCountRange, currentTabIndex]); //handle providing datatable with correct subset of data // const [records, setRecords] = useState(sortedModsWithIsExpanded.slice(0, pageSize)); @@ -586,7 +750,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: - // apply the correct class to the body element to change the background color of the pagination dropdown + // apply the correct class (defined in ~/styles/globals.css) to the body element to change the background color of the pagination dropdown useEffect(() => { const menuClassNames = canonicalDifficultyNames.map((difficultyName) => `${difficultyName.toLowerCase()}-menu`); @@ -621,6 +785,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: const { cx, classes } = useStyles({ colors }); + const tabColors: string[] = Array(canonicalDifficultyNames.length); Object.entries(classes).forEach( @@ -658,7 +823,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: return () => { tabsParent.removeChild(tabContainer); - + setTabContainer(null); }; }, [classes.tabContainer]); @@ -694,7 +859,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: { + const modTypeString = (modWithInfo.type !== "Normal" && modWithInfo.type !== "LobbyOther") ? + modWithInfo.type : ( + modWithInfo.type === "Normal" ? "Campaign" : "Other Lobby" + ); + + return ( + + ); + }, filter: ( modWithInfo.Map.length, - filter: ( - - ), - filtering: isMapCountFiltered, - titleClassName: isMapCountFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, - }, - { - accessor: "type", - title: "Type", + accessor: "publisherName", + title: "Publisher", sortable: true, + ellipsis: true, + render: (modWithInfo) => { + const publicationDate = new Date(modWithInfo.timeCreatedGamebanana * 1000); // convert from seconds to milliseconds + return ( + + ); + }, filter: ( - ), - filtering: isModTypeFiltered, - titleClassName: isModTypeFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle + filtering: isPublishersFiltered, + titleClassName: isPublishersFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, }, { accessor: "Quality", title: "Quality", sortable: true, - render: (modWithInfo) => modWithInfo.Quality.name, + ellipsis: true, + render: (modWithInfo) => { + if (modWithInfo.Quality.count === 0) return ( + + {modWithInfo.Quality.name} + + ); + + return ( + + ); + }, filter: ( modWithInfo.Difficulty.name, + ellipsis: true, + render: (modWithInfo) => { + const difficultyNameFromMod = modWithInfo.Difficulty.name; + + if (modWithInfo.Difficulty.count === 0) { + return ( + + {difficultyNameFromMod} + + ); + } + + + const [parentDifficultyName, childDifficultyName] = difficultyNameFromMod.split(": "); + + if (parentDifficultyName === undefined || childDifficultyName === undefined) return ""; + + + return ( + + ); + }, filter: ( ), - filtering: isChildDifficultyFiltered, - titleClassName: isChildDifficultyFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, + filtering: isChildDifficultiesFiltered, + titleClassName: isChildDifficultiesFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, + }, + { + accessor: "mapCount", + title: "# Maps", + sortable: true, + ellipsis: true, + filter: ( + + ), + filtering: isMapCountFiltered, + titleClassName: isMapCountFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, + }, + { + accessor: "techsAny", + title: "Techs", + sortable: false, + ellipsis: true, + render: (modWithInfo) => { + const techsAnyString = modWithInfo.TechsAny.join(", "); + const techsFCString = modWithInfo.TechsFC.join(", "); + + return ( + + ); + }, + filter: ( + + ), + filtering: isTechsAnyFiltered, + titleClassName: isTechsAnyFiltered ? classes.filteredColumnTitle : classes.unfilteredColumnTitle, cellsClassName: (record) => { return cx( classes.modCell, @@ -821,7 +1100,7 @@ export const ModsTable = ({ qualities, difficulties, modsWithInfo, isLoading }: }, ]} sortStatus={sortStatus} - onSortStatusChange={setSortStatus as Dispatch>} //un-narrow type to match types in DataTable + onSortStatusChange={setSortStatus as Dispatch>} // un-narrow type to match types in DataTable rowExpansion={{ trigger: "click", allowMultiple: false, diff --git a/src/components/mods/modsTableTooltip.tsx b/src/components/mods/modsTableTooltip.tsx new file mode 100644 index 00000000..fe64f76f --- /dev/null +++ b/src/components/mods/modsTableTooltip.tsx @@ -0,0 +1,62 @@ +import { Tooltip, Text } from "@mantine/core"; + + +// styles are defined in ~/styles/globals.css + + +type ModsTableTooltipProps_Base = { + dropdownString: string; + multiline?: boolean; + maxWidth?: number; +}; + +type ModsTableTooltipProps = ( + { + targetString: string; + childComponent?: never; + } | { + targetString?: never; + childComponent: JSX.Element; + + } +) & ModsTableTooltipProps_Base; + + + + +/** `target` must be able to accept a `ref`. */ +export const ModsTableTooltip = ({ + targetString, + childComponent, + dropdownString, + multiline = false, // I couldn't make multiline work with static tooltips so we are using floating for now. we could also use a popover styled like a tooltip. + maxWidth, +}: ModsTableTooltipProps) => { + + return ( + + {dropdownString} + + } + > + { + childComponent ? + childComponent : ( + + {targetString} + + ) + } + + ); +}; \ No newline at end of file diff --git a/src/components/mods/publicationDate.tsx b/src/components/mods/publicationDate.tsx index 235e1ce5..234ff93b 100644 --- a/src/components/mods/publicationDate.tsx +++ b/src/components/mods/publicationDate.tsx @@ -15,13 +15,10 @@ const PUBLICATION_DATE_LABEL = "Published: "; -const PublicationDate = ({ publicationDate }: PublicationDateProps) => { +export const PublicationDate = ({ publicationDate }: PublicationDateProps) => { return ( {`${PUBLICATION_DATE_LABEL}${publicationDate === undefined ? "Undefined" : publicationDate?.toLocaleDateString()}`} ); -}; - - -export default PublicationDate; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/mods/publisherName.tsx b/src/components/mods/publisherName.tsx index abf0f419..238aefc0 100644 --- a/src/components/mods/publisherName.tsx +++ b/src/components/mods/publisherName.tsx @@ -16,7 +16,7 @@ const PUBLISHER_NAME_LABEL = "Publisher: "; -const PublisherName = ({ publisherId }: PublisherNameProps) => { +export const PublisherName = ({ publisherId }: PublisherNameProps) => { const publisherQuery = api.publisher.getById.useQuery({ id: publisherId }, { queryKey: ["publisher.getById", { id: publisherId }] }); const publisher = publisherQuery.data; @@ -46,7 +46,4 @@ const PublisherName = ({ publisherId }: PublisherNameProps) => { {PUBLISHER_NAME_LABEL + publisherName} ); -}; - - -export default PublisherName; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/mods/types.ts b/src/components/mods/types.ts index 3afc1f9d..10fbaa5f 100644 --- a/src/components/mods/types.ts +++ b/src/components/mods/types.ts @@ -15,7 +15,7 @@ export type ModRatingData = RouterOutputs["rating"]["getModRatingData"]; export type ModNoRatingData = Pick; export type ModYesRatingData = { //TODO: figure out how to do this through narrowing instead of directly referencing the type modId: ModRatingData["modId"]; -} & RatingsInfo +} & RatingsInfo; export type Map = RouterOutputs["map"]["getById"]; @@ -24,11 +24,13 @@ export type MapRatingData = RouterOutputs["rating"]["getMapRatingData"]; export type MapNoRatingData = Pick; export type MapYesRatingData = { //TODO: figure out how to do this through narrowing instead of directly referencing the type mapId: MapRatingData["mapId"]; -} & RatingsInfo +} & RatingsInfo; export type Quality = RouterOutputs["quality"]["getAll"][number]; export type Difficulty = RouterOutputs["difficulty"]["getAll"][number]; +export type Publisher = RouterOutputs["publisher"]["getAll"][number]; +export type Tech = RouterOutputs["tech"]["getAll"][number]; export type Length = RouterOutputs["length"]["getAll"][number]; @@ -40,9 +42,32 @@ type RatingInfo = { count: number; }; +export type MapWithTechInfo = { + TechsAny: Tech[]; + TechsFC: Tech[]; +} & Omit; + +export type MapWithTechAndRatingInfo = { + lengthName: string; + lengthDescription: string; + overallCount: number; + qualityName: string; + qualityCount: number; + difficultyName: string; + difficultyCount: number; + chapterSide?: string; +} & MapWithTechInfo; + + export type ModWithInfo = { overallCount: number; + /** should only be defined if there are no difficulty ratings */ lowestCannonicalDifficulty: number | undefined; Quality: RatingInfo; Difficulty: RatingInfo; -} & Mod; \ No newline at end of file + mapCount: number; + MapsWithTechInfo: MapWithTechInfo[]; + publisherName: Publisher["name"]; + TechsAny: Tech["name"][]; + TechsFC: Tech["name"][]; +} & Omit; \ No newline at end of file diff --git a/src/consts/defaultToLocaleDateStringOptions.ts b/src/consts/defaultToLocaleDateStringOptions.ts new file mode 100644 index 00000000..b520fb1d --- /dev/null +++ b/src/consts/defaultToLocaleDateStringOptions.ts @@ -0,0 +1,5 @@ +export const defaultToLocaleDateStringOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "numeric", +}; \ No newline at end of file diff --git a/src/consts/noRatingsFoundMessage.ts b/src/consts/noRatingsFoundMessage.ts index 3937adf9..c93581cb 100644 --- a/src/consts/noRatingsFoundMessage.ts +++ b/src/consts/noRatingsFoundMessage.ts @@ -1 +1 @@ -export const noRatingsFoundMessage = "No ratings."; \ No newline at end of file +export const noRatingsFoundMessage = "No ratings"; \ No newline at end of file diff --git a/src/hooks/gamebananaApi.ts b/src/hooks/gamebananaApi.ts index 117be034..0d0e30e9 100644 --- a/src/hooks/gamebananaApi.ts +++ b/src/hooks/gamebananaApi.ts @@ -48,7 +48,7 @@ const GAMEBANANA_ITEM_FIELDS: { Mod: GAMEBANANA_MOD_FIELDS, }; -type GamebananaItemFields< //TODO!!!: make this work then continue below +type GamebananaItemFields< //TODO: make this work then continue below ItemType extends GamebananaItemType > = typeof GAMEBANANA_ITEM_FIELDS[ItemType]; diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index c96bc167..207b9c57 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -7,27 +7,32 @@ import axios, { AxiosRequestConfig } from 'axios'; +const EXTRA_FETCH_LOGS = false; + + + + export const useFetch = (url: string) => { const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState(null); - console.log(`useFetch fired with url: ${url}`); + if (EXTRA_FETCH_LOGS) console.log(`useFetch fired with url: ${url}`); useEffect( () => { - console.log("useFetch useEffect fired"); + if (EXTRA_FETCH_LOGS) console.log("useFetch useEffect fired"); const source = axios.CancelToken.source(); const fetchData = async () => { - console.log("useFetch fetchData fired"); + if (EXTRA_FETCH_LOGS) console.log("useFetch fetchData fired"); if (!url) { - console.log("No url provided"); + if (EXTRA_FETCH_LOGS) console.log("No url provided"); setIsLoading(false); setIsError(true); @@ -43,7 +48,7 @@ export const useFetch = (url: string) => { try { - console.log("useFetch fetchData try block fired"); + if (EXTRA_FETCH_LOGS) console.log("useFetch fetchData try block fired"); setIsLoading(true); setError(undefined); @@ -56,11 +61,11 @@ export const useFetch = (url: string) => { }; - console.log(`call axios with options: ${JSON.stringify(options)}`); + if (EXTRA_FETCH_LOGS) console.log(`call axios with options: ${JSON.stringify(options)}`); const axiosResponse = await axios(options); - console.log(`axiosResponse: ${JSON.stringify(axiosResponse)}`); + if (EXTRA_FETCH_LOGS) console.log(`axiosResponse: ${JSON.stringify(axiosResponse)}`); if (axiosResponse.status !== 200) throw `Error: ${axiosResponse.status}`; @@ -69,6 +74,11 @@ export const useFetch = (url: string) => { setData(data); } catch (error) { + if (axios.isCancel(error)) { + console.log(`Request canceled for url: ${url}`); + return; + } + console.log(`Error in useFetch: ${error}`); setIsError(true); diff --git a/src/pages/mods/index.tsx b/src/pages/mods/index.tsx index 04ca4ccd..bf0d4591 100644 --- a/src/pages/mods/index.tsx +++ b/src/pages/mods/index.tsx @@ -1,8 +1,8 @@ -import { type NextPage } from "next"; +import type { NextPage } from "next"; import { api } from "~/utils/api"; import { useMemo } from "react"; import { createStyles, Title } from "@mantine/core"; -import { Difficulty, Mod, ModRatingData, ModYesRatingData, Quality } from "~/components/mods/types"; +import type { Difficulty, Mod, ModRatingData, ModYesRatingData, Quality, Publisher, MapWithTechInfo, Tech } from "~/components/mods/types"; import { noRatingsFoundMessage } from "~/consts/noRatingsFoundMessage"; import { Layout } from "~/components/layout/layout"; import { ModsTable } from "~/components/mods/modsTable"; @@ -25,11 +25,35 @@ const useStyles = createStyles( -const getModWithInfo = (isLoading: boolean, mods: Mod[], ratingsFromModIds: ModRatingData[], qualities: Quality[], difficulties: Difficulty[], mapCanonicalDifficulty: Map): ModWithInfo[] => { +const getLowestDifficultyId = (difficultyOneId: number, difficultyTwoId: number, difficulties: Difficulty[]): number => { + const difficultyOne = difficulties.find((difficulty) => difficulty.id === difficultyOneId); + + if (!difficultyOne) throw `Difficulty ${difficultyOneId} not found. This should not happen.`; + + + const difficultyTwo = difficulties.find((difficulty) => difficulty.id === difficultyTwoId); + + if (!difficultyTwo) throw `Difficulty ${difficultyTwoId} not found. This should not happen.`; + + + if (difficultyOne.order < difficultyTwo.order) return difficultyOneId; + + return difficultyTwoId; +}; + + + + +const getModsWithInfo = (isLoading: boolean, mods: Mod[], ratingsFromModIds: ModRatingData[], qualities: Quality[], difficulties: Difficulty[], publishers: Publisher[], mapsWithTechInfo: MapWithTechInfo[]): ModWithInfo[] => { if (isLoading) return []; const modsWithInfo: ModWithInfo[] = mods.map((mod) => { + const publisher = publishers.find((publisher) => publisher.id === mod.publisherId); + + if (publisher === undefined) throw `Mod ${mod.id} has an undefined publisher (id: ${mod.publisherId}). This should not happen.`; + + const rating = ratingsFromModIds.find((rating) => rating.modId === mod.id); if (rating === undefined) throw `Mod ${mod.id} has an undefined rating - this should not happen.`; @@ -75,24 +99,67 @@ const getModWithInfo = (isLoading: boolean, mods: Mod[], ratingsFromModIds: ModR } + const techIdsAny: Set = new Set(); + const techIdsFC: Set = new Set(); + const mapsWithInfoForMod: MapWithTechInfo[] = []; + + mod.Map.forEach( + ({ id: mapId }) => { + const map = mapsWithTechInfo.find((map) => map.id === mapId); + + if (map === undefined) { + console.log(`mapId = ${mapId}`); + console.log(mapsWithTechInfo); + throw `Map ${mapId} not found in loop 1. This should not happen.`; + } + + + map.TechsAny.forEach( + (tech) => { + techIdsAny.add(tech.name); + techIdsFC.delete(tech.name); + } + ); + + map.TechsFC.forEach( + (tech) => { + if (!techIdsAny.has(tech.name)) techIdsFC.add(tech.name); + } + ); + + + mapsWithInfoForMod.push(map); + } + ); + + // We set lowestCannonicalDifficulty on mods which have no difficulty rating. // This works as every mod has at least one map. // Thus every mod will either have a difficulty rating or a lowestCannonicalDifficulty. - let lowestCannonicalDifficulty: number | undefined = undefined; + let lowestCannonicalDifficultyId: number | undefined = undefined; if (difficultyId === -1) { difficultyName = noRatingsFoundMessage; - mod.Map.forEach(mapId => { - const mapDifficulty = mapCanonicalDifficulty.get(mapId.id); + mod.Map.forEach( + ({ id: mapId }) => { + const map = mapsWithTechInfo.find((map) => map.id === mapId); + + if (map === undefined) throw `Map ${mapId} not found in loop 2. This should not happen.`; + + + const mapCanonicalDifficultyId = map.canonicalDifficultyId; + + if (mapCanonicalDifficultyId === undefined) throw `Cannonical difficulty for map ${mapId} not found. This should not happen.`; - if (mapDifficulty === undefined) throw `Cannonical difficulty for map ${mapId.id} not found. This should not happen.`; - - if (lowestCannonicalDifficulty === undefined || mapDifficulty < lowestCannonicalDifficulty) { - lowestCannonicalDifficulty = mapDifficulty; + if (lowestCannonicalDifficultyId === undefined) { + lowestCannonicalDifficultyId = mapCanonicalDifficultyId; + } else { + lowestCannonicalDifficultyId = getLowestDifficultyId(lowestCannonicalDifficultyId, mapCanonicalDifficultyId, difficulties); + } } - }); + ); } else { if (difficultyCount === 0) throw `Difficulty count is 0 for mod ${mod.id} but difficultyId is ${difficultyId} (and not -1) - this should not happen.`; @@ -106,10 +173,11 @@ const getModWithInfo = (isLoading: boolean, mods: Mod[], ratingsFromModIds: ModR difficultyName = difficulty.name; } + return { ...mod, overallCount, - lowestCannonicalDifficulty, + lowestCannonicalDifficulty: lowestCannonicalDifficultyId, Quality: { id: qualityId, name: qualityName, @@ -120,9 +188,16 @@ const getModWithInfo = (isLoading: boolean, mods: Mod[], ratingsFromModIds: ModR name: difficultyName, count: difficultyCount, }, + Map: undefined, // overwrite the Map property in mod + mapCount: mapsWithInfoForMod.length, + MapsWithTechInfo: mapsWithInfoForMod, + publisherName: publisher.name, + TechsAny: Array.from(techIdsAny), + TechsFC: Array.from(techIdsFC), }; }); + return modsWithInfo; }; @@ -139,6 +214,14 @@ const Mods: NextPage = () => { const difficulties = difficultyQuery.data ?? []; + const publisherQuery = api.publisher.getAll.useQuery({}, { queryKey: ["publisher.getAll", {}] }); + const publishers = publisherQuery.data ?? []; + + + const techQuery = api.tech.getAll.useQuery({}, { queryKey: ["tech.getAll", {}] }); + const techs = techQuery.data ?? []; + + /* //get all mod ids //not using pagination because backend pagination is awkward with mantine-datatable //TODO: implement this const modIdsQuery = api.mod.getIds.useQuery({}, { queryKey: ["mod.getIds", {}] }); @@ -213,14 +296,16 @@ const Mods: NextPage = () => { const mods_maybeEmpty: Mod[] = []; - modsQuery.data.forEach((mod) => { - const modWithIsExpanded = { - ...mod, - isExpanded: false, - }; //TODO!: prove this cast is safe + modsQuery.data.forEach( + (mod) => { + const modWithIsExpanded = { + ...mod, + isExpanded: false, + }; //TODO!: prove this cast is safe - mods_maybeEmpty.push(modWithIsExpanded); - }); + mods_maybeEmpty.push(modWithIsExpanded); + } + ); if (!mods_maybeEmpty.length) console.log(`mods_maybeEmpty is empty.`); @@ -258,61 +343,66 @@ const Mods: NextPage = () => { }, [isLoadingRatings, ratingQueries, /*modIds,*/ mods]); //TODO: figure out if modIds/mods can be removed from this dependency array - // We only query maps for which the corresponding mod doesn't have a difficulty rating. - // We later set lowestCannonicalDifficulty on mods which have no difficulty rating. - // This works as every mod has at least one map. - // Thus every mod will either have a difficulty rating or a lowestCannonicalDifficulty. - const mapQuery = api.useQueries( - (useQueriesApi) => { - if (isLoadingRatings) return []; - - const mapIds: number[] = []; - mods.forEach(mod => { - const rating = ratingsFromModIds.find((rating) => rating.modId === mod.id); - if (rating === undefined) throw `Mod ${mod.id} has an undefined rating - this should not happen.`; - - // We only query maps for which the corresponding mod doesn't have a difficulty rating. - if (!("averageDifficultyId" in rating) || rating.averageDifficultyId === undefined || rating.averageDifficultyId === -1) { - mod.Map.forEach(mapId => { - mapIds.push(mapId.id); - }); - } - }); + const mapQuery = api.map.getAll.useQuery({}, { queryKey: ["map.getAll", {}] }); - return mapIds.map((id) => (useQueriesApi.map.getById( - { id }, - { queryKey: ["map.getById", { id, tableName: "Map" }] }, - ))); - }, - ); + const isLoadingMaps = mapQuery.isLoading || !mapQuery.data || !mapQuery.data.length; - const isLoadingMaps = isLoadingRatings || mapQuery.some((query) => query.isLoading); + const mapsWithTechInfo: MapWithTechInfo[] = useMemo(() => { + if (isLoadingRatings || isLoadingMaps) return []; - /**Map */ - const mapCanonicalDifficulty = useMemo(() => { - if (isLoadingMaps) return new Map(); - const mapCanonicalDifficulty = new Map(); + const maps_maybeEmpty: MapWithTechInfo[] = []; - mapQuery.forEach(mapRatingQuery => { - const map = mapRatingQuery.data; + mapQuery.data.forEach( + (mapFromQuery) => { + const rating = ratingsFromModIds.find((rating) => rating.modId === mapFromQuery.modId); - if (map !== undefined) { - mapCanonicalDifficulty.set(map.id, map.canonicalDifficultyId); + if (!rating) throw `Rating for mod ${mapFromQuery.modId} (via map ${mapFromQuery.id}) not found. This should not happen.`; + + + const techsAny: Tech[] = []; + const techsFC: Tech[] = []; + + + mapFromQuery.MapToTechs.forEach( + (mapToTechRelation) => { + const tech = techs.find((tech) => tech.id === mapToTechRelation.techId); + + if (!tech) throw `Tech ${mapToTechRelation.techId} not found. This should not happen.`; + + + if (mapToTechRelation.fullClearOnlyBool) techsFC.push(tech); + else techsAny.push(tech); + } + ); + + + const mapWithInfo: MapWithTechInfo & { MapsToTechs: undefined; } = { + ...mapFromQuery, + MapsToTechs: undefined, // overwrite the MapsToTechs property in oldMap + TechsAny: techsAny, + TechsFC: techsFC, + }; + + + maps_maybeEmpty.push(mapWithInfo); } - }); + ); + + if (!maps_maybeEmpty.length) console.log("maps_maybeEmpty is empty."); + - return mapCanonicalDifficulty; - }, [isLoadingMaps, mapQuery]); + return maps_maybeEmpty; + }, [isLoadingRatings, isLoadingMaps, ratingsFromModIds, techs, mapQuery.data]); //check that all data is loaded - const isLoading = isLoadingMods || isLoadingRatings || isLoadingMaps || qualityQuery.isLoading || difficultyQuery.isLoading; + const isLoading = isLoadingMods || isLoadingRatings || isLoadingMaps || qualityQuery.isLoading || difficultyQuery.isLoading || publisherQuery.isLoading || techQuery.isLoading; //get mods with map count, and quality and difficulty names const modsWithInfo = useMemo(() => { - return getModWithInfo(isLoading, mods, ratingsFromModIds, qualities, difficulties, mapCanonicalDifficulty); - }, [isLoading, mods, ratingsFromModIds, qualities, difficulties, mapCanonicalDifficulty]); + return getModsWithInfo(isLoading, mods, ratingsFromModIds, qualities, difficulties, publishers, mapsWithTechInfo); + }, [isLoading, mods, ratingsFromModIds, qualities, difficulties, publishers, mapsWithTechInfo]); const { classes } = useStyles(); @@ -321,7 +411,7 @@ const Mods: NextPage = () => { return ( Mods List - + ); }; diff --git a/src/server/api/routers/map_mod_publisher/map.ts b/src/server/api/routers/map_mod_publisher/map.ts index 99df06cf..c0a583a8 100644 --- a/src/server/api/routers/map_mod_publisher/map.ts +++ b/src/server/api/routers/map_mod_publisher/map.ts @@ -21,16 +21,15 @@ import { zodOutputIdObject } from "../../utils/zodOutputIdObject"; //TODO!: check all routers to make sure disconnect/connect or set are used in any many-to-many relationships +type MapToTechRelation = { techId: number, fullClearOnlyBool: boolean; }[]; -type TrimmedMap = Omit; -type TrimmedMapArchive = Omit; -type TrimmedMapEdit = Omit; -type TrimmedMapNewWithModNew = Omit; -type TrimmedMapNewSolo = Omit; - +type TrimmedMap = Omit & { MapToTechs: MapToTechRelation; }; +type TrimmedMapArchive = Omit & { Map_ArchiveToTechs: MapToTechRelation; }; +type TrimmedMapEdit = Omit & { Map_EditToTechs: MapToTechRelation; }; +type TrimmedMapNewWithModNew = Omit & { Map_NewWithMod_NewToTechs: MapToTechRelation; }; +type TrimmedMapNewSolo = Omit & { Map_NewSoloToTechs: MapToTechRelation; }; -type MapToTechRelation = { techId: number, fullClearOnlyBool: boolean; }[]; type ExpandedMap = Map & { MapToTechs: MapToTechRelation; }; type ExpandedMapArchive = Map_Archive & { Map_ArchiveToTechs: MapToTechRelation; }; @@ -275,7 +274,7 @@ type MapTableName = typeof mapTableNameArray[number]; type MapUnion< TableName extends MapTableName, - ReturnAll extends boolean + ReturnAll extends boolean, > = ( TableName extends "Map" ? IfElse : ( @@ -462,10 +461,10 @@ const getMapByName = async< const whereObject: Prisma.MapWhereInput = exactMatch ? { [tableName === "Map" ? "modId" : "mod_newId"]: modId, - name: { equals: query } + name: { equals: query }, } : { - name: { contains: query } + name: { contains: query }, }; @@ -597,7 +596,12 @@ const getMapByName = async< -const getTechConnectObject = (input: z.infer | z.infer): Prisma.MapToTechsCreateWithoutMapInput[] => { +type TechIdsForConnection = { + techAnyIds?: number[]; + techFullClearIds?: number[]; +}; + +const getTechConnectObject = (input: TechIdsForConnection): Prisma.MapToTechsCreateWithoutMapInput[] => { const techConnectObject: Prisma.MapToTechsCreateWithoutMapInput[] = []; @@ -619,6 +623,31 @@ const getTechConnectObject = (input: z.infer | z.infer return techConnectObject; }; +const getTechIdsForConnection = (mapToTechs: MapToTechRelation): TechIdsForConnection => { + const techAnyIds: number[] = []; + const techFullClearIds: number[] = []; + + mapToTechs.forEach( + (mapToTech) => { + if (mapToTech.fullClearOnlyBool) { + techFullClearIds.push(mapToTech.techId); + } + else { + techAnyIds.push(mapToTech.techId); + } + } + ); + + + const techIdsForConnection: TechIdsForConnection = { + techAnyIds: techAnyIds, + techFullClearIds: techFullClearIds, + }; + + + return techIdsForConnection; +}; + @@ -631,9 +660,9 @@ export const mapRouter = createTRPCRouter({ orderBy: getOrderObjectArray(input.selectors, input.directions), }); }), - + rest_getAll: publicProcedure - .meta({ openapi: { method: "GET", path: "/maps" }}) + .meta({ openapi: { method: "GET", path: "/maps" } }) .input(z.void()) .output(restMapSchema.array()) .query(({ ctx }) => { @@ -801,7 +830,7 @@ export const mapRouter = createTRPCRouter({ }; } - let map: Map | TrimmedMapNewSolo; + let map: TrimmedMap | TrimmedMapNewSolo; if (checkPermissions(MODLIST_MODERATOR_PERMISSION_STRINGS, ctx.user.permissions)) { map = await ctx.prisma.map.create({ @@ -811,6 +840,7 @@ export const mapRouter = createTRPCRouter({ User_ApprovedBy: { connect: { id: ctx.user.id } }, MapToTechs: { create: techConnectObject }, }, + select: defaultMapSelect, }); @@ -833,6 +863,7 @@ export const mapRouter = createTRPCRouter({ ...mapCreateData, Map_NewSoloToTechs: { create: techConnectObject }, }, + select: defaultMapNewSoloSelect, }); } @@ -1058,7 +1089,7 @@ export const mapRouter = createTRPCRouter({ const currentTime = getCurrentTime(); - const techConnectObject = getTechConnectObject(input); + const techConnectObject = getTechConnectObject(getTechIdsForConnection(mapEdit.Map_EditToTechs)); await ctx.prisma.map_Archive.create({ diff --git a/src/styles/globals.css b/src/styles/globals.css index 830bcb36..5188cddc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -32,6 +32,13 @@ a { } +/* ModsTable tooltips */ +.mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + border: 2px solid; + border-radius: 16px; +} + + /* pagination popover background */ .beginner-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { background-color: #16367b; /* difficultyColors.beginner.primary.backgroundColor */ @@ -58,6 +65,13 @@ a { color: black; /* difficultyColors.beginner.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.beginner-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #84a4e9; /* difficultyColors.beginner.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.beginner.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.beginner.primaryHover.textColor */ +} + /* pagination popover background */ .intermediate-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -85,6 +99,13 @@ a { color: white; /* difficultyColors.intermediate.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.intermediate-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #87d0f9; /* difficultyColors.intermediate.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.intermediate.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.intermediate.primaryHover.textColor */ +} + /* pagination popover background */ .advanced-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -112,6 +133,13 @@ a { color: white; /* difficultyColors.advanced.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.advanced-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #d1cedf; /* difficultyColors.advanced.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.advanced.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.advanced.primaryHover.textColor */ +} + /* pagination popover background */ .expert-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -139,6 +167,13 @@ a { color: white; /* difficultyColors.expert.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.expert-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #f8f0ff; /* difficultyColors.expert.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.expert.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.expert.primaryHover.textColor */ +} + /* pagination popover background */ .grandmaster-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -166,6 +201,13 @@ a { color: white; /* difficultyColors.grandmaster.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.grandmaster-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #c1adff; /* difficultyColors.grandmaster.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.grandmaster.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.grandmaster.primaryHover.textColor */ +} + /* pagination popover background */ .astral-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -193,6 +235,13 @@ a { color: white; /* difficultyColors.astral.secondaryHover.textColor */ } +/* ModsTable tooltips */ +.astral-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #c1adff; /* difficultyColors.astral.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.astral.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.astral.primaryHover.textColor */ +} + /* pagination popover background */ .celestial-menu .mantine-Menu-dropdown.mantine-Menu-dropdown { @@ -218,4 +267,11 @@ a { } .celestial-menu .mantine-Menu-dropdown button:hover .mantine-Text-root { color: white; /* difficultyColors.celestial.secondaryHover.textColor */ +} + +/* ModsTable tooltips */ +.celestial-menu .mantine-TooltipFloating-tooltip.mantine-TooltipFloating-tooltip { + background-color: #c1adff; /* difficultyColors.celestial.primaryHover.backgroundColor */ + color: "black"; /* difficultyColors.celestial.primaryHover.textColor */ + border-color: "black"; /* difficultyColors.celestial.primaryHover.textColor */ } \ No newline at end of file diff --git a/src/utils/getOrdinal.ts b/src/utils/getOrdinal.ts new file mode 100644 index 00000000..0d779b13 --- /dev/null +++ b/src/utils/getOrdinal.ts @@ -0,0 +1,12 @@ +/** `returnZeroth` defaults to `true` */ +export const getOrdinal = ( + n: number, + returnZeroth = true, +): string => { + if (n === 1) return "1st"; + if (n === 2) return "2nd"; + if (n === 3) return "3rd"; + if (n === 0 && !returnZeroth) return ""; + + return `${n}th`; +} \ No newline at end of file diff --git a/src/utils/truncateString.ts b/src/utils/truncateString.ts new file mode 100644 index 00000000..f96fb10a --- /dev/null +++ b/src/utils/truncateString.ts @@ -0,0 +1,11 @@ +export const truncateString = (string: string, maxLength: number, trimString = true, ellipsis = "...") => { + const trimmedString = trimString ? string.trim() : string; + + + if (trimmedString.length <= maxLength) { + return trimmedString; + } + + + return trimmedString.slice(0, maxLength - ellipsis.length) + ellipsis; +} \ No newline at end of file