Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 3 additions & 18 deletions apps/stardew.app/src/components/cards/bundle-item-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { Dispatch, SetStateAction } from "react";
import { usePlayers } from "@/contexts/players-context";

import { categoryIcons, goldIcons } from "@/lib/constants";
import { BundleItem, BundleItemWithLocation } from "@/types/bundles";
import { BundleItemWithLocation } from "@/types/bundles";
import { BooleanCard } from "./boolean-card";

import { categoryItems } from "@/lib/utils";

interface BundleItemCardProps {
item: BundleItemWithLocation;
completed?: boolean;
Expand All @@ -29,23 +31,6 @@ interface BundleItemCardProps {
setPromptOpen?: Dispatch<SetStateAction<boolean>>;
}

const categoryItems: Record<string, string> = {
"-4": "Any Fish",
"-5": "Any Egg",
"-6": "Any Milk",
"-777": "Wild Seeds (Any)",
};

export function bundleItemName<T extends BundleItem>(item: T): string {
if (item.itemID == "-1") {
return "Gold";
} else if (item.itemID in categoryItems) {
return categoryItems[item.itemID];
}

return objects[item.itemID as keyof typeof objects]?.name;
}

export const BundleItemCard = ({
item,
show,
Expand Down
48 changes: 48 additions & 0 deletions apps/stardew.app/src/components/required-ingredients-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from "react";
import { InfoCard } from "./cards/info-card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";

interface RequiredIngredientsListProps {
title: string;
requiredIngredients: {
itemID: string;
name: string;
quantity: number;
sourceURL: string;
}[];
}

export const RequiredIngredientsList: React.FC<
RequiredIngredientsListProps
> = ({ title, requiredIngredients }) => {
return (
<Accordion type="single" collapsible defaultValue="item-1" asChild>
<section className="space-y-3">
<AccordionItem value="item-1">
<AccordionTrigger className="ml-1 pt-0 text-xl font-semibold text-gray-900 dark:text-white">
{title}
</AccordionTrigger>
<AccordionContent asChild>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{requiredIngredients.map((ingredient) => (
<InfoCard
key={ingredient.itemID}
title={ingredient.name}
description={`x${ingredient.quantity}`}
sourceURL={ingredient.sourceURL}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
</section>
</Accordion>
);
};

RequiredIngredientsList.displayName = "RequiredIngredientsList";
60 changes: 60 additions & 0 deletions apps/stardew.app/src/contexts/feature-gate-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useRouter } from "next/router";
import { createContext, ReactNode, useCallback, useContext } from "react";

interface FeatureGateContextType {
isFeatureEnabled: (feature: keyof typeof FEATURE_GATES) => boolean;
}

const FeatureGateContext = createContext<FeatureGateContextType | undefined>(
undefined,
);

const FEATURE_GATES = {
"cooking-ingredients": {
enabled: false,
queryParam: "cooking-ingredients",
description: "Show the ingredients needed to cook all shown recipes",
},
"crafting-ingredients": {
enabled: false,
queryParam: "crafting-ingredients",
description: "Show the ingredients needed to craft all shown recipes",
},
};

export function FeatureGateProvider({ children }: { children: ReactNode }) {
const { query } = useRouter();

const isFeatureEnabled = useCallback(
(feature: keyof typeof FEATURE_GATES) => {
if (query[FEATURE_GATES[feature].queryParam] === "1") {
return true;
}

if (query[FEATURE_GATES[feature].queryParam] === "0") {
return false;
}

return FEATURE_GATES[feature].enabled;
},
[query],
);

return (
<FeatureGateContext.Provider
value={{
isFeatureEnabled,
}}
>
{children}
</FeatureGateContext.Provider>
);
}

export function useFeatureGate() {
const context = useContext(FeatureGateContext);
if (context === undefined) {
throw new Error("useFeatureGate must be used within a FeatureGateContext");
}
return context;
}
95 changes: 95 additions & 0 deletions apps/stardew.app/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import XXH from "xxhashjs";

import objects from "@/data/objects.json";
import { BundleItem } from "@/types/bundles";

const semverSatisfies = require("semver/functions/satisfies");
const semverGte = require("semver/functions/gte");

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
Expand Down Expand Up @@ -250,3 +254,94 @@ export function getRandomSeed(
);
}
}

export const categoryItems: Record<string, string> = {
"-4": "Any Fish",
"-5": "Any Egg",
"-6": "Any Milk",
"-777": "Wild Seeds (Any)",
};

export function bundleItemName<T extends BundleItem>(item: T): string {
if (item.itemID == "-1") {
return "Gold";
} else if (item.itemID in categoryItems) {
return categoryItems[item.itemID];
}

return objects[item.itemID as keyof typeof objects]?.name;
}

export const composeShoppingListItems = (
shoppingList: Record<string, number>,
) =>
Object.entries(shoppingList)
.sort((a, b) => b[1] - a[1])
.map(([itemID, quantity]) => {
const imagePath =
parseInt(itemID, 10) < 0
? `(C)${itemID}`
: /\(\S+\)/.test(itemID)
? itemID
: `(O)${itemID}`;

return {
itemID,
quantity,
name: bundleItemName({
itemID,
itemQuantity: quantity as number,
itemQuality: "0",
}),
sourceURL: `https://cdn.stardew.app/images/${imagePath}.webp`,
};
});

interface Recipe {
ingredients: Array<{
itemID: string;
quantity: number;
}>;
itemID: string;
minVersion: string;
unlockConditions: string;
}

export const composeShoppingList = (filteredRecipes: Recipe[]) =>
filteredRecipes.reduce(
(acc, r) => {
r.ingredients.forEach((i) => {
acc[i.itemID] = (acc[i.itemID] || 0) + i.quantity;
});
return acc;
},
{} as Record<string, number>,
);

export const getFilteredRecipes = <T extends Recipe>(
recipes: Record<string, T>,
gameVersion: string,
search: string,
_filter: string,
playerRecipes: Record<string, 0 | 1 | 2>,
getNameFn: (r: T) => string,
): T[] => {
return Object.values(recipes)
.filter((r) => semverGte(gameVersion, r.minVersion))
.filter((r) => {
if (!search) return true;
return getNameFn(r).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
});
};
27 changes: 15 additions & 12 deletions apps/stardew.app/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Topbar, User } from "@/components/top-bar";
import { Toaster } from "sonner";

import { ThemeProvider } from "@/components/theme-provider";
import { FeatureGateProvider } from "@/contexts/feature-gate-context";
import { MultiSelectProvider } from "@/contexts/multi-select-context";
import { PlayersProvider } from "@/contexts/players-context";
import { PreferencesProvider } from "@/contexts/preferences-context";
Expand All @@ -29,20 +30,22 @@ export default function App({ Component, pageProps }: AppProps) {
<PlayersProvider>
<PreferencesProvider>
<MultiSelectProvider>
<div className={`${inter.className}`}>
<div className="sticky top-0 z-10 dark:bg-neutral-950">
<Topbar />
</div>
<div>
<Sidebar className="hidden max-h-[calc(100vh-65px)] min-h-[calc(100vh-65px)] overflow-y-auto overflow-x-clip md:fixed md:flex md:w-72 md:flex-col" />
<div className="md:pl-72">
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<Toaster richColors />
<FeatureGateProvider>
<div className={`${inter.className}`}>
<div className="sticky top-0 z-10 dark:bg-neutral-950">
<Topbar />
</div>
<div>
<Sidebar className="hidden max-h-[calc(100vh-65px)] min-h-[calc(100vh-65px)] overflow-y-auto overflow-x-clip md:fixed md:flex md:w-72 md:flex-col" />
<div className="md:pl-72">
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<Toaster richColors />
</div>
</div>
</div>
</div>
</FeatureGateProvider>
</MultiSelectProvider>
</PreferencesProvider>
</PlayersProvider>
Expand Down
5 changes: 2 additions & 3 deletions apps/stardew.app/src/pages/bundles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ import { usePreferences } from "@/contexts/preferences-context";

import { AchievementCard } from "@/components/cards/achievement-card";
import {
BundleItemCard,
bundleItemName,
BundleItemCard
} from "@/components/cards/bundle-item-card";
import { UnblurDialog } from "@/components/dialogs/unblur-dialog";
import BundleSheet from "@/components/sheets/bundle-sheet";
Expand All @@ -53,7 +52,7 @@ import {
import { Command, CommandInput } from "@/components/ui/command";
import { Progress } from "@/components/ui/progress";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import { bundleItemName, cn } from "@/lib/utils";
import { useMediaQuery } from "@react-hook/media-query";
import { IconSettings } from "@tabler/icons-react";
import clsx from "clsx";
Expand Down
Loading