diff --git a/src/features/game/items/AppItemPage.tsx b/src/features/game/items/AppItemPage.tsx index e866a58..77885de 100644 --- a/src/features/game/items/AppItemPage.tsx +++ b/src/features/game/items/AppItemPage.tsx @@ -1,6 +1,5 @@ import { Box } from "@mantine/core"; import { useQueryStates } from "nuqs"; -import { useCallback, useMemo } from "react"; import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; import { @@ -9,6 +8,7 @@ import { } from "#/features/game/items/ItemFilterBar"; import { ItemVirtualGrid } from "#/features/game/items/ItemVirtualGrid"; import type { + AppItem, CollectItemInput, GameCollectedItemsDal, GameFilterConfig, @@ -24,6 +24,7 @@ import { type AppItemPageProps = { items: AnyGameConfig["ITEMS"]; + resolveLinkedItems: (item: AppItem) => AppItem[]; dal: GameCollectedItemsDal; gameFilterConfig?: GameFilterConfig; }; @@ -36,7 +37,12 @@ const universalParsers = { showCollectableOnly: showCollectableOnlyParser, }; -const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { +const AppItemPage = ({ + items, + resolveLinkedItems, + dal, + gameFilterConfig, +}: AppItemPageProps) => { const [universalParams, setUniversalParams] = useQueryStates(universalParsers); const { @@ -51,7 +57,7 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { gameFilterConfig?.parsers ?? {}, ); - const activeGameParams: Record = useMemo(() => { + const getActiveGameParams = (): Record => { if (!gameFilterConfig) return {}; return Object.fromEntries( gameFilterConfig.defs.map((def) => [ @@ -59,27 +65,25 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { (gameParams as Record)[def.key] ?? def.defaultValue, ]), ); - }, [gameFilterConfig, gameParams]); + }; + const activeGameParams = getActiveGameParams(); - const setParam = useCallback( - (key: string, value: string | undefined) => { - setGameParams({ [key]: value ?? null } as Parameters< - typeof setGameParams - >[0]); - }, - [setGameParams], - ); + const setParam = (key: string, value: string | undefined) => { + return setGameParams({ [key]: value ?? null } as Parameters< + typeof setGameParams + >[0]); + }; - const setUniversalParam = useCallback( - (key: keyof typeof universalParams, value: string | boolean) => { - setUniversalParams({ [key]: value } as Parameters< - typeof setUniversalParams - >[0]); - }, - [setUniversalParams], - ); + const setUniversalParam = ( + key: keyof typeof universalParams, + value: string | boolean, + ): Promise => { + return setUniversalParams({ [key]: value } as Parameters< + typeof setUniversalParams + >[0]); + }; - const clearAllFilters = useCallback(() => { + const clearAllFilters = () => { setUniversalParams({ search: "", showCollectedItems: true, @@ -94,37 +98,28 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { ) as Parameters[0], ); } - }, [setUniversalParams, setGameParams, gameFilterConfig]); + }; // Collected items const { data: collectedData } = useDalQuery(dal.list, undefined); - const collectedIds = useMemo( - () => (collectedData ?? []).map((r) => r.itemId), - [collectedData], - ); + const collectedIds = (collectedData ?? []).map((r) => r.itemId); const { mutate: collect } = useDalMutation(dal.collect); const { mutate: uncollect } = useDalMutation(dal.uncollect); - const handleCollect = useCallback( - ({ itemId, itemName }: CollectItemInput) => collect({ itemId, itemName }), - [collect], - ); - const handleUncollect = useCallback( - ({ itemId, itemName }: CollectItemInput) => uncollect({ itemId, itemName }), - [uncollect], - ); + const handleCollect = ({ itemId, itemName }: CollectItemInput) => + collect({ itemId, itemName }); + + const handleUncollect = ({ itemId, itemName }: CollectItemInput) => + uncollect({ itemId, itemName }); // Filtering - const isUncollectable = useCallback( - (category: string) => - items.uncollectableCategories.some( - (uc) => String(category).toLowerCase() === String(uc).toLowerCase(), - ), - [items.uncollectableCategories], - ); + const isUncollectable = (category: string) => + items.uncollectableCategories.some( + (uc) => String(category).toLowerCase() === String(uc).toLowerCase(), + ); - const filteredItems = useMemo(() => { + const getFilteredItems = () => { let result = items.all; // Search filter @@ -147,8 +142,7 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { result = result.filter((item) => { const collected = collectedIds.includes(item.id); if (collected && !showCollectedItems) return false; - if (!collected && !showUncollectedItems) return false; - return true; + return !(!collected && !showUncollectedItems); }); // Game-specific filters @@ -157,27 +151,19 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { } return result; - }, [ - items.all, - search, - showCollectableOnly, - showCollectedItems, - showUncollectedItems, - collectedIds, - isUncollectable, - gameFilterConfig, - activeGameParams, - ]); + }; + const filteredItems = getFilteredItems(); - const filteredCategories = useMemo(() => { + const getFilteredCategories = () => { const catSet = new Set(filteredItems.map((item) => String(item.category))); return items.categories.map((c) => String(c)).filter((c) => catSet.has(c)); - }, [filteredItems, items.categories]); + }; + const filteredCategories = getFilteredCategories(); const hasCollectableItems = items.collectable.length > 0; // Active filter chips - const activeFilters: ActiveFilter[] = useMemo(() => { + const getActiveFilters = (): ActiveFilter[] => { const filters: ActiveFilter[] = []; if (search) { @@ -237,17 +223,8 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { } return filters; - }, [ - search, - showCollectedItems, - showUncollectedItems, - dimUncollectedItems, - showCollectableOnly, - gameFilterConfig, - activeGameParams, - setUniversalParam, - setParam, - ]); + }; + const activeFilters = getActiveFilters(); const renderGameFilters = gameFilterConfig?.renderControls( @@ -285,6 +262,7 @@ const AppItemPage = ({ items, dal, gameFilterConfig }: AppItemPageProps) => { AppItem[]; dal: GameCollectedItemsDal; isCollectable: boolean; onCollect: ({ itemId, itemName }: CollectItemInput) => void; @@ -38,12 +40,14 @@ type ItemInfoModalProps = { const ItemInfoModal = ({ item, + resolveLinkedItems, dal, isCollectable, onCollect, onUncollect, }: ItemInfoModalProps) => { - const [screenshotMode, setScreenshotMode] = useState(false); + const [_screenshotMode, setScreenshotMode] = useState(false); + const screenshotMode = true; // TODO REVERT const containerRef = useRef(null); const gameId = useGameId(); @@ -89,6 +93,8 @@ const ItemInfoModal = ({ } }; + const linkedItems = resolveLinkedItems(item); + const itemContent = ( @@ -146,36 +152,33 @@ const ItemInfoModal = ({ )} - {item.linkedItems && Object.keys(item.linkedItems).length > 0 && ( + {linkedItems.length > 0 && ( <> - - {Object.entries(item.linkedItems).map(([key, value]) => { - const linkedArr = Array.isArray(value) ? value : [value]; - return linkedArr.map((linked) => { - const imageUrl = - screenshotMode && - "imageUrl" in linked && - typeof linked.imageUrl === "string" - ? linked.imageUrl - : null; - return ( - + + {linkedItems.map((linkedItem) => { + return ( + + - {key}: + {linkedItem.category}: - {imageUrl && ( - - )} - {linked.name} - - ); - }); + + + + {linkedItem.name} + + + ); })} - + )} diff --git a/src/features/game/items/ItemVirtualGrid.tsx b/src/features/game/items/ItemVirtualGrid.tsx index 5d59a96..715b894 100644 --- a/src/features/game/items/ItemVirtualGrid.tsx +++ b/src/features/game/items/ItemVirtualGrid.tsx @@ -1,7 +1,7 @@ import { Text } from "@mantine/core"; import { modals } from "@mantine/modals"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ItemCard } from "#/features/game/items/ItemCard"; import { ItemInfoModal } from "#/features/game/items/ItemInfoModal"; import type { @@ -22,6 +22,7 @@ type RowData = type ItemVirtualGridProps = { items: AppItem[]; + resolveLinkedItems: (item: AppItem) => AppItem[]; categories: string[]; uncollectableCategories: string[]; collectedIds: string[]; @@ -33,6 +34,7 @@ type ItemVirtualGridProps = { const ItemVirtualGrid = ({ items, + resolveLinkedItems, categories, uncollectableCategories, collectedIds, @@ -54,13 +56,10 @@ const ItemVirtualGrid = ({ return () => observer.disconnect(); }, []); - const isCollectable = useCallback( - (item: AppItem) => - !uncollectableCategories.some( - (uc) => String(item.category).toLowerCase() === uc.toLowerCase(), - ), - [uncollectableCategories], - ); + const isCollectable = (item: AppItem) => + !uncollectableCategories.some( + (uc) => String(item.category).toLowerCase() === uc.toLowerCase(), + ); const rowData: RowData[] = []; for (const category of categories) { @@ -88,25 +87,23 @@ const ItemVirtualGrid = ({ scrollMargin: containerRef.current?.offsetTop ?? 0, }); - const handleInfo = useCallback( - (item: AppItem) => { - modals.open({ - title: item.name, - size: "md", - centered: true, - children: ( - - ), - }); - }, - [isCollectable, dal, onCollect, onUncollect], - ); + const handleInfo = (item: AppItem) => { + modals.open({ + title: item.name, + size: "md", + centered: true, + children: ( + + ), + }); + }; if (rowData.length === 0) { return ( diff --git a/src/features/game/items/utils.ts b/src/features/game/items/utils.ts index d93afd1..f2938d2 100644 --- a/src/features/game/items/utils.ts +++ b/src/features/game/items/utils.ts @@ -1,89 +1,127 @@ -import { upperFirst } from "@mantine/hooks"; -import type { AppItem } from "#/features/game/items/types"; - -/** Formats a comma-separated category filter value (or "cat:sub") into a human-readable label. */ -const formatCategoryLabel = (raw: string): string => { - const selected = raw ? raw.split(",").filter(Boolean) : []; - if (selected.length === 0) return ""; - if (selected.length === 1) { - const val = selected[0] ?? ""; - if (val.includes(":")) { - const [cat, sub] = val.split(":"); - return `${upperFirst(sub ?? "")} ${upperFirst(cat ?? "")}`; - } - return upperFirst(val); - } - return `${selected.length} selected`; -}; - -/** Returns true if the item's category (and optional subcategory) matches the filter value. */ -const itemMatchesCategory = (item: AppItem, filterValue: string): boolean => { - if (filterValue.includes(":")) { - const [category, subcategory] = filterValue.split(":"); - if (String(item.category) !== category) return false; - return ( - item.subcategory !== undefined && String(item.subcategory) === subcategory - ); - } - return String(item.category) === filterValue; -}; - -type CategoryOption = { - label: string; - value: string; -}; - -type GroupedOption = { - group: string; - items: CategoryOption[]; -}; - -function getItemSubcategories(items: AppItem[]): GroupedOption[] { - const categoryMap = new Map>(); - - for (const item of items) { - const cat = String(item.category); - if (!categoryMap.has(cat)) { - categoryMap.set(cat, new Set()); - } - - if (item.subcategory) { - const subcategoryValue = `${cat}:${String(item.subcategory)}`; - categoryMap.get(cat)?.add(subcategoryValue); - } - } - - const groupedOptions: GroupedOption[] = []; - - for (const [category, subcategories] of categoryMap.entries()) { - const options: CategoryOption[] = []; - - options.push({ - label: `All ${titleCase(category)}`, - value: category, - }); - - if (subcategories.size > 0) { - for (const subcategory of Array.from(subcategories).sort()) { - const [, type] = subcategory.split(":"); - options.push({ - label: `Only ${titleCase(type ?? "")} ${titleCase(category)}`, - value: subcategory, - }); - } - } - - groupedOptions.push({ group: titleCase(category), items: options }); - } - - return groupedOptions.sort((a, b) => a.group.localeCompare(b.group)); -} - -function titleCase(str: string): string { - return str - .split(/[_ ]/) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); -} - -export { getItemSubcategories, formatCategoryLabel, itemMatchesCategory }; +import { upperFirst } from "@mantine/hooks"; +import type { AppItem } from "#/features/game/items/types"; + +/** Formats a comma-separated category filter value (or "cat:sub") into a human-readable label. */ +const formatCategoryLabel = (raw: string): string => { + const selected = raw ? raw.split(",").filter(Boolean) : []; + if (selected.length === 0) return ""; + if (selected.length === 1) { + const val = selected[0] ?? ""; + if (val.includes(":")) { + const [cat, sub] = val.split(":"); + return `${upperFirst(sub ?? "")} ${upperFirst(cat ?? "")}`; + } + return upperFirst(val); + } + return `${selected.length} selected`; +}; + +/** Returns true if the item's category (and optional subcategory) matches the filter value. */ +const itemMatchesCategory = (item: AppItem, filterValue: string): boolean => { + if (filterValue.includes(":")) { + const [category, subcategory] = filterValue.split(":"); + if (String(item.category) !== category) return false; + return ( + item.subcategory !== undefined && String(item.subcategory) === subcategory + ); + } + return String(item.category) === filterValue; +}; + +type CategoryOption = { + label: string; + value: string; +}; + +type GroupedOption = { + group: string; + items: CategoryOption[]; +}; + +const getItemSubcategories = (items: AppItem[]): GroupedOption[] => { + const categoryMap = new Map>(); + + for (const item of items) { + const cat = String(item.category); + if (!categoryMap.has(cat)) { + categoryMap.set(cat, new Set()); + } + + if (item.subcategory) { + const subcategoryValue = `${cat}:${String(item.subcategory)}`; + categoryMap.get(cat)?.add(subcategoryValue); + } + } + + const groupedOptions: GroupedOption[] = []; + + for (const [category, subcategories] of categoryMap.entries()) { + const options: CategoryOption[] = []; + + options.push({ + label: `All ${titleCase(category)}`, + value: category, + }); + + if (subcategories.size > 0) { + for (const subcategory of Array.from(subcategories).sort()) { + const [, type] = subcategory.split(":"); + options.push({ + label: `Only ${titleCase(type ?? "")} ${titleCase(category)}`, + value: subcategory, + }); + } + } + + groupedOptions.push({ group: titleCase(category), items: options }); + } + + return groupedOptions.sort((a, b) => a.group.localeCompare(b.group)); +}; + +const titleCase = (str: string): string => { + return str + .split(/[_ ]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +}; + +/** + * Resolves linked items for a given item by matching names + * from the item's linkedItems field against a list of all items. + */ +const resolveLinkedItems = ( + item: TItem, + allItems: TItem[], +): TItem[] => { + if (!item.linkedItems) return []; + + const results: TItem[] = []; + + for (const [_key, values] of Object.entries(item.linkedItems)) { + const itemsToProcess = Array.isArray(values) ? values : [values]; + + for (const value of itemsToProcess) { + if (!value || !(value as { name?: string }).name) continue; + + const foundItem = allItems.find( + (i) => + i.name.toLowerCase() === + (value as { name: string }).name.toLowerCase(), + ); + + if (foundItem && !results.some((r) => r.id === foundItem.id)) { + results.push(foundItem); + } + } + } + + return results; +}; + +export { + getItemSubcategories, + formatCategoryLabel, + itemMatchesCategory, + resolveLinkedItems, +}; diff --git a/src/features/screenshot/core/ScreenshotContainer.module.css b/src/features/screenshot/core/ScreenshotContainer.module.css index 1db918a..8c68662 100644 --- a/src/features/screenshot/core/ScreenshotContainer.module.css +++ b/src/features/screenshot/core/ScreenshotContainer.module.css @@ -1,3 +1,20 @@ -.container { - border-radius: var(--mantine-radius-lg); +.screenshotContainer { + border-radius: var(--mantine-radius-lg); + border: 2px solid var(--mantine-color-primary-5); + background: light-dark( + linear-gradient( + 0deg, + var(--mantine-color-gray-3) 0%, + var(--mantine-color-gray-1) 25%, + var(--mantine-color-gray-0) 75%, + var(--mantine-color-gray-3) 100% + ), + linear-gradient( + 0deg, + var(--mantine-color-primary-9) 0%, + var(--mantine-color-black) 25%, + var(--mantine-color-black) 75%, + var(--mantine-color-primary-9) 100% + ) + ); } diff --git a/src/features/screenshot/core/ScreenshotContainer.tsx b/src/features/screenshot/core/ScreenshotContainer.tsx index 3dd4b7c..e4ff889 100644 --- a/src/features/screenshot/core/ScreenshotContainer.tsx +++ b/src/features/screenshot/core/ScreenshotContainer.tsx @@ -1,107 +1,109 @@ -import { Avatar, Box, type BoxProps, Group, Stack, Text } from "@mantine/core"; -import cx from "clsx"; -import type { ReactNode } from "react"; -import { forwardRef } from "react"; -import type { LogoSize } from "#/components/AppLogo"; -import { ScreenshotWatermark } from "#/features/screenshot/core/ScreenshotWatermark"; -import classes from "./ScreenshotContainer.module.css"; - -type WatermarkGameConfig = { - METADATA: { - renderLogo: (size: LogoSize) => ReactNode; - label: string; - }; -}; - -type WatermarkConfig = { - gameConfig: WatermarkGameConfig; - logoSize?: LogoSize; - fontSize?: string; - gap?: number | string; -}; - -type ScreenshotContainerProps = { - children: ReactNode; - screenshotMode: boolean; - watermark?: WatermarkConfig | false; - title?: string; - subtitle?: string; - avatarUrl?: string; - className?: string; -} & Omit; - -const ScreenshotContainer = forwardRef< - HTMLDivElement, - ScreenshotContainerProps ->( - ( - { - children, - screenshotMode, - watermark, - title, - subtitle, - avatarUrl, - className, - ...boxProps - }, - ref, - ) => { - const showWatermark = screenshotMode && watermark !== false && watermark; - - return ( - - {avatarUrl && title ? ( - - - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - - ) : ( - <> - {title && ( - - {title} - - )} - {subtitle && ( - - {subtitle} - - )} - - )} - {children} - {showWatermark && ( - - - - )} - - ); - }, -); - -ScreenshotContainer.displayName = "ScreenshotContainer"; - -export { ScreenshotContainer }; -export type { WatermarkConfig }; +import { Avatar, Box, type BoxProps, Group, Stack, Text } from "@mantine/core"; +import cx from "clsx"; +import type { ReactNode } from "react"; +import { forwardRef } from "react"; +import type { LogoSize } from "#/components/AppLogo"; +import { ScreenshotWatermark } from "#/features/screenshot/core/ScreenshotWatermark"; +import classes from "./ScreenshotContainer.module.css"; + +type WatermarkGameConfig = { + METADATA: { + renderLogo: (size: LogoSize) => ReactNode; + label: string; + }; +}; + +type WatermarkConfig = { + gameConfig: WatermarkGameConfig; + logoSize?: LogoSize; + fontSize?: string; + gap?: number | string; +}; + +type ScreenshotContainerProps = { + children: ReactNode; + screenshotMode: boolean; + watermark?: WatermarkConfig | false; + title?: string; + subtitle?: string; + avatarUrl?: string; + className?: string; +} & Omit; + +const ScreenshotContainer = forwardRef< + HTMLDivElement, + ScreenshotContainerProps +>( + ( + { + children, + screenshotMode, + watermark, + title, + subtitle, + avatarUrl, + className, + ...boxProps + }, + ref, + ) => { + const showWatermark = screenshotMode && watermark !== false && watermark; + + return ( + + {avatarUrl && title ? ( + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + ) : ( + <> + {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + )} + {children} + {showWatermark && ( + + + + )} + + ); + }, +); + +ScreenshotContainer.displayName = "ScreenshotContainer"; + +export { ScreenshotContainer }; +export type { WatermarkConfig }; diff --git a/src/games/clairobscur/core/game-config/pages.tsx b/src/games/clairobscur/core/game-config/pages.tsx index adc78a3..3aaf539 100644 --- a/src/games/clairobscur/core/game-config/pages.tsx +++ b/src/games/clairobscur/core/game-config/pages.tsx @@ -1,11 +1,16 @@ import type { GamePages } from "#/features/game/core/types"; import { AppItemPage } from "#/features/game/items/AppItemPage"; +import { resolveLinkedItems } from "#/features/game/items/utils.ts"; import { ITEMS } from "#/games/clairobscur/core/game-config/items"; import { clairObscurCollectedItemsDal } from "#/games/clairobscur/dal/collected-items"; const PAGES: GamePages = { renderItemLookup: () => ( - + resolveLinkedItems(item, ITEMS.all)} + dal={clairObscurCollectedItemsDal} + /> ), }; diff --git a/src/games/remnant2/core/game-config/pages.tsx b/src/games/remnant2/core/game-config/pages.tsx index 7e0b562..6c57bac 100644 --- a/src/games/remnant2/core/game-config/pages.tsx +++ b/src/games/remnant2/core/game-config/pages.tsx @@ -12,6 +12,7 @@ import { formatCategoryLabel, getItemSubcategories, itemMatchesCategory, + resolveLinkedItems, } from "#/features/game/items/utils"; import { ITEMS } from "#/games/remnant2/core/game-config/items"; import { remnant2CollectedItemsDal } from "#/games/remnant2/dal/collected-items"; @@ -140,9 +141,7 @@ const remnant2FilterConfig: GameFilterConfig = { result = result.filter((item) => { const itemDlc = (item as { dlc?: string }).dlc ?? ""; if (excludedDlcs.includes(itemDlc)) return false; - if (includedDlcs.length > 0 && !includedDlcs.includes(itemDlc)) - return false; - return true; + return !(includedDlcs.length > 0 && !includedDlcs.includes(itemDlc)); }); } @@ -154,6 +153,7 @@ const PAGES: GamePages = { renderItemLookup: () => ( resolveLinkedItems(item, ITEMS.all)} dal={remnant2CollectedItemsDal} gameFilterConfig={remnant2FilterConfig} /> diff --git a/src/games/slaythespire2/core/game-config/pages.tsx b/src/games/slaythespire2/core/game-config/pages.tsx index 243e810..f6b5a55 100644 --- a/src/games/slaythespire2/core/game-config/pages.tsx +++ b/src/games/slaythespire2/core/game-config/pages.tsx @@ -8,6 +8,7 @@ import { formatCategoryLabel, getItemSubcategories, itemMatchesCategory, + resolveLinkedItems, } from "#/features/game/items/utils"; import { ITEMS } from "#/games/slaythespire2/core/game-config/items"; import { slayTheSpire2CollectedItemsDal } from "#/games/slaythespire2/dal/collected-items"; @@ -73,6 +74,7 @@ const PAGES: GamePages = { renderItemLookup: () => ( resolveLinkedItems(item, ITEMS.all)} dal={slayTheSpire2CollectedItemsDal} gameFilterConfig={slayTheSpire2FilterConfig} />