diff --git a/bun.lockb b/bun.lockb index bc210180..5503a05f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3007ae65..36e95e94 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.4", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.0.6", "@react-hook/media-query": "^1.1.1", "@tabler/icons-react": "^2.30.0", @@ -47,6 +49,7 @@ "eslint": "8.44.0", "eslint-config-next": "13.4.12", "fast-xml-parser": "^4.2.6", + "lucide-react": "^0.522.0", "mysql2": "^3.10.1", "next": "^14.2.3", "next-themes": "^0.2.1", diff --git a/src/components/cards/boolean-card.tsx b/src/components/cards/boolean-card.tsx index 7741bfdb..45aed75f 100644 --- a/src/components/cards/boolean-card.tsx +++ b/src/components/cards/boolean-card.tsx @@ -5,6 +5,7 @@ import type { ItemData, MuseumItem } from "@/types/items"; import { Dispatch, SetStateAction } from "react"; import { usePlayers } from "@/contexts/players-context"; +import { useMultiSelect } from "@/contexts/multi-select-context"; import { NewItemBadge } from "@/components/new-item-badge"; import { @@ -66,6 +67,7 @@ export const BooleanCard = ({ handleStatusChange, }: BooleanCardProps) => { const { activePlayer, patchPlayer } = usePlayers(); + const { isMultiSelectMode, selectedItems, toggleItem } = useMultiSelect(); // let itemType = "O"; //Todo add item types to object data files, and use them here to hotswap data source // let dataSource = objects; let iconURL: string; @@ -137,6 +139,8 @@ export const BooleanCard = ({ await patchPlayer(patch); } + const isSelected = selectedItems.has(item.itemID.toString()); + return ( @@ -146,8 +150,13 @@ export const BooleanCard = ({ completed ? "border-green-900 bg-green-500/20 hover:bg-green-500/30 dark:bg-green-500/10 hover:dark:bg-green-500/20" : "border-neutral-200 bg-white hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800", + isMultiSelectMode && isSelected && "ring-primary ring-2", )} onClick={() => { + if (isMultiSelectMode) { + toggleItem(item.itemID.toString()); + return; + } if (minVersion === "1.6.0" && !show && !completed) { setPromptOpen?.(true); return; diff --git a/src/components/cards/dialog-card.tsx b/src/components/cards/dialog-card.tsx index 96644e6b..08e57558 100644 --- a/src/components/cards/dialog-card.tsx +++ b/src/components/cards/dialog-card.tsx @@ -28,6 +28,7 @@ import { import { Stardrop } from "@/lib/parsers/general"; import { ChevronRightIcon } from "@radix-ui/react-icons"; +import { useMultiSelect } from "@/contexts/multi-select-context"; interface Props { title: string; @@ -65,6 +66,7 @@ export const DialogCard = ({ }: Props) => { const { activePlayer, patchPlayer } = usePlayers(); const [open, setOpen] = useState(false); + const { isMultiSelectMode, selectedItems, toggleItem } = useMultiSelect(); const minVersion = _type === "power" ? powersData[_id].minVersion : "1.5.0"; @@ -72,6 +74,10 @@ export const DialogCard = ({ ? "border-green-900 bg-green-500/20 hover:bg-green-500/30 dark:bg-green-500/10 hover:dark:bg-green-500/20" : "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950 hover:bg-neutral-100 dark:hover:bg-neutral-800"; + if (isMultiSelectMode && selectedItems.has(_id)) { + checkedClass += " ring-2 ring-primary"; + } + async function handleStatusChange(status: boolean) { if (!activePlayer) return; @@ -156,6 +162,10 @@ export const DialogCard = ({ checkedClass, )} onClick={(e) => { + if (isMultiSelectMode) { + toggleItem(_id); + return; + } if (minVersion === "1.6.0" && !show && !completed) { e.preventDefault(); setPromptOpen?.(true); diff --git a/src/components/cards/recipe-card.tsx b/src/components/cards/recipe-card.tsx index 0d72f76f..6ad66582 100644 --- a/src/components/cards/recipe-card.tsx +++ b/src/components/cards/recipe-card.tsx @@ -6,9 +6,10 @@ import objects from "@/data/objects.json"; import type { CraftingRecipe, Recipe } from "@/types/recipe"; import { cn } from "@/lib/utils"; -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, SetStateAction, useRef } from "react"; import { usePlayers } from "@/contexts/players-context"; +import { useMultiSelect } from "@/contexts/multi-select-context"; import { NewItemBadge } from "@/components/new-item-badge"; import { @@ -41,6 +42,9 @@ interface Props { * @memberof Props */ setPromptOpen?: Dispatch>; + + index: number; + allRecipes: T[]; } export const RecipeCard = ({ @@ -50,8 +54,14 @@ export const RecipeCard = ({ setObject, setPromptOpen, show, + index, + allRecipes, }: Props) => { const { activePlayer, patchPlayer } = usePlayers(); + const { isMultiSelectMode, selectedItems, toggleItem, addItems } = + useMultiSelect(); + const lastSelectedIndex = useRef(null); + let colorClass = ""; switch (status) { case 1: @@ -110,6 +120,34 @@ export const RecipeCard = ({ await patchPlayer(patch); } + const isSelected = selectedItems.has(recipe.itemID.toString()); + + const handleClick = (e: React.MouseEvent) => { + if (isMultiSelectMode) { + if (e.shiftKey && lastSelectedIndex.current !== null) { + // Select range of items + const start = Math.min(lastSelectedIndex.current, index); + const end = Math.max(lastSelectedIndex.current, index); + const itemsToSelect = allRecipes + .slice(start, end + 1) + .map((r) => r.itemID.toString()); + addItems(itemsToSelect); + } else { + // Single item selection + toggleItem(recipe.itemID.toString()); + lastSelectedIndex.current = index; + } + return; + } + + if (recipe.minVersion === "1.6.0" && !show && status < 1) { + setPromptOpen?.(true); + return; + } + setObject(recipe); + setIsOpen(true); + }; + return ( @@ -117,17 +155,13 @@ export const RecipeCard = ({ className={cn( "relative flex select-none items-center justify-between rounded-lg border px-5 py-4 text-neutral-950 shadow-sm transition-colors hover:cursor-pointer dark:text-neutral-50", colorClass, + isMultiSelectMode && isSelected && "ring-2 ring-blue-500", )} - onClick={() => { - if (recipe.minVersion === "1.6.0" && !show && status < 1) { - setPromptOpen?.(true); - return; - } - setObject(recipe); - setIsOpen(true); - }} + onClick={handleClick} > - {recipe.minVersion === "1.6.0" && } + {recipe.minVersion === "1.6.0" && ( + + )}
({

- + {!isMultiSelectMode && ( + + )}
diff --git a/src/components/dialogs/bulk-action-dialog.tsx b/src/components/dialogs/bulk-action-dialog.tsx new file mode 100644 index 00000000..93b86117 --- /dev/null +++ b/src/components/dialogs/bulk-action-dialog.tsx @@ -0,0 +1,160 @@ +import { usePlayers } from "@/contexts/players-context"; +import { useMultiSelect } from "@/contexts/multi-select-context"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface Props { + open: boolean; + setOpen: (open: boolean) => void; + type: "cooking" | "crafting" | "shipping" | "museum"; + onBulkAction?: ( + status: number | null, + selectedItems: Set, + close: () => void, + ) => void; +} + +export const BulkActionDialog = ({ + open, + setOpen, + type, + onBulkAction, +}: Props) => { + const { selectedItems, clearSelection } = useMultiSelect(); + const { activePlayer, patchPlayer } = usePlayers(); + + const close = () => { + clearSelection(); + setOpen(false); + }; + + const handleBulkAction = async (status: number | null) => { + if (onBulkAction) { + onBulkAction(status, selectedItems, close); + return; + } + if (!activePlayer) return; + + const patch: any = {}; + const items = Array.from(selectedItems); + + switch (type) { + case "cooking": + patch.cooking = { + recipes: Object.fromEntries(items.map((id) => [id, status])), + }; + break; + case "crafting": + patch.crafting = { + recipes: Object.fromEntries(items.map((id) => [id, status])), + }; + break; + case "shipping": + patch.shipping = { + shipped: Object.fromEntries(items.map((id) => [id, status])), + }; + break; + case "museum": + patch.museum = { + donated: Object.fromEntries(items.map((id) => [id, status])), + }; + break; + } + + await patchPlayer(patch); + close(); + }; + + let foundLabel = "Set All Selected as Completed"; + let notFoundLabel = "Set All Selected as Incomplete"; + if (type === "museum") { + foundLabel = "Set All Selected as Found"; + notFoundLabel = "Set All Selected as Not Found"; + } else if (type === "shipping") { + foundLabel = "Set All Selected as Shipped"; + notFoundLabel = "Set All Selected as Unshipped"; + } + + if ( + type === "museum" || + type === "shipping" || + type === "cooking" || + type === "crafting" + ) { + return ( + + + + Bulk Action + +
+ + +
+
+
+ ); + } + + return ( + + + + Bulk Action + + {selectedItems.size} items selected + + +
+
+ + + +
+
+ + + +
+
+ ); +}; diff --git a/src/components/filter-btn.tsx b/src/components/filter-btn.tsx index 3782f163..8bf3508e 100644 --- a/src/components/filter-btn.tsx +++ b/src/components/filter-btn.tsx @@ -25,6 +25,7 @@ interface ButtonProps { target: string; _filter: string; setFilter: Dispatch>; + className?: string; } interface DataItem { @@ -51,6 +52,7 @@ export const FilterButton = ({ target, _filter, setFilter, + className, }: ButtonProps) => { const { activePlayer } = useContext(PlayersContext); @@ -66,8 +68,9 @@ export const FilterButton = ({ return ( + + + ) : ( + <> + {fishCaught.has(fish?.itemID?.toString() ?? "0") ? ( + + ) : ( + + )} + + )} + {!activePlayer && } + {name && ( + + )} + + + {fish && ( + <> +
+

Location

+ +
    + {fish.locations.map((location) => ( +
  • + {location} +
  • + ))} +
+
+ {!fish.trapFish && ( + <> +
+

Season

+ +
    + {fish.seasons.map((season) => ( +
  • + {season} +
  • + ))} +
+
+
+

Time

+ +

+ {fish.time} +

+
+
+

Weather

+ +

+ {fish.weather} +

+
+
+

Difficulty

+ +

+ {fish.difficulty} +

+
+ + )} + + )} + + + ); + if (isDesktop) { return ( @@ -95,111 +252,7 @@ export const FishSheet = ({ open, setIsOpen, fish }: Props) => { {description ? description : "No Description Found"} - {fish && ( -
-
-
- {fishCaught.has(fish.itemID) ? ( - - ) : ( - - )} - {!activePlayer && } - {name && ( - - )} -
-
-
-

Location

- -
    - {fish.locations.map((location) => ( -
  • - {location} -
  • - ))} -
-
- {!fish.trapFish && ( - <> -
-

Season

- -
    - {fish.seasons.map((season) => ( -
  • - {season} -
  • - ))} -
-
-
-

Time

- -

- {fish.time} -

-
-
-

Weather

- -

- {fish.weather} -

-
-
-

Difficulty

- -

- {fish.difficulty} -

-
- - )} -
- )} + {content}
); @@ -225,111 +278,7 @@ export const FishSheet = ({ open, setIsOpen, fish }: Props) => { {description ? description : "No Description Found"} - {fish && ( -
-
-
- {fishCaught.has(fish.itemID) ? ( - - ) : ( - - )} - {!activePlayer && } - {name && ( - - )} -
-
-
-

Location

- -
    - {fish.locations.map((location) => ( -
  • - {location} -
  • - ))} -
-
- {!fish.trapFish && ( - <> -
-

Season

- -
    - {fish.seasons.map((season) => ( -
  • - {season} -
  • - ))} -
-
-
-

Time

- -

- {fish.time} -

-
-
-

Weather

- -

- {fish.weather} -

-
-
-

Difficulty

- -

- {fish.difficulty} -

-
- - )} -
- )} +
{content}
diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 00000000..afe5da62 --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 00000000..9c930a48 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-100 hover:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-neutral-100 data-[state=on]:text-neutral-900 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:hover:bg-neutral-800 dark:hover:text-neutral-400 dark:focus-visible:ring-neutral-300 dark:data-[state=on]:bg-neutral-800 dark:data-[state=on]:text-neutral-50", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-neutral-200 bg-transparent shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/src/contexts/multi-select-context.tsx b/src/contexts/multi-select-context.tsx new file mode 100644 index 00000000..d42d263c --- /dev/null +++ b/src/contexts/multi-select-context.tsx @@ -0,0 +1,83 @@ +import { createContext, useContext, useState, ReactNode } from "react"; + +interface MultiSelectContextType { + isMultiSelectMode: boolean; + toggleMultiSelectMode: () => void; + selectedItems: Set; + toggleItem: (id: string) => void; + clearSelection: () => void; + addItems: (ids: string[]) => void; + removeItems: (ids: string[]) => void; +} + +const MultiSelectContext = createContext( + undefined, +); + +export function MultiSelectProvider({ children }: { children: ReactNode }) { + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + + const toggleMultiSelectMode = () => { + setIsMultiSelectMode(!isMultiSelectMode); + if (isMultiSelectMode) { + clearSelection(); + } + }; + + const toggleItem = (id: string) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const clearSelection = () => { + setSelectedItems(new Set()); + }; + + const addItems = (ids: string[]) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + ids.forEach((id) => newSet.add(id)); + return newSet; + }); + }; + + const removeItems = (ids: string[]) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + ids.forEach((id) => newSet.delete(id)); + return newSet; + }); + }; + + return ( + + {children} + + ); +} + +export function useMultiSelect() { + const context = useContext(MultiSelectContext); + if (context === undefined) { + throw new Error("useMultiSelect must be used within a MultiSelectProvider"); + } + return context; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 19eca8ab..42cbf912 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,7 @@ import { Toaster } from "sonner"; import { ThemeProvider } from "@/components/theme-provider"; import { PlayersProvider } from "@/contexts/players-context"; import { PreferencesProvider } from "@/contexts/preferences-context"; +import { MultiSelectProvider } from "@/contexts/multi-select-context"; import { useEffect, useState } from "react"; import useSWR from "swr"; @@ -28,20 +29,22 @@ export default function App({ Component, pageProps }: AppProps) { -
-
- -
-
- -
- - - - + +
+
+ +
+
+ +
+ + + + +
-
+ diff --git a/src/pages/cooking.tsx b/src/pages/cooking.tsx index 89b27a22..4ec400fc 100644 --- a/src/pages/cooking.tsx +++ b/src/pages/cooking.tsx @@ -1,4 +1,5 @@ import Head from "next/head"; +import { X } from "lucide-react"; import achievements from "@/data/achievements.json"; import recipes from "@/data/cooking.json"; @@ -8,11 +9,13 @@ import type { Recipe } from "@/types/recipe"; import { usePlayers } from "@/contexts/players-context"; import { usePreferences } from "@/contexts/preferences-context"; +import { useMultiSelect } from "@/contexts/multi-select-context"; import { useEffect, useMemo, useState } from "react"; import { AchievementCard } from "@/components/cards/achievement-card"; import { RecipeCard } from "@/components/cards/recipe-card"; import { UnblurDialog } from "@/components/dialogs/unblur-dialog"; +import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; import { FilterButton } from "@/components/filter-btn"; import { RecipeSheet } from "@/components/sheets/recipe-sheet"; import { @@ -21,7 +24,10 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; import { Command, CommandInput } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; const semverGte = require("semver/functions/gte"); @@ -31,6 +37,12 @@ const reqs: Record = { "Gourmet Chef": Object.keys(recipes).length, // 1.6 default }; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // unknown or not completed + "1": "border-yellow-900 bg-yellow-500/20", // known, but not completed + "2": "border-green-900 bg-green-500/20", // completed +}; + export default function Cooking() { const [open, setIsOpen] = useState(false); const [recipe, setRecipe] = useState(null); @@ -42,11 +54,18 @@ export default function Cooking() { const [search, setSearch] = useState(""); const [_filter, setFilter] = useState("all"); + const [bulkActionOpen, setBulkActionOpen] = useState(false); const [showPrompt, setPromptOpen] = useState(false); const { activePlayer } = usePlayers(); const { show, toggleShow } = usePreferences(); + const { + isMultiSelectMode, + toggleMultiSelectMode, + selectedItems, + clearSelection, + } = useMultiSelect(); useEffect(() => { if (activePlayer) { @@ -163,31 +182,83 @@ export default function Cooking() {

All Recipes

- {/* Filters */} -
-
- - - + {/* Filters and Actions Row */} +
+ + setFilter(val === _filter ? "all" : val) + } + className="gap-2" + > + + + + Unknown ({reqs["Gourmet Chef"] - (knownCount + cookedCount)} + ) + + + + + Known ({knownCount}) + + + + Cooked ({cookedCount}) + + +
+ + {isMultiSelectMode && ( + + )}
- +
+ {/* Search Bar Row */} +
+ setSearch(v)} placeholder="Search Recipes" @@ -221,7 +292,7 @@ export default function Cooking() { ); } else return true; // all recipes }) - .map((f) => ( + .map((f, index, filteredRecipes) => ( ))}
@@ -243,6 +316,11 @@ export default function Cooking() { setOpen={setPromptOpen} toggleShow={toggleShow} /> + ); diff --git a/src/pages/crafting.tsx b/src/pages/crafting.tsx index c134d7fd..eaec035c 100644 --- a/src/pages/crafting.tsx +++ b/src/pages/crafting.tsx @@ -10,6 +10,7 @@ import type { CraftingRecipe } from "@/types/recipe"; import { usePlayers } from "@/contexts/players-context"; import { usePreferences } from "@/contexts/preferences-context"; import { useEffect, useMemo, useState } from "react"; +import { useMultiSelect } from "@/contexts/multi-select-context"; import { AchievementCard } from "@/components/cards/achievement-card"; import { RecipeCard } from "@/components/cards/recipe-card"; @@ -23,6 +24,11 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Command, CommandInput } from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; const semverGte = require("semver/functions/gte"); @@ -32,6 +38,12 @@ const reqs: Record = { "Craft Master": Object.keys(recipes).length, }; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // unknown or not completed + "1": "border-yellow-900 bg-yellow-500/20", // known, but not completed + "2": "border-green-900 bg-green-500/20", // completed +}; + export default function Crafting() { const [open, setIsOpen] = useState(false); const [recipe, setRecipe] = useState(null); @@ -48,6 +60,14 @@ export default function Crafting() { const { activePlayer } = usePlayers(); const { show, toggleShow } = usePreferences(); + const { + isMultiSelectMode, + toggleMultiSelectMode, + selectedItems, + clearSelection, + } = useMultiSelect(); + + const [bulkActionOpen, setBulkActionOpen] = useState(false); useEffect(() => { if (activePlayer) { @@ -174,31 +194,83 @@ export default function Crafting() {

All Recipes

- {/* Filters */} -
-
- - - + {/* Filters and Actions Row */} +
+ + setFilter(val === _filter ? "all" : val) + } + className="gap-2" + > + + + + Unknown ( + {reqs["Craft Master"] - (knownCount + craftedCount)}) + + + + + Known ({knownCount}) + + + + Crafted ({craftedCount}) + + +
+ + {isMultiSelectMode && ( + + )}
- +
+ {/* Search Bar Row */} +
+ setSearch(v)} placeholder="Search Recipes" @@ -232,8 +304,8 @@ export default function Crafting() { ); } else return true; // all recipes }) - .map((f) => ( - ( + key={f.itemID} recipe={f} status={ @@ -243,6 +315,8 @@ export default function Crafting() { setObject={setRecipe} setPromptOpen={setPromptOpen} show={show} + index={index} + allRecipes={filteredRecipes as CraftingRecipe[]} /> ))}
@@ -254,6 +328,11 @@ export default function Crafting() { setOpen={setPromptOpen} toggleShow={toggleShow} /> + ); diff --git a/src/pages/fishing.tsx b/src/pages/fishing.tsx index 8786b79f..3aea9bb4 100644 --- a/src/pages/fishing.tsx +++ b/src/pages/fishing.tsx @@ -22,6 +22,12 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Command, CommandInput } from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import { useMultiSelect } from "@/contexts/multi-select-context"; +import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; import { IconClock, IconCloud } from "@tabler/icons-react"; @@ -72,6 +78,11 @@ const seasons = [ }, ]; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // incomplete + "2": "border-green-900 bg-green-500/20", // completed +}; + export default function Fishing() { const [open, setIsOpen] = useState(false); const [fish, setFish] = useState(null); @@ -87,9 +98,17 @@ export default function Fishing() { const [gameVersion, setGameVersion] = useState("1.6.0"); - const { activePlayer } = usePlayers(); + const { activePlayer, patchPlayer } = usePlayers(); const { show, toggleShow } = usePreferences(); + const { + isMultiSelectMode, + toggleMultiSelectMode, + selectedItems, + clearSelection, + } = useMultiSelect(); + const [bulkActionOpen, setBulkActionOpen] = useState(false); + useEffect(() => { if (activePlayer) { setFishCaught(new Set(activePlayer?.fishing?.fishCaught ?? [])); @@ -127,6 +146,24 @@ export default function Fishing() { return { completed, additionalDescription }; }; + // Custom bulk action handler for fishing + const handleFishingBulkAction = async ( + status: number | null, + selectedItems: Set, + close: () => void, + ) => { + if (!activePlayer) return; + const current = new Set(activePlayer.fishing?.fishCaught ?? []); + selectedItems.forEach((id) => { + if (status === 2) current.add(id); + if (status === 0) current.delete(id); + }); + await patchPlayer({ + fishing: { fishCaught: Array.from(current) }, + }); + close(); + }; + return ( <> @@ -193,50 +230,96 @@ export default function Fishing() {

All Fish

- {/* Filters */} -
-
- +
+ + setFilter(val === _filter ? "all" : val) + } + className="gap-2" + > + + + + Uncaught ({reqs["Master Angler"] - fishCaught.size}) + + + + + + Caught ({fishCaught.size}) + + + +
+
+ - + + {isMultiSelectMode && ( + + )}
-
-
- - -
-
- - setSearch(v)} - placeholder="Search Fish" - /> - -
-
+
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Fish" + /> +
{/* Fish Cards */}
@@ -249,9 +332,9 @@ export default function Fishing() { }) .filter((f) => { if (_filter === "0") { - return !fishCaught.has(f.itemID); // incompleted + return !fishCaught.has(f.itemID.toString()); // uncaught } else if (_filter === "2") { - return fishCaught.has(f.itemID); // completed + return fishCaught.has(f.itemID.toString()); // caught } else return true; // all }) .filter((f) => { @@ -280,7 +363,7 @@ export default function Fishing() { + ); diff --git a/src/pages/island/walnuts.tsx b/src/pages/island/walnuts.tsx index 91a8a3a5..4c8c4410 100644 --- a/src/pages/island/walnuts.tsx +++ b/src/pages/island/walnuts.tsx @@ -14,6 +14,12 @@ import { FilterButton, FilterSearch } from "@/components/filter-btn"; import { Command, CommandInput } from "@/components/ui/command"; import { IconMapPin } from "@tabler/icons-react"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import { useMultiSelect } from "@/contexts/multi-select-context"; +import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; const inter = Inter({ subsets: ["latin"] }); @@ -51,8 +57,14 @@ const type = [ label: "Volcano", }, ]; + +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // unfound + "2": "border-green-900 bg-green-500/20", // found +}; + export default function IslandWalnuts() { - const { activePlayer } = usePlayers(); + const { activePlayer, patchPlayer } = usePlayers(); const [walnutsFound, setWalnutsFound] = useState>(new Set()); const [_filter, setFilter] = useState("all"); @@ -60,6 +72,14 @@ export default function IslandWalnuts() { const [search, setSearch] = useState(""); + const { + isMultiSelectMode, + toggleMultiSelectMode, + selectedItems, + clearSelection, + } = useMultiSelect(); + const [bulkActionOpen, setBulkActionOpen] = useState(false); + useEffect(() => { if (activePlayer && activePlayer.walnuts?.found) { // take the walnut IDs in walnutFound and add them to a set @@ -87,6 +107,24 @@ export default function IslandWalnuts() { }); }, [walnutsFound, _filter]); + // Custom bulk action handler for walnuts + const handleWalnutBulkAction = async ( + status: number | null, + selectedItems: Set, + close: () => void, + ) => { + if (!activePlayer) return; + const current = { ...activePlayer.walnuts?.found }; + selectedItems.forEach((id) => { + if (status === 2) current[id] = walnuts[id].count; + if (status === 0) current[id] = 0; + }); + await patchPlayer({ + walnuts: { found: current }, + }); + close(); + }; + return ( <> @@ -116,33 +154,54 @@ export default function IslandWalnuts() {

Golden Walnut Tracker

-
-
- +
+ + setFilter(val === _filter ? "all" : val) + } + className="gap-2" + > + + + + Unfound ( + {130 - + Object.entries(activePlayer?.walnuts?.found ?? {}).reduce( + (a, b) => a + b[1], + 0, + )} + ) + + + + + + Found ( + {Object.entries(activePlayer?.walnuts?.found ?? {}).reduce( (a, b) => a + b[1], 0, - ) ?? 0 - })`} - setFilter={setFilter} - /> - a + b[1], - 0, - ) ?? 0 - })`} - setFilter={setFilter} - /> + )} + ) + + +
-
+
- - setSearch(v)} - placeholder="Search Walnuts" - /> - + + {isMultiSelectMode && ( + + )}
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Walnuts" + /> + +
{displayedWalnuts .filter((f) => { @@ -194,6 +285,12 @@ export default function IslandWalnuts() { })}
+ ); diff --git a/src/pages/museum.tsx b/src/pages/museum.tsx index 131d43bc..bc8ecbb3 100644 --- a/src/pages/museum.tsx +++ b/src/pages/museum.tsx @@ -2,6 +2,7 @@ import Head from "next/head"; import achievements from "@/data/achievements.json"; import museum from "@/data/museum.json"; +import objects from "@/data/objects.json"; import { MuseumItem } from "@/types/items"; import { useState, useMemo } from "react"; @@ -19,6 +20,13 @@ import { } from "@/components/ui/accordion"; import { usePlayers } from "@/contexts/players-context"; import { usePreferences } from "@/contexts/preferences-context"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; +import { useMultiSelect } from "@/contexts/multi-select-context"; +import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { Command, CommandInput } from "@/components/ui/command"; const reqs: Record = { "A Complete Collection": Object.values(museum).flatMap((item) => @@ -27,6 +35,11 @@ const reqs: Record = { "Treasure Trove": 40, }; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // not donated + "2": "border-green-900 bg-green-500/20", // donated +}; + export default function Museum() { const [open, setIsOpen] = useState(false); const [museumArtifact, setMuseumArtifact] = useState(null); @@ -48,13 +61,25 @@ export default function Museum() { [activePlayer], ); + const { + isMultiSelectMode, + toggleMultiSelectMode, + selectedItems, + clearSelection, + } = useMultiSelect(); + const [bulkActionOpen, setBulkActionOpen] = useState(false); + const [bulkType, setBulkType] = useState<"artifact" | "mineral">("artifact"); + + const [artifactSearch, setArtifactSearch] = useState(""); + const [mineralSearch, setMineralSearch] = useState(""); + const getAchievementProgress = (name: string) => { let completed = false; let additionalDescription = ""; if (!activePlayer || !activePlayer.museum) return { completed, additionalDescription }; - + const collection = museumArtifactCollected.size + museumMineralCollected.size; @@ -75,6 +100,39 @@ export default function Museum() { Object.values(museum.minerals).length - museumMineralCollected.size, }; + // Calculate donatedCount for artifacts based on filtered items + const donatedArtifactCount = Object.values(museum.artifacts).filter((f) => + museumArtifactCollected.has(f.itemID), + ).length; + + // Custom bulk action handler for museum + const { patchPlayer } = usePlayers(); + const handleMuseumBulkAction = async ( + status: number | null, + selectedItems: Set, + close: () => void, + ) => { + if (!activePlayer) return; + const patch: any = { museum: {} }; + if (bulkType === "artifact") { + const current = new Set(activePlayer.museum?.artifacts ?? []); + selectedItems.forEach((id) => { + if (status === 2) current.add(id); + if (status === 0) current.delete(id); + }); + patch.museum.artifacts = Array.from(current); + } else { + const current = new Set(activePlayer.museum?.minerals ?? []); + selectedItems.forEach((id) => { + if (status === 2) current.add(id); + if (status === 0) current.delete(id); + }); + patch.museum.minerals = Array.from(current); + } + await patchPlayer(patch); + close(); + }; + return ( <> @@ -145,22 +203,101 @@ export default function Museum() {
-
- - +
+
+ + setArtifactFilter( + val === _artifactFilter ? "all" : val, + ) + } + className="gap-2" + > + + + + Not Donated ({remainingDonations.artifacts}) + + + + + + Donated ({donatedArtifactCount}) + + + +
+
+ + {isMultiSelectMode && ( + + )} +
+
+ {/* Search Bar Row */} +
+ + setArtifactSearch(v)} + placeholder="Search Artifacts" + /> +
{Object.values(museum.artifacts) + .filter((f) => { + if (!artifactSearch) return true; + const name = + objects[f.itemID as keyof typeof objects]?.name || + ""; + return name + .toLowerCase() + .includes(artifactSearch.toLowerCase()); + }) .filter((f) => { if (_artifactFilter === "0") { return !museumArtifactCollected.has(f.itemID); // incompleted @@ -190,22 +327,93 @@ export default function Museum() {

All Minerals

-
- - +
+
+ + setMineralFilter(val === _mineralFilter ? "all" : val) + } + className="gap-2" + > + + + + Not Donated ({remainingDonations.minerals}) + + + + + + Donated ({museumMineralCollected.size}) + + + +
+
+ + {isMultiSelectMode && ( + + )} +
+
+ {/* Search Bar Row */} +
+ + setMineralSearch(v)} + placeholder="Search Minerals" + /> +
{Object.values(museum.minerals) + .filter((f) => { + if (!mineralSearch) return true; + const name = + objects[f.itemID as keyof typeof objects]?.name || ""; + return name + .toLowerCase() + .includes(mineralSearch.toLowerCase()); + }) .filter((f) => { if (_mineralFilter === "0") { return !museumMineralCollected.has(f.itemID); // incompleted @@ -237,6 +445,12 @@ export default function Museum() { setOpen={setPromptOpen} toggleShow={toggleShow} /> + ); diff --git a/src/pages/relationships.tsx b/src/pages/relationships.tsx index a94dd1eb..03ca090d 100644 --- a/src/pages/relationships.tsx +++ b/src/pages/relationships.tsx @@ -20,6 +20,8 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Command, CommandInput } from "@/components/ui/command"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; import { HeartIcon, HomeIcon, UsersIcon } from "@heroicons/react/24/solid"; import { IconBabyCarriage, IconAdjustments } from "@tabler/icons-react"; @@ -40,6 +42,11 @@ const sort_filters = [ { value: "hearts", label: "Hearts" }, ]; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // incomplete + "2": "border-green-900 bg-green-500/20", // completed +}; + export default function Relationships() { const { activePlayer } = usePlayers(); @@ -269,19 +276,35 @@ export default function Relationships() { All Villagers
-
- - +
+ + setFilter(val === _filter ? "all" : val) + } + className="gap-2" + > + + + Incomplete + + + + Completed + +
- - setSearch(v)} - placeholder="Search Villagers" - /> -
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Villagers" + /> + +
{Object.values(villagers) .filter((v) => { diff --git a/src/pages/shipping.tsx b/src/pages/shipping.tsx index ba1e6cbf..bccda6c4 100644 --- a/src/pages/shipping.tsx +++ b/src/pages/shipping.tsx @@ -22,6 +22,8 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Command, CommandInput } from "@/components/ui/command"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { cn } from "@/lib/utils"; import { IconClock } from "@tabler/icons-react"; @@ -56,9 +58,15 @@ const seasons = [ }, ]; +const bubbleColors: Record = { + "0": "border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950", // unshipped + "1": "border-green-900 bg-green-500/20", // shipped + "2": "border-blue-900 bg-blue-500/20", // polyculture +}; + export default function Shipping() { const [search, setSearch] = useState(""); - const [_filter, setFilter] = useState("all"); + const [filter, setFilter] = useState("all"); const [_seasonFilter, setSeasonFilter] = useState("all"); const [showPrompt, setPromptOpen] = useState(false); @@ -102,13 +110,19 @@ export default function Shipping() { if (semverGte(gameVersion, "1.6.0") && key === "372") return; // Clam and Smoked Fish is excluded in 1.6 // Polyculture calculation - if (shipping_items[key as keyof typeof shipping_items].polyculture) { + if ( + shipping_items[key as keyof typeof shipping_items] && + shipping_items[key as keyof typeof shipping_items].polyculture + ) { if ((activePlayer.shipping?.shipped[key] ?? 0) >= 15) polycultureCount++; } // Monoculture calculation - if (shipping_items[key as keyof typeof shipping_items].monoculture) { + if ( + shipping_items[key as keyof typeof shipping_items] && + shipping_items[key as keyof typeof shipping_items].monoculture + ) { if ((activePlayer.shipping?.shipped[key] ?? 0) >= 300) monocultureAchieved = true; } @@ -146,6 +160,20 @@ export default function Shipping() { return { completed, additionalDescription }; }; + // Calculate completedCount based on filtered items + const completedCount = Object.values(typedShippingItems) + .filter((i) => { + // Clam is excluded in 1.6, so we won't show it + if (i.itemID === "372") return !semverGte(gameVersion, "1.6.0"); + return true; + }) + .filter((i) => semverGte(gameVersion, i.minVersion)) + .filter((i) => { + if (_seasonFilter === "all") return true; + return i.seasons.includes(_seasonFilter); + }) + .filter((i) => i.itemID in basicShipped).length; + return ( <> @@ -212,31 +240,52 @@ export default function Shipping() {

All Items

- {/* Filters */} -
-
- - - + {/* Filters and Actions Row */} +
+
+ + setFilter(val === filter ? "all" : val) + } + className="gap-2" + > + + + + Unshipped ({reqs["Full Shipment"] - basicShippedCount}) + + + + + + Polyculture ({reqs["Polyculture"] - polycultureCount}) + + + + + + Completed ({completedCount}) + + +
- - setSearch(v)} - placeholder="Search Items" - /> -
+ {/* Search Bar Row */} +
+ + setSearch(v)} + placeholder="Search Items" + /> + +
{/* Items */}
{Object.values(typedShippingItems) @@ -270,24 +322,20 @@ export default function Shipping() { return name.toLowerCase().includes(search.toLowerCase()); }) .filter((i) => { - if (_filter === "0") { - // Item not shipped + if (filter === "0") { + // Unshipped return !(i.itemID in basicShipped); - } else if (_filter === "1") { - // Polyculture crops that need completing + } else if (filter === "1") { + // Polyculture in progress return ( i.itemID in shipping_items && shipping_items[i.itemID as keyof typeof shipping_items] .polyculture && (!basicShipped[i.itemID] || basicShipped[i.itemID]! < 15) ); - } else if (_filter === "2") { - // Shipped/Completed (we won't check for monoculture here) - return i.itemID in basicShipped && - shipping_items[i.itemID as keyof typeof shipping_items] - .polyculture - ? basicShipped[i.itemID]! >= 15 - : basicShipped[i.itemID]! >= 1; + } else if (filter === "2") { + // Completed (all shipped) + return i.itemID in basicShipped; } else return true; // all recipes }) .filter((i) => {