diff --git a/apps/stardew.app/src/components/cards/ingredient-card.tsx b/apps/stardew.app/src/components/cards/ingredient-card.tsx new file mode 100644 index 00000000..e3fdffc5 --- /dev/null +++ b/apps/stardew.app/src/components/cards/ingredient-card.tsx @@ -0,0 +1,245 @@ +import Image from "next/image"; + +import bigCraftables from "@/data/big_craftables.json"; +import fishes from "@/data/fish.json"; +import objects from "@/data/objects.json"; + +import type { FishType } from "@/types/items"; + +import { cn } from "@/lib/utils"; +import { Dispatch, SetStateAction, useState } from "react"; + +import { deweaponize } from "@/lib/utils"; + +import { NewItemBadge } from "@/components/new-item-badge"; +import { FishSheet } from "@/components/sheets/fish-sheet"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { IconChevronRight, IconExternalLink } from "@tabler/icons-react"; + +interface Props { + /** + * The ID of the object/big-craftable/category to display + */ + itemID: string; + + /** + * Number to display as the needed count in the card + */ + count: number; + + /** + * Whether the user prefers to see new content + * + * @type {boolean} + * @memberof Props + */ + show: boolean; + + /** + * The handler to display the new content confirmation prompt + * + * @type {Dispatch>} + * @memberof Props + */ + setPromptOpen?: Dispatch>; +} + +interface Item { + isCategory: boolean; + isBC: boolean; + minVersion: string; + name: string; + iconURL: string; + description?: string; + wikiName: string; +} + +const categoryItems: Record = { + "-4": "Any Fish", + "-5": "Any Egg", + "-6": "Any Milk", + "-777": "Wild Seeds (Any)", +}; + +const categoryWikiNames: Record = { + "-4": "Fish", + "-5": "Egg", + "-6": "Milk", + "-777": "Wild_Seeds", +}; + +export function IngredientMinVersion(itemID: string): string { + if (itemID.startsWith("-")) { + return "1.5.0"; + } else if (deweaponize(itemID).key === "BC") { + const item_id = deweaponize(itemID).value; + return bigCraftables[item_id as keyof typeof bigCraftables].minVersion; + } + + return objects[itemID as keyof typeof objects].minVersion; +} + +export function IngredientName(itemID: string): string { + // if itemID is less than 0, it's a category + if (itemID.startsWith("-")) { + return categoryItems[itemID]; + } else if (deweaponize(itemID).key === "BC") { + const item_id = deweaponize(itemID).value; + return bigCraftables[item_id as keyof typeof bigCraftables].name; + } else { + return objects[itemID as keyof typeof objects].name; + } +} + +function GetItemDetails(itemID: string): Item { + // if itemID is less than 0, it's a category + if (itemID.startsWith("-")) { + return { + isCategory: true, + isBC: false, + minVersion: "1.5.0", + name: categoryItems[itemID], + iconURL: `https://cdn.stardew.app/images/(C)${itemID}.webp`, + wikiName: categoryWikiNames[itemID], + }; + } else if (deweaponize(itemID).key === "BC") { + const item_id = deweaponize(itemID).value; + let item = bigCraftables[item_id as keyof typeof bigCraftables]; + + return { + isCategory: false, + isBC: true, + minVersion: item.minVersion, + name: item.name, + iconURL: `https://cdn.stardew.app/images/(BC)${deweaponize(itemID).value}.webp`, + description: item.description, + wikiName: item.name.replaceAll(" ", "_"), + }; + } else { + let item = objects[itemID as keyof typeof objects]; + + return { + isCategory: false, + isBC: false, + minVersion: item.minVersion, + name: item.name, + iconURL: `https://cdn.stardew.app/images/(O)${itemID}.webp`, + description: item.description ?? undefined, + wikiName: item.name.replaceAll(" ", "_"), + }; + } +} + +export const IngredientCard = ({ + itemID, + count, + show, + setPromptOpen, +}: Props) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [fishOpen, setFishOpen] = useState(false); + + const item = GetItemDetails(itemID); + const isFish = itemID in fishes; + const fish = isFish + ? (fishes[itemID as keyof typeof fishes] as FishType) + : null; + + const openChanged = isFish ? setFishOpen : setDialogOpen; + + return ( + <> + + +
{ + if (item.minVersion === "1.6.0" && !show) { + e.preventDefault(); + setPromptOpen?.(true); + return; + } + }} + > + {item.minVersion === "1.6.0" && ( + + )} +
+ {item.name} +
+

{`${item.name} (${count}x)`}

+

+ {item.description ?? ""} +

+
+
+ +
+
+ + + {item.name} + {item.name} + + {item.description} + + + + +
+ +
+
+
+
+ + + ); +}; diff --git a/apps/stardew.app/src/components/dialogs/beta-features-dialog.tsx b/apps/stardew.app/src/components/dialogs/beta-features-dialog.tsx new file mode 100644 index 00000000..7a59239b --- /dev/null +++ b/apps/stardew.app/src/components/dialogs/beta-features-dialog.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + toggleBetaFeatures: () => boolean; +} + +export const BetaFeaturesDialog = ({ + open, + setOpen, + toggleBetaFeatures, +}: Props) => { + return ( + + + + Show Beta Features + + This feature is currently in beta and will likely change often based + on feedback. You can always disable beta features again in your{" "} + + account settings + + . + + + + + + + + + + + + + ); +}; diff --git a/apps/stardew.app/src/components/ingredient-list.tsx b/apps/stardew.app/src/components/ingredient-list.tsx new file mode 100644 index 00000000..c5e285f1 --- /dev/null +++ b/apps/stardew.app/src/components/ingredient-list.tsx @@ -0,0 +1,199 @@ +import fishes from "@/data/fish.json"; +import shipping_items from "@/data/shipping.json"; + +import type { Recipe } from "@/types/recipe"; + +import { usePlayers } from "@/contexts/players-context"; +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; + +import { + IngredientCard, + IngredientMinVersion, + IngredientName, +} from "./cards/ingredient-card"; + +const semverGte = require("semver/functions/gte"); + +interface Props { + /** + * All of the recipes available within the game + */ + recipes: { + [key: string]: T; + }; + + /** + * Player's recipe knowledge + */ + playerRecipes: { + [key: string]: 0 | 1 | 2; + }; + + /** + * Whether to limit ingredients counts to unkown ("0"), known ("1"), or + * "all" recipes. + */ + filterKnown?: string; + + /** + * Limit shown ingredients to those available in a particular season, or + * "all" ingredients. + */ + filterSeason?: string; + + /** + * Allow for searching for specific ingredients. + */ + searchText?: string; + + /** + * Whether the user prefers to see new content + * + * @type {boolean} + * @memberof Props + */ + show: boolean; + + /** + * The handler to display the new content confirmation prompt + * + * @type {Dispatch>} + * @memberof Props + */ + setPromptOpen?: Dispatch>; +} + +class IngredientData { + id: string = ""; + counts: [number, number, number] = [0, 0, 0]; + seasons: string[] = []; + + constructor(itemID: string) { + this.id = itemID; + + if (itemID in fishes) { + const f = fishes[itemID as keyof typeof fishes]; + + if ("seasons" in f) { + this.seasons = f.seasons; + } + } else if (itemID in shipping_items) { + this.seasons = + shipping_items[itemID as keyof typeof shipping_items].seasons; + } + } +} + +type IngredientsRecord = Record; + +export const IngredientList = ({ + recipes, + playerRecipes, + filterKnown, + filterSeason = "all", + searchText, + setPromptOpen, + show, +}: Props) => { + const [gameVersion, setGameVersion] = useState("1.6.0"); + + const { activePlayer } = usePlayers(); + + useEffect(() => { + if (activePlayer) { + // set the minimum game version + if (activePlayer.general?.gameVersion) { + const version = activePlayer.general.gameVersion; + setGameVersion(version); + } + } + }, [activePlayer]); + + const ingredientCounts: IngredientData[] = useMemo(() => { + const reduceIngredients = ( + acc: IngredientsRecord, + [_, v]: [string, T], + status: 0 | 1 | 2, + ): IngredientsRecord => + v.ingredients.reduce((a, i) => { + if (!(i.itemID in a)) { + a[i.itemID] = new IngredientData(i.itemID); + } + + a[i.itemID].counts[status] += i.quantity; + + if (i.itemID in recipes) { + a = reduceIngredients(a, [i.itemID, recipes[i.itemID]], status); + } + + return a; + }, acc); + + const ingredientsMap: IngredientsRecord = Object.entries(recipes).reduce( + (acc, [id, v]) => + reduceIngredients(acc, [id, v], playerRecipes[v.itemID] ?? 0), + {}, + ); + + return Object.values(ingredientsMap).sort((a, b) => { + const a_name = IngredientName(a.id); + const b_name = IngredientName(b.id); + + if (a_name === b_name) { + return 0; + } + + return a_name < b_name ? -1 : 1; + }); + }, [recipes, playerRecipes]); + + return ( +
+ {ingredientCounts + .filter((details) => + semverGte(gameVersion, IngredientMinVersion(details.id)), + ) + .filter((details) => { + if (!searchText) { + return true; + } + + return IngredientName(details.id) + .toLowerCase() + .includes(searchText.toLowerCase()); + }) + .filter((details) => { + if (filterSeason === "all") { + return true; + } + + return ( + details.seasons.length == 0 || + details.seasons.includes(filterSeason) + ); + }) + .map((details): [string, number] => { + switch (filterKnown) { + case "0": + return [details.id, details.counts[0]]; + case "1": + return [details.id, details.counts[1]]; + case "2": + return [details.id, 0]; + default: + return [details.id, details.counts[0] + details.counts[1]]; + } + }) + .filter(([_, count]) => count > 0) + .map(([id, count]) => ( + + ))} +
+ ); +}; diff --git a/apps/stardew.app/src/components/sheets/fish-sheet.tsx b/apps/stardew.app/src/components/sheets/fish-sheet.tsx index 49680525..dccfc35f 100644 --- a/apps/stardew.app/src/components/sheets/fish-sheet.tsx +++ b/apps/stardew.app/src/components/sheets/fish-sheet.tsx @@ -34,9 +34,15 @@ interface Props { open: boolean; setIsOpen: Dispatch>; fish: FishType | null; + showCaught?: boolean; } -export const FishSheet = ({ open, setIsOpen, fish }: Props) => { +export const FishSheet = ({ + open, + setIsOpen, + fish, + showCaught = true, +}: Props) => { const { activePlayer, patchPlayer } = useContext(PlayersContext); const { selectedItems, clearSelection } = useMultiSelect(); const isDesktop = useMediaQuery("(min-width: 768px)"); @@ -102,57 +108,58 @@ export const FishSheet = ({ open, setIsOpen, fish }: Props) => {
- {selectedItems.size > 0 ? ( - <> - - - - ) : ( - <> - {fishCaught.has(fish?.itemID?.toString() ?? "0") ? ( + {showCaught && + (selectedItems.size > 0 ? ( + <> - ) : ( - )} - - )} - {!activePlayer && } + + ) : ( + <> + {fishCaught.has(fish?.itemID?.toString() ?? "0") ? ( + + ) : ( + + )} + + ))} + {showCaught && !activePlayer && } {name && (
diff --git a/apps/stardew.app/src/pages/cooking.tsx b/apps/stardew.app/src/pages/cooking.tsx index 2c5c4697..c9a5f7e2 100644 --- a/apps/stardew.app/src/pages/cooking.tsx +++ b/apps/stardew.app/src/pages/cooking.tsx @@ -10,12 +10,15 @@ import type { Recipe } from "@/types/recipe"; import { useMultiSelect } from "@/contexts/multi-select-context"; import { usePlayers } from "@/contexts/players-context"; import { usePreferences } from "@/contexts/preferences-context"; +import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { AchievementCard } from "@/components/cards/achievement-card"; import { RecipeCard } from "@/components/cards/recipe-card"; +import { BetaFeaturesDialog } from "@/components/dialogs/beta-features-dialog"; import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; import { UnblurDialog } from "@/components/dialogs/unblur-dialog"; +import { FilterSearch } from "@/components/filter-btn"; import { RecipeSheet } from "@/components/sheets/recipe-sheet"; import { Accordion, @@ -25,9 +28,14 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Command, CommandInput } from "@/components/ui/command"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; +import { IngredientList } from "@/components/ingredient-list"; +import { NewItemBadge } from "@/components/new-item-badge"; +import { IconClock } from "@tabler/icons-react"; + const semverGte = require("semver/functions/gte"); const reqs: Record = { @@ -42,7 +50,32 @@ const bubbleColors: Record = { "2": "border-green-900 bg-green-500/20", // completed }; +const seasons = [ + { + value: "all", + label: "All Seasons", + }, + { + value: "Spring", + label: "Spring", + }, + { + value: "Summer", + label: "Summer", + }, + { + value: "Fall", + label: "Fall", + }, + { + value: "Winter", + label: "Winter", + }, +]; + export default function Cooking() { + const router = useRouter(); + const [open, setIsOpen] = useState(false); const [recipe, setRecipe] = useState(null); const [playerRecipes, setPlayerRecipes] = useState<{ @@ -51,14 +84,19 @@ export default function Cooking() { const [gameVersion, setGameVersion] = useState("1.6.0"); + const [activeTab, setActiveTab] = useState("recipes"); + const [search, setSearch] = useState(""); + const [ingredientSearch, setIngredientSearch] = useState(""); const [_filter, setFilter] = useState("all"); const [bulkActionOpen, setBulkActionOpen] = useState(false); const [showPrompt, setPromptOpen] = useState(false); + const [betaDialogOpen, setBetaDialogOpen] = useState(false); const { activePlayer } = usePlayers(); - const { show, toggleShow } = usePreferences(); + const { show, toggleShow, showBetaFeatures, toggleBetaFeatures } = + usePreferences(); const { isMultiSelectMode, toggleMultiSelectMode, @@ -66,6 +104,8 @@ export default function Cooking() { clearSelection, } = useMultiSelect(); + const [_seasonFilter, setSeasonFilter] = useState("all"); + useEffect(() => { if (activePlayer) { if (activePlayer.cooking?.recipes) { @@ -115,6 +155,38 @@ export default function Cooking() { return { completed, additionalDescription }; }; + useEffect(() => { + if (router.isReady) { + const tabParam = router.query.trackingTab; + if ( + typeof tabParam === "string" && + activeTab !== tabParam && + ["recipes", "ingredients"].includes(tabParam) + ) { + setActiveTab(tabParam); + } + } + }, [router.isReady, router.query.trackingTab, activeTab, router]); + + const handleTabChange = (value: string) => { + if (value == activeTab) { + return; + } + // If trying to switch to ingredients tab and beta features aren't enabled, show dialog + if (value === "ingredients" && !showBetaFeatures) { + setBetaDialogOpen(true); + return; + } + router.push( + { + pathname: router.pathname, + query: { ...router.query, trackingTab: value }, + }, + undefined, + { shallow: true }, + ); + }; + return ( <> @@ -176,141 +248,214 @@ export default function Cooking() { - {/* All Recipes Section */} -
-

- All Recipes -

- {/* Filters and Actions Row */} -
- - setFilter(val === _filter ? "all" : val) - } - className="gap-2" - > - - - - Unknown ({reqs["Gourmet Chef"] - (knownCount + cookedCount)} - ) - - - - - Known ({knownCount}) - - - - Cooked ({cookedCount}) - - -
- - {isMultiSelectMode && ( + + + + Unknown ( + {reqs["Gourmet Chef"] - (knownCount + cookedCount)}) + + + + + Known ({knownCount}) + + + + Cooked ({cookedCount}) + + +
- )} + {isMultiSelectMode && ( + + )} +
+
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Recipes" + /> +
-
- {/* Search Bar Row */} -
- - setSearch(v)} - placeholder="Search Recipes" - /> - -
- {/* Cards */} -
- {Object.values(recipes) - .filter((r) => semverGte(gameVersion, r.minVersion)) - .filter((r) => { - if (!search) return true; - const name = objects[r.itemID as keyof typeof objects].name; - return name.toLowerCase().includes(search.toLowerCase()); - }) - .filter((r) => { - if (_filter === "0") { - // unknown recipes (not in playerRecipes) - return !( - r.itemID in playerRecipes && playerRecipes[r.itemID] > 0 - ); - } else if (_filter === "1") { - // known recipes (in playerRecipes) and not cooked - return ( - r.itemID in playerRecipes && playerRecipes[r.itemID] === 1 - ); - } else if (_filter === "2") { - // cooked recipes (in playerRecipes) and cooked - return ( - r.itemID in playerRecipes && playerRecipes[r.itemID] === 2 - ); - } else return true; // all recipes - }) - .map((f, index, filteredRecipes) => ( - + {Object.values(recipes) + .filter((r) => semverGte(gameVersion, r.minVersion)) + .filter((r) => { + if (!search) return true; + const name = objects[r.itemID as keyof typeof objects].name; + return name.toLowerCase().includes(search.toLowerCase()); + }) + .filter((r) => { + if (_filter === "0") { + // unknown recipes (not in playerRecipes) + return !( + r.itemID in playerRecipes && playerRecipes[r.itemID] > 0 + ); + } else if (_filter === "1") { + // known recipes (in playerRecipes) and not cooked + return ( + r.itemID in playerRecipes && + playerRecipes[r.itemID] === 1 + ); + } else if (_filter === "2") { + // cooked recipes (in playerRecipes) and cooked + return ( + r.itemID in playerRecipes && + playerRecipes[r.itemID] === 2 + ); + } else return true; // all recipes + }) + .map((f, index, filteredRecipes) => ( + + ))} +
+ + {/* Needed Ingredients Section */} + + {/* Filters and Actions Row */} +
+
+ + setFilter(val === _filter ? "all" : val) } - setIsOpen={setIsOpen} - setObject={setRecipe} - setPromptOpen={setPromptOpen} - show={show} - index={index} - allRecipes={filteredRecipes} + className="gap-2" + > + + + + Unknown ( + {reqs["Gourmet Chef"] - (knownCount + cookedCount)}) + + + + + Known ({knownCount}) + + +
+
+ - ))} -
-
+
+ + {/* Search Bar Row */} +
+ + setIngredientSearch(v)} + placeholder="Search Ingredients" + /> + +
+ + recipes={recipes} + playerRecipes={playerRecipes} + show={show} + setPromptOpen={setPromptOpen} + filterKnown={_filter} + filterSeason={_seasonFilter} + searchText={ingredientSearch} + /> + + + { + const enabled = toggleBetaFeatures(); + if (enabled) { + // Switch to ingredients tab after enabling + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + trackingTab: "ingredients", + }, + }, + undefined, + { shallow: true }, + ); + } + return enabled; + }} + /> = { "2": "border-green-900 bg-green-500/20", // completed }; +const seasons = [ + { + value: "all", + label: "All Seasons", + }, + { + value: "Spring", + label: "Spring", + }, + { + value: "Summer", + label: "Summer", + }, + { + value: "Fall", + label: "Fall", + }, + { + value: "Winter", + label: "Winter", + }, +]; + export default function Crafting() { + const router = useRouter(); + const [open, setIsOpen] = useState(false); const [recipe, setRecipe] = useState(null); const [playerRecipes, setPlayerRecipes] = useState<{ @@ -52,13 +84,19 @@ export default function Crafting() { const [gameVersion, setGameVersion] = useState("1.6.0"); + const [activeTab, setActiveTab] = useState("recipes"); + const [search, setSearch] = useState(""); + const [ingredientSearch, setIngredientSearch] = useState(""); const [_filter, setFilter] = useState("all"); + const [_seasonFilter, setSeasonFilter] = useState("all"); const [showPrompt, setPromptOpen] = useState(false); + const [betaDialogOpen, setBetaDialogOpen] = useState(false); const { activePlayer } = usePlayers(); - const { show, toggleShow } = usePreferences(); + const { show, toggleShow, showBetaFeatures, toggleBetaFeatures } = + usePreferences(); const { isMultiSelectMode, toggleMultiSelectMode, @@ -127,6 +165,38 @@ export default function Crafting() { } }; + useEffect(() => { + if (router.isReady) { + const tabParam = router.query.trackingTab; + if ( + typeof tabParam === "string" && + activeTab !== tabParam && + ["recipes", "ingredients"].includes(tabParam) + ) { + setActiveTab(tabParam); + } + } + }, [router.isReady, router.query.trackingTab, activeTab, router]); + + const handleTabChange = (value: string) => { + if (value == activeTab) { + return; + } + // If trying to switch to ingredients tab and beta features aren't enabled, show dialog + if (value === "ingredients" && !showBetaFeatures) { + setBetaDialogOpen(true); + return; + } + router.push( + { + pathname: router.pathname, + query: { ...router.query, trackingTab: value }, + }, + undefined, + { shallow: true }, + ); + }; + return ( <> @@ -188,141 +258,216 @@ export default function Crafting() { - {/* All Recipes Section */} -
-

- All Recipes -

- {/* Filters and Actions Row */} -
- - setFilter(val === _filter ? "all" : val) - } - className="gap-2" - > - - - - Unknown ( - {reqs["Craft Master"] - (knownCount + craftedCount)}) - - - - - Known ({knownCount}) - - - - Crafted ({craftedCount}) - - -
- - {isMultiSelectMode && ( + + + + Unknown ( + {reqs["Craft Master"] - (knownCount + craftedCount)}) + + + + + Known ({knownCount}) + + + + + Crafted ({craftedCount}) + + + +
- )} + {isMultiSelectMode && ( + + )} +
+
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Recipes" + /> + +
+ {/* Cards */} +
+ {Object.values(recipes) + .filter((r) => semverGte(gameVersion, r.minVersion)) + .filter((r) => { + if (!search) return true; + const name = getName(r.itemID, r.isBigCraftable); + return name.toLowerCase().includes(search.toLowerCase()); + }) + .filter((r) => { + if (_filter === "0") { + // unknown recipes (not in playerRecipes) + return !( + r.itemID in playerRecipes && playerRecipes[r.itemID] > 0 + ); + } else if (_filter === "1") { + // known recipes (in playerRecipes) and not cooked + return ( + r.itemID in playerRecipes && + playerRecipes[r.itemID] === 1 + ); + } else if (_filter === "2") { + // cooked recipes (in playerRecipes) and cooked + return ( + r.itemID in playerRecipes && + playerRecipes[r.itemID] === 2 + ); + } else return true; // all recipes + }) + .map((f, index, filteredRecipes) => ( + + key={f.itemID} + recipe={f} + status={ + f.itemID in playerRecipes ? playerRecipes[f.itemID] : 0 + } + setIsOpen={setIsOpen} + setObject={setRecipe} + setPromptOpen={setPromptOpen} + show={show} + index={index} + allRecipes={filteredRecipes as CraftingRecipe[]} + /> + ))}
-
- {/* Search Bar Row */} -
- - setSearch(v)} - placeholder="Search Recipes" - /> - -
- {/* Cards */} -
- {Object.values(recipes) - .filter((r) => semverGte(gameVersion, r.minVersion)) - .filter((r) => { - if (!search) return true; - const name = getName(r.itemID, r.isBigCraftable); - return name.toLowerCase().includes(search.toLowerCase()); - }) - .filter((r) => { - if (_filter === "0") { - // unknown recipes (not in playerRecipes) - return !( - r.itemID in playerRecipes && playerRecipes[r.itemID] > 0 - ); - } else if (_filter === "1") { - // known recipes (in playerRecipes) and not cooked - return ( - r.itemID in playerRecipes && playerRecipes[r.itemID] === 1 - ); - } else if (_filter === "2") { - // cooked recipes (in playerRecipes) and cooked - return ( - r.itemID in playerRecipes && playerRecipes[r.itemID] === 2 - ); - } else return true; // all recipes - }) - .map((f, index, filteredRecipes) => ( - - key={f.itemID} - recipe={f} - status={ - f.itemID in playerRecipes ? playerRecipes[f.itemID] : 0 + + {/* Needed Ingredients Section */} + + {/* Filters and Actions Row */} +
+
+ + setFilter(val === _filter ? "all" : val) } - setIsOpen={setIsOpen} - setObject={setRecipe} - setPromptOpen={setPromptOpen} - show={show} - index={index} - allRecipes={filteredRecipes as CraftingRecipe[]} + className="gap-2" + > + + + + Unknown ( + {reqs["Craft Master"] - (knownCount + craftedCount)}) + + + + + Known ({knownCount}) + + +
+
+ +
+
+ {/* Search Bar Row */} +
+ + setIngredientSearch(v)} + placeholder="Search Ingredients" /> - ))} -
-
+ + + + recipes={recipes} + playerRecipes={playerRecipes} + show={show} + setPromptOpen={setPromptOpen} + filterKnown={_filter} + filterSeason={_seasonFilter} + searchText={ingredientSearch} + /> + + + { + const enabled = toggleBetaFeatures(); + if (enabled) { + // Switch to ingredients tab after enabling + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + trackingTab: "ingredients", + }, + }, + undefined, + { shallow: true }, + ); + } + return enabled; + }} + />