@@ -577,8 +577,8 @@ const AnimalsContent = () => {
{/* No Data State */}
{!animalsData && (
-
-
+
+
No Save Data
diff --git a/apps/stardew.app/src/pages/api/bug.ts b/src/pages/api/bug.ts
similarity index 93%
rename from apps/stardew.app/src/pages/api/bug.ts
rename to src/pages/api/bug.ts
index 16981892..f7ebedc0 100644
--- a/apps/stardew.app/src/pages/api/bug.ts
+++ b/src/pages/api/bug.ts
@@ -1,5 +1,15 @@
import { NextApiRequest, NextApiResponse } from "next";
+type LinearIssueCreateResponse = {
+ data: {
+ issueCreate: {
+ issue: {
+ identifier: string;
+ };
+ };
+ };
+};
+
async function turnstile(token: string, ip: string | null) {
const formData = new URLSearchParams();
@@ -79,7 +89,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
}),
});
- const parsedLinearResponse = await linearResponse.json();
+ const parsedLinearResponse =
+ (await linearResponse.json()) as LinearIssueCreateResponse;
const identifier = parsedLinearResponse.data.issueCreate.issue.identifier;
if (!linearResponse.ok) {
diff --git a/apps/stardew.app/src/pages/api/feedback.ts b/src/pages/api/feedback.ts
similarity index 100%
rename from apps/stardew.app/src/pages/api/feedback.ts
rename to src/pages/api/feedback.ts
diff --git a/apps/stardew.app/src/pages/api/index.ts b/src/pages/api/me.ts
similarity index 62%
rename from apps/stardew.app/src/pages/api/index.ts
rename to src/pages/api/me.ts
index d162c2a0..4f5c5eb9 100644
--- a/apps/stardew.app/src/pages/api/index.ts
+++ b/src/pages/api/me.ts
@@ -1,18 +1,22 @@
import * as schema from "$drizzle/schema";
-import { db } from "@/db";
+import { withDb } from "@/db";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { getUID } from "./saves";
async function get(req: NextApiRequest, res: NextApiResponse) {
- const uid = await getUID(req, res);
- if (!uid) return res.status(401).end();
- const [user] = await db
- .select()
- .from(schema.users)
- .where(eq(schema.users.id, uid))
- .limit(1);
- return res.json(user);
+ return withDb(async (db) => {
+ const uid = await getUID(req, res, db);
+ if (!uid) return res.status(401).end();
+
+ const [user] = await db
+ .select()
+ .from(schema.users)
+ .where(eq(schema.users.id, uid))
+ .limit(1);
+
+ return res.json(user);
+ });
}
export default async function handler(
diff --git a/src/pages/api/oauth/callback.ts b/src/pages/api/oauth/callback.ts
new file mode 100644
index 00000000..ccea6577
--- /dev/null
+++ b/src/pages/api/oauth/callback.ts
@@ -0,0 +1,233 @@
+import * as schema from "$drizzle/schema";
+import { withDb } from "@/db";
+import { getRequestOrigin, getServerCookieDomain } from "@/lib/cookies";
+import { getCookie, setCookie } from "cookies-next";
+import crypto from "crypto";
+import { eq } from "drizzle-orm";
+import type { NextApiRequest, NextApiResponse } from "next";
+import { createToken } from "../saves";
+
+type Data = Record;
+type DiscordTokenResponse = {
+ access_token: string;
+ scope: string;
+};
+type DiscordUserResponse = {
+ id: string;
+ username: string;
+ avatar: string;
+};
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ try {
+ return await withDb(async (db) => {
+ const cookieDomain = getServerCookieDomain(req);
+ const origin = getRequestOrigin(req);
+ if (!origin) {
+ res.status(400).end();
+ console.log("[OAuth] No request origin");
+ return;
+ }
+
+ const redirectUri = `${origin}/api/oauth/callback`;
+ const state = getCookie("oauth_state", { req });
+ const callbackState = req.query.state;
+ if (
+ !state ||
+ typeof state !== "string" ||
+ typeof callbackState !== "string" ||
+ callbackState !== state
+ ) {
+ res.status(400).end();
+ console.log("[OAuth] Invalid state");
+ return;
+ }
+
+ const uid = getCookie("uid", { req });
+
+ if (!uid || typeof uid !== "string") {
+ res.status(400).end();
+ res.redirect("/");
+ console.log("[OAuth] No UID cookie");
+ return;
+ }
+
+ const code = req.query.code as string;
+ if (!code) {
+ res.status(400).end();
+ res.redirect("/");
+ console.log("[OAuth] No code");
+ return;
+ }
+
+ const discord = await fetch(
+ `https://discord.com/api/oauth2/token?grant_type=authorization_code&code=${code}&redirect_uri=${encodeURIComponent(
+ redirectUri,
+ )}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: process.env.DISCORD_ID ?? "",
+ client_secret: process.env.DISCORD_SECRET ?? "",
+ grant_type: "authorization_code",
+ code: code ?? "",
+ redirect_uri: redirectUri,
+ }),
+ },
+ );
+
+ if (!discord.ok) {
+ res.status(400).end();
+ console.log("[OAuth] Discord error");
+ return;
+ }
+
+ const discordData = (await discord.json()) as DiscordTokenResponse;
+
+ const discordUser = await fetch(`https://discord.com/api/users/@me`, {
+ headers: {
+ Authorization: `Bearer ${discordData.access_token}`,
+ },
+ });
+
+ if (!discordUser.ok) {
+ res.status(400).end();
+ console.log("[OAuth] Discord user error");
+ return;
+ }
+
+ const discordUserData = (await discordUser.json()) as DiscordUserResponse;
+
+ let [user] = await db
+ .select()
+ .from(schema.users)
+ .where(eq(schema.users.id, uid))
+ .limit(1);
+
+ let cookieSecret =
+ user?.cookie_secret ?? crypto.randomBytes(16).toString("hex");
+
+ if (!user) {
+ let [discordUser] = await db
+ .select()
+ .from(schema.users)
+ .where(eq(schema.users.discord_id, discordUserData.id))
+ .limit(1);
+
+ if (discordUser) {
+ user = discordUser;
+ cookieSecret = user.cookie_secret;
+
+ // update discord name if it has changed
+ if (discordUser.discord_name !== discordUserData.username) {
+ await db
+ .update(schema.users)
+ .set({ discord_name: discordUserData.username })
+ .where(eq(schema.users.discord_id, discordUserData.id));
+ }
+
+ // update discord avatar if the avatar hash changed
+ if (discordUser.discord_avatar !== discordUserData.avatar) {
+ await db
+ .update(schema.users)
+ .set({ discord_avatar: discordUserData.avatar })
+ .where(eq(schema.users.discord_id, discordUserData.id));
+ }
+ } else {
+ await db
+ .insert(schema.users)
+ .values({
+ id: uid,
+ discord_id: discordUserData.id,
+ discord_name: discordUserData.username,
+ discord_avatar: discordUserData.avatar,
+ cookie_secret: cookieSecret,
+ })
+ .onDuplicateKeyUpdate({
+ set: {
+ discord_id: discordUserData.id,
+ discord_name: discordUserData.username,
+ discord_avatar: discordUserData.avatar,
+ cookie_secret: cookieSecret,
+ },
+ });
+ // await conn.execute(
+ // "INSERT INTO Users (id, discord_id, discord_name, discord_avatar, cookie_secret) VALUES (?, ?, ?, ?, ?)",
+ // [
+ // uid as string,
+ // discordUserData.id,
+ // discordUserData.username,
+ // discordUserData.avatar,
+ // cookieSecret,
+ // ],
+ // );
+ user = {
+ id: uid,
+ discord_id: discordUserData.id,
+ discord_name: discordUserData.username,
+ discord_avatar: discordUserData.avatar,
+ cookie_secret: cookieSecret,
+ };
+ }
+ }
+
+ setCookie("uid", user.id, {
+ req,
+ res,
+ domain: cookieDomain,
+ maxAge: 60 * 60 * 24 * 365,
+ });
+
+ const token = createToken(user.id, cookieSecret, 60 * 60 * 24 * 365);
+ setCookie("token", token.token, {
+ req,
+ res,
+ domain: cookieDomain,
+ expires: new Date(token.expires * 1000),
+ });
+
+ setCookie(
+ "discord_user",
+ JSON.stringify({
+ discord_id: discordUserData.id,
+ discord_name: discordUserData.username,
+ discord_avatar: discordUserData.avatar,
+ }),
+ {
+ req,
+ res,
+ domain: cookieDomain,
+ expires: new Date(token.expires * 1000),
+ },
+ );
+
+ res.redirect("/");
+
+ if (discordData.scope.includes("guilds.join")) {
+ await fetch(
+ `https://discord.com/api/guilds/${process.env.DISCORD_GUILD}/members/${discordUserData.id}`,
+ {
+ method: "PUT",
+ body: JSON.stringify({
+ access_token: `${discordData.access_token}`,
+ roles: ["1150490180860530819"],
+ }),
+ headers: {
+ "Authorization": `Bot ${process.env.DISCORD_TOKEN}`,
+ "Content-Type": "application/json",
+ },
+ },
+ );
+ }
+ });
+ } catch (e: any) {
+ res.status(500).send(e.message);
+ console.log("[OAuth] Error", e);
+ }
+}
diff --git a/apps/stardew.app/src/pages/api/oauth/index.ts b/src/pages/api/oauth/index.ts
similarity index 54%
rename from apps/stardew.app/src/pages/api/oauth/index.ts
rename to src/pages/api/oauth/index.ts
index 0036fc1a..d5945465 100644
--- a/apps/stardew.app/src/pages/api/oauth/index.ts
+++ b/src/pages/api/oauth/index.ts
@@ -1,4 +1,5 @@
import { setCookie } from "cookies-next";
+import { getRequestOrigin, getServerCookieDomain } from "@/lib/cookies";
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -8,22 +9,23 @@ export default function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
+ const origin = getRequestOrigin(req);
+ if (!origin) {
+ res.status(400).end();
+ return;
+ }
+
+ const redirectUri = `${origin}/api/oauth/callback`;
const state = crypto.randomBytes(4).toString("hex");
setCookie("oauth_state", state, {
req,
res,
- domain: parseInt(process.env.NEXT_PUBLIC_DEVELOPMENT!)
- ? "localhost"
- : "stardew.app",
+ domain: getServerCookieDomain(req),
maxAge: 60 * 60 * 24 * 365,
});
res.redirect(
`https://discord.com/api/oauth2/authorize?client_id=${
process.env.DISCORD_ID
- }&redirect_uri=${encodeURIComponent(
- process.env.DISCORD_REDIRECT ?? "",
- )}&state=${state}&response_type=code&scope=identify${
- req.query.discord === "true" ? `%20guilds.join` : ``
- }`,
+ }&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&response_type=code&scope=identify${req.query && !req.query.discord ? `` : `%20guilds.join`}`,
);
}
diff --git a/src/pages/api/saves/[playerId].ts b/src/pages/api/saves/[playerId].ts
new file mode 100644
index 00000000..3e5e8c41
--- /dev/null
+++ b/src/pages/api/saves/[playerId].ts
@@ -0,0 +1,112 @@
+import { withDb } from "$db";
+import * as schema from "$drizzle/schema";
+import { applyPlayerPatch } from "@/lib/player-patch";
+import { and, eq } from "drizzle-orm";
+import { NextApiRequest, NextApiResponse } from "next";
+import { Player, getUID } from ".";
+
+function parseRequestBody(body: unknown): T {
+ if (typeof body === "string") {
+ return JSON.parse(body) as T;
+ }
+
+ return body as T;
+}
+
+const mergeableFields = [
+ "general",
+ "bundles",
+ "fishing",
+ "cooking",
+ "crafting",
+ "shipping",
+ "museum",
+ "social",
+ "monsters",
+ "walnuts",
+ "notes",
+ "scraps",
+ "perfection",
+ "powers",
+ "rarecrows",
+ "animals",
+] as const satisfies ReadonlyArray;
+
+async function patch(req: NextApiRequest, res: NextApiResponse) {
+ return withDb(async (db) => {
+ res.setHeader(
+ "Cache-Control",
+ "no-store, no-cache, must-revalidate, max-age=0",
+ );
+
+ const playerId = req.query.playerId as string | undefined;
+ if (!playerId) return res.status(400).end();
+
+ const uid = await getUID(req, res, db);
+ const player = parseRequestBody(req.body);
+ if (!player) return res.status(400).end();
+
+ try {
+ const [existingSave] = await db
+ .select()
+ .from(schema.saves)
+ .where(
+ and(eq(schema.saves._id, playerId), eq(schema.saves.user_id, uid)),
+ )
+ .limit(1);
+
+ if (!existingSave) {
+ return res.status(404).json({ error: "Save not found" });
+ }
+
+ const updates = mergeableFields.reduce(
+ (acc, field) => {
+ if (!Object.prototype.hasOwnProperty.call(player, field)) {
+ return acc;
+ }
+
+ acc[field] = applyPlayerPatch(existingSave[field], player[field]);
+ return acc;
+ },
+ {} as Partial>,
+ );
+
+ if (Object.keys(updates).length === 0) {
+ return res.status(400).json({ error: "No update fields provided" });
+ }
+
+ await db
+ .update(schema.saves)
+ .set(updates)
+ .where(
+ and(eq(schema.saves._id, playerId), eq(schema.saves.user_id, uid)),
+ );
+
+ res.status(204).end();
+ } catch (e) {
+ console.error("Database update error:", e);
+ console.error("Player data:", JSON.stringify(player, null, 2));
+ console.error("Player ID:", playerId);
+ console.error("User ID:", uid);
+ res
+ .status(500)
+ .json({ error: e instanceof Error ? e.message : "Unknown error" });
+ }
+ });
+}
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ try {
+ switch (req.method) {
+ case "PATCH":
+ return await patch(req, res);
+ }
+ res.status(405).end();
+ } catch (e: any) {
+ console.error("Handler error:", e);
+ res.status(500).json({ error: e.message });
+ }
+}
diff --git a/apps/stardew.app/src/pages/api/saves/index.ts b/src/pages/api/saves/index.ts
similarity index 63%
rename from apps/stardew.app/src/pages/api/saves/index.ts
rename to src/pages/api/saves/index.ts
index 71a84a17..85229563 100644
--- a/apps/stardew.app/src/pages/api/saves/index.ts
+++ b/src/pages/api/saves/index.ts
@@ -1,5 +1,7 @@
-import { db } from "$db";
+import type { Db } from "$db";
+import { withDb } from "$db";
import * as schema from "$drizzle/schema";
+import { getServerCookieDomain } from "@/lib/cookies";
import { getCookie, setCookie } from "cookies-next";
import crypto from "crypto";
import { and, eq } from "drizzle-orm";
@@ -7,6 +9,18 @@ import { NextApiRequest, NextApiResponse } from "next";
type Data = Record;
+function parseRequestBody(body: unknown): T {
+ if (typeof body === "string") {
+ if (!body) {
+ return {} as T;
+ }
+
+ return JSON.parse(body) as T;
+ }
+
+ return (body ?? {}) as T;
+}
+
export interface SqlUser {
id: string;
discord_id: string;
@@ -38,6 +52,7 @@ export interface Player {
export async function getUID(
req: NextApiRequest,
res: NextApiResponse,
+ db: Db,
): Promise {
let uid = getCookie("uid", { req, res });
if (uid && typeof uid === "string") {
@@ -77,9 +92,7 @@ export async function getUID(
req,
res,
maxAge: 60 * 60 * 24 * 365,
- domain: parseInt(process.env.NEXT_PUBLIC_DEVELOPMENT!)
- ? "localhost"
- : "stardew.app",
+ domain: getServerCookieDomain(req),
});
}
return uid;
@@ -115,23 +128,38 @@ export const verifyToken = (token: string, key: string) => {
};
};
+async function getPlayersByUid(db: Db, uid: string) {
+ return db.select().from(schema.saves).where(eq(schema.saves.user_id, uid));
+}
+
async function get(req: NextApiRequest, res: NextApiResponse) {
- const uid = await getUID(req, res);
- const players = await db
- .select()
- .from(schema.saves)
- .where(eq(schema.saves.user_id, uid));
- res.json(players);
+ return withDb(async (db) => {
+ res.setHeader(
+ "Cache-Control",
+ "no-store, no-cache, must-revalidate, max-age=0",
+ );
+
+ const uid = await getUID(req, res, db);
+ const players = await getPlayersByUid(db, uid);
+
+ res.json(players);
+ });
}
async function post(req: NextApiRequest, res: NextApiResponse) {
- // console.log("Saving...");
- // console.log(process.env.DATABASE_URL);
- const uid = await getUID(req, res);
- const players = JSON.parse(req.body) as Player[];
- for (const player of players) {
+ return withDb(async (db) => {
+ res.setHeader(
+ "Cache-Control",
+ "no-store, no-cache, must-revalidate, max-age=0",
+ );
+
+ const uid = await getUID(req, res, db);
+ const players = parseRequestBody(req.body);
+
try {
- if (player._id) {
+ for (const player of players) {
+ if (!player._id) continue;
+
await db
.insert(schema.saves)
.values({
@@ -139,62 +167,62 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
user_id: uid,
...player,
})
- .onDuplicateKeyUpdate({ set: player });
+ .onDuplicateKeyUpdate({
+ set: {
+ user_id: uid,
+ ...player,
+ },
+ });
}
- res.status(200).end();
+
+ const savedPlayers = await getPlayersByUid(db, uid);
+ res.status(200).json(savedPlayers);
} catch (e) {
console.log(e);
res.status(500).end();
}
- }
+ });
}
async function _delete(req: NextApiRequest, res: NextApiResponse) {
- // console.log("Deleting...");
- const uid = await getUID(req, res);
-
- if (!req.body) {
- // delete all players
- await db.delete(schema.saves).where(eq(schema.saves.user_id, uid));
- // const result = await conn.execute("DELETE FROM Saves WHERE user_id = ?", [
- // uid,
- // ]);
- // console.log("[DEBUG:SAVES] DELETE | deleted all players with uid =", uid);
- } else {
- // console.log("[DEBUG:SAVES] DELETE | req.body =", req.body);
- const { type } = JSON.parse(req.body);
+ return withDb(async (db) => {
+ const uid = await getUID(req, res, db);
+ const body = req.body
+ ? parseRequestBody<{ type?: string; _id?: string }>(req.body)
+ : undefined;
+ const type = body?.type;
if (type === "player") {
- // delete a single player
- const { _id } = JSON.parse(req.body);
+ const playerId = body?._id;
+ if (!playerId) {
+ return res.status(400).end();
+ }
+
await db
.delete(schema.saves)
- .where(and(eq(schema.saves.user_id, uid), eq(schema.saves._id, _id)));
-
- // const result = await conn.execute(
- // "DELETE FROM Saves WHERE user_id = ? AND _id = ?",
- // [uid, _id],
- // );
-
- // console.log("[DEBUG:SAVES] DELETE | deleted one player with id =", _id);
- } else {
- // delete entire account
- // delete players
+ .where(
+ and(eq(schema.saves.user_id, uid), eq(schema.saves._id, playerId)),
+ );
+ } else if (type === "account") {
await db.delete(schema.saves).where(eq(schema.saves.user_id, uid));
- // const result = await conn.execute("DELETE FROM Saves WHERE user_id = ?", [
- // uid,
- // ]);
- // delete user
await db.delete(schema.users).where(eq(schema.users.id, uid));
- // const result2 = await conn.execute("DELETE FROM Users WHERE id = ?", [
- // uid,
- // ]);
- // console.log("[DEBUG:SAVES] DELETE | deleted account with uid =", uid);
+ res.setHeader(
+ "Cache-Control",
+ "no-store, no-cache, must-revalidate, max-age=0",
+ );
+ return res.status(204).end();
+ } else {
+ await db.delete(schema.saves).where(eq(schema.saves.user_id, uid));
}
- }
- // console.log(result.rowsAffected)
- res.status(204).end();
+
+ const remainingPlayers = await getPlayersByUid(db, uid);
+ res.setHeader(
+ "Cache-Control",
+ "no-store, no-cache, must-revalidate, max-age=0",
+ );
+ res.status(200).json(remainingPlayers);
+ });
}
export default async function handler(
@@ -212,6 +240,10 @@ export default async function handler(
}
res.status(405).end();
} catch (e: any) {
- res.send(e.message);
+ console.error(e);
+ const status = res.statusCode >= 400 ? res.statusCode : 500;
+ res
+ .status(status)
+ .send(e instanceof Error ? e.message : "Internal Server Error");
}
}
diff --git a/apps/stardew.app/src/pages/bundles.tsx b/src/pages/bundles.tsx
similarity index 93%
rename from apps/stardew.app/src/pages/bundles.tsx
rename to src/pages/bundles.tsx
index 169a93af..4b1ac27b 100644
--- a/apps/stardew.app/src/pages/bundles.tsx
+++ b/src/pages/bundles.tsx
@@ -1,7 +1,10 @@
import Head from "next/head";
+import { type ReactElement } from "react";
import achievements from "@/data/achievements.json";
import bundlesData from "@/data/bundles.json";
+import fishData from "@/data/fish.json";
+import shippingData from "@/data/shipping.json";
import {
Bundle,
@@ -34,14 +37,12 @@ import {
} from "@/components/ui/tooltip";
import { PlayerType, usePlayers } from "@/contexts/players-context";
-import { usePreferences } from "@/contexts/preferences-context";
import { AchievementCard } from "@/components/cards/achievement-card";
import {
BundleItemCard,
bundleItemName,
} from "@/components/cards/bundle-item-card";
-import { UnblurDialog } from "@/components/dialogs/unblur-dialog";
import BundleSheet from "@/components/sheets/bundle-sheet";
import {
Accordion,
@@ -54,8 +55,9 @@ 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 { FilterSearch } from "@/components/filter-btn";
import { useMediaQuery } from "@react-hook/media-query";
-import { IconSettings } from "@tabler/icons-react";
+import { IconClock, IconSettings } from "@tabler/icons-react";
import clsx from "clsx";
import { useEffect, useState } from "react";
@@ -72,16 +74,37 @@ 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" },
+];
+
+function itemAvailableInSeason(item: BundleItem, season: string): boolean {
+ const id = item.itemID;
+ const fish = (fishData as Record)[id];
+ if (fish?.seasons) {
+ return fish.seasons.includes(season);
+ }
+ const shipping = (shippingData as Record)[id];
+ if (shipping?.seasons && shipping.seasons.length > 0) {
+ return shipping.seasons.includes(season);
+ }
+ return true;
+}
+
type BundleAccordionProps = {
bundleWithStatus: BundleWithStatus;
- children: JSX.Element | JSX.Element[];
+ children: ReactElement | ReactElement[];
alternateOptions?: Bundle[];
onChangeBundle?: (bundle: Bundle, bundleWithStatus: BundleWithStatus) => void;
};
type AccordionSectionProps = {
title: string;
- children: JSX.Element | JSX.Element[];
+ children: ReactElement | ReactElement[];
completedCount?: number;
};
@@ -101,7 +124,7 @@ const CommunityCenterRooms: CommunityCenterRoomName[] = [
"Abandoned Joja Mart",
];
-function AccordionSection(props: AccordionSectionProps): JSX.Element {
+function AccordionSection(props: AccordionSectionProps): ReactElement {
const { activePlayer } = usePlayers();
let progressIndicator =
activePlayer &&
@@ -137,7 +160,7 @@ function AccordionSection(props: AccordionSectionProps): JSX.Element {
);
}
-function BundleAccordion(props: BundleAccordionProps): JSX.Element {
+function BundleAccordion(props: BundleAccordionProps): ReactElement {
const isDesktop = useMediaQuery("only screen and (min-width: 768px)");
const { bundle, bundleStatus } = props.bundleWithStatus;
@@ -428,16 +451,13 @@ function GenerateFreshBundles(): BundleWithStatus[] {
}
export default function Bundles() {
- // unblur dialog
- const [showPrompt, setPromptOpen] = useState(false);
- const { show, toggleShow } = usePreferences();
-
let [open, setIsOpen] = useState(false);
let [object, setObject] = useState(null);
let [bundles, setBundles] = useState([]);
let [completeCount, setCompleteCount] = useState(0);
let [incompleteCount, setIncompleteCount] = useState(0);
let [filter, setFilter] = useState("all");
+ let [_seasonFilter, setSeasonFilter] = useState("all");
let [search, setSearch] = useState("");
const { activePlayer, patchPlayer } = usePlayers();
@@ -495,7 +515,7 @@ export default function Bundles() {
// See note in bundlesheet.tsx
// @ts-ignore
- await patchPlayer(patch);
+ void patchPlayer(patch);
}
}
@@ -593,7 +613,7 @@ export default function Bundles() {
})}
{/* Filters and Actions Row */}
-
+
Complete ({completeCount})
+
{/* Search Bar Row */}
@@ -688,6 +715,14 @@ export default function Bundles() {
return true;
}
})
+ .filter((item) => {
+ if (_seasonFilter === "all") return true;
+ if (isRandomizer(item)) return true;
+ return itemAvailableInSeason(
+ item as BundleItem,
+ _seasonFilter,
+ );
+ })
.filter((item) => {
if (bundleMatched) {
return true;
@@ -749,8 +784,6 @@ export default function Bundles() {
setIsOpen={setIsOpen}
completed={bundleWithStatus.bundleStatus[index]}
setObject={setObject}
- show={show}
- setPromptOpen={setPromptOpen}
/>
);
})
@@ -768,11 +801,6 @@ export default function Bundles() {
setIsOpen={setIsOpen}
bundleItemWithLocation={object}
/>
-
>
diff --git a/src/pages/cooking.tsx b/src/pages/cooking.tsx
new file mode 100644
index 00000000..18a8a682
--- /dev/null
+++ b/src/pages/cooking.tsx
@@ -0,0 +1,486 @@
+import { X } from "lucide-react";
+import Head from "next/head";
+
+import achievements from "@/data/achievements.json";
+import recipes from "@/data/cooking.json";
+import objects from "@/data/objects.json";
+
+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 { FilterSearch } from "@/components/filter-btn";
+import { RecipeSheet } from "@/components/sheets/recipe-sheet";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} 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 { IconClock } from "@tabler/icons-react";
+
+const semverGte = require("semver/functions/gte");
+
+const reqs: Record
= {
+ "Cook": 10,
+ "Sous Chef": 25,
+ "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
+};
+
+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<{
+ [key: string]: 0 | 1 | 2;
+ }>({});
+
+ 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 [betaDialogOpen, setBetaDialogOpen] = useState(false);
+
+ const { activePlayer } = usePlayers();
+ const { showBetaFeatures, toggleBetaFeatures } = usePreferences();
+ const {
+ isMultiSelectMode,
+ toggleMultiSelectMode,
+ selectedItems,
+ clearSelection,
+ } = useMultiSelect();
+
+ const [_seasonFilter, setSeasonFilter] = useState("all");
+
+ useEffect(() => {
+ if (activePlayer) {
+ if (activePlayer.cooking?.recipes) {
+ setPlayerRecipes(activePlayer.cooking.recipes);
+ } else setPlayerRecipes({});
+
+ // update the requirements for achievements and set the minimum game version
+ if (activePlayer.general?.gameVersion) {
+ const version = activePlayer.general.gameVersion;
+ setGameVersion(version);
+
+ reqs["Gourmet Chef"] = Object.values(recipes).filter((r) =>
+ semverGte(version, r.minVersion),
+ ).length;
+ }
+ }
+ }, [activePlayer]);
+
+ const cookedCount = useMemo(() => {
+ if (!activePlayer || !activePlayer.cooking?.recipes) return 0;
+
+ return Object.values(activePlayer.cooking.recipes).filter((r) => r > 1)
+ .length;
+ }, [activePlayer]);
+
+ // tracks how many recipes the players knows but has not cooked
+ const knownCount = useMemo(() => {
+ if (!activePlayer || !activePlayer.cooking?.recipes) return 0;
+
+ return Object.values(activePlayer.cooking.recipes).filter((r) => r === 1)
+ .length;
+ }, [activePlayer]);
+
+ const getAchievementProgress = (name: string) => {
+ let completed = false;
+ let additionalDescription = "";
+
+ if (!activePlayer) {
+ return { completed, additionalDescription };
+ }
+
+ completed = cookedCount >= reqs[name];
+
+ if (!completed) {
+ additionalDescription = ` - ${reqs[name] - cookedCount} left`;
+ }
+ 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 (
+ <>
+
+ Stardew Valley Cooking Tracker | stardew.app
+
+
+
+
+
+
+
+
+
+ Cooking Tracker
+
+ {/* Achievements Section */}
+
+
+
+
+ Achievements
+
+
+
+ {Object.values(achievements)
+ .filter((a) => a.description.includes("Cook"))
+ .map((achievement) => {
+ const { completed, additionalDescription } =
+ getAchievementProgress(achievement.name);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ All Recipes
+
+ Ingredient Tracker{" "}
+
+ beta
+
+
+
+ {/* All Recipes Section */}
+
+ {/* 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"
+ />
+
+
+ {/* 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) => (
+
+ ))}
+
+
+ {/* Needed Ingredients Section */}
+
+ {/* Filters and Actions Row */}
+
+
+
+ setFilter(val === _filter ? "all" : val)
+ }
+ className="gap-2"
+ >
+
+
+
+ Unknown (
+ {reqs["Gourmet Chef"] - (knownCount + cookedCount)})
+
+
+
+
+ Known ({knownCount})
+
+
+
+
+
+
+
+ {/* Search Bar Row */}
+
+
+ setIngredientSearch(v)}
+ placeholder="Search Ingredients"
+ />
+
+
+
+ recipes={recipes}
+ playerRecipes={playerRecipes}
+ 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;
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/src/pages/crafting.tsx b/src/pages/crafting.tsx
new file mode 100644
index 00000000..00f8fc98
--- /dev/null
+++ b/src/pages/crafting.tsx
@@ -0,0 +1,498 @@
+import Head from "next/head";
+
+import achievements from "@/data/achievements.json";
+import bigobjects from "@/data/big_craftables.json";
+import recipes from "@/data/crafting.json";
+import objects from "@/data/objects.json";
+
+import type { CraftingRecipe } 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 { FilterSearch } from "@/components/filter-btn";
+import { IngredientList } from "@/components/ingredient-list";
+import { RecipeSheet } from "@/components/sheets/recipe-sheet";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} 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 { IconClock } from "@tabler/icons-react";
+import { X } from "lucide-react";
+
+const semverGte = require("semver/functions/gte");
+
+const reqs: Record = {
+ "D.I.Y.": 15,
+ "Artisan": 30,
+ "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
+};
+
+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<{
+ [key: string]: 0 | 1 | 2;
+ }>({});
+
+ 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 [betaDialogOpen, setBetaDialogOpen] = useState(false);
+
+ const { activePlayer } = usePlayers();
+ const { showBetaFeatures, toggleBetaFeatures } = usePreferences();
+ const {
+ isMultiSelectMode,
+ toggleMultiSelectMode,
+ selectedItems,
+ clearSelection,
+ } = useMultiSelect();
+
+ const [bulkActionOpen, setBulkActionOpen] = useState(false);
+
+ useEffect(() => {
+ if (activePlayer) {
+ if (activePlayer.crafting?.recipes) {
+ setPlayerRecipes(activePlayer.crafting.recipes);
+ } else setPlayerRecipes({});
+
+ // update the requirements for achievements and set the minimum game version
+ if (activePlayer.general?.gameVersion) {
+ const version = activePlayer.general.gameVersion;
+ setGameVersion(version);
+
+ reqs["Craft Master"] = Object.values(recipes).filter((r) =>
+ semverGte(version, r.minVersion),
+ ).length;
+ }
+ }
+ }, [activePlayer]);
+
+ // calculate craftedCount here (all values of 2)
+ const craftedCount = useMemo(() => {
+ if (!activePlayer || !activePlayer.crafting?.recipes) return 0;
+
+ return Object.values(activePlayer.crafting.recipes).filter((r) => r === 2)
+ .length;
+ }, [activePlayer]);
+
+ // tracks how many recipes the player knows but hasn't crafted
+ const knownCount = useMemo(() => {
+ if (!activePlayer || !activePlayer.crafting?.recipes) return 0;
+
+ return Object.values(activePlayer.crafting.recipes).filter((r) => r === 1)
+ .length;
+ }, [activePlayer]);
+
+ const getAchievementProgress = (name: string) => {
+ let completed = false;
+ let additionalDescription = "";
+
+ if (!activePlayer) {
+ return { completed, additionalDescription };
+ }
+
+ completed = craftedCount >= reqs[name];
+
+ if (!completed) {
+ additionalDescription = ` - ${reqs[name] - craftedCount} left`;
+ }
+
+ return { completed, additionalDescription };
+ };
+
+ const getName = (id: string, isBigCraftable: boolean) => {
+ if (isBigCraftable) {
+ return bigobjects[id as keyof typeof bigobjects].name;
+ } else {
+ return objects[id as keyof typeof objects].name;
+ }
+ };
+
+ 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 (
+ <>
+
+ Stardew Valley Crafting Tracker | stardew.app
+
+
+
+
+
+
+
+
+
+ Crafting Tracker
+
+ {/* Achievements Section */}
+
+
+
+
+ Achievements
+
+
+
+ {Object.values(achievements)
+ .filter((a) => a.description.includes("Craft"))
+ .map((achievement) => {
+ const { completed, additionalDescription } =
+ getAchievementProgress(achievement.name);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+ All Recipes
+
+ Ingredient Tracker{" "}
+
+ beta
+
+
+
+ {/* All Recipes Section */}
+
+ {/* 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"
+ />
+
+
+ {/* 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}
+ index={index}
+ allRecipes={filteredRecipes as CraftingRecipe[]}
+ />
+ ))}
+
+
+ {/* Needed Ingredients Section */}
+
+ {/* Filters and Actions Row */}
+
+
+
+ setFilter(val === _filter ? "all" : val)
+ }
+ className="gap-2"
+ >
+
+
+
+ Unknown (
+ {reqs["Craft Master"] - (knownCount + craftedCount)})
+
+
+
+
+ Known ({knownCount})
+
+
+
+
+
+
+
+ {/* Search Bar Row */}
+
+
+ setIngredientSearch(v)}
+ placeholder="Search Ingredients"
+ />
+
+
+
+ recipes={recipes}
+ playerRecipes={playerRecipes}
+ 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;
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/apps/stardew.app/src/pages/editor/create.tsx b/src/pages/editor/create.tsx
similarity index 98%
rename from apps/stardew.app/src/pages/editor/create.tsx
rename to src/pages/editor/create.tsx
index 3b79f8c7..7ce82922 100644
--- a/apps/stardew.app/src/pages/editor/create.tsx
+++ b/src/pages/editor/create.tsx
@@ -88,7 +88,7 @@ export const skillsArray = [
] as const;
export default function Editor() {
- let [disabled, setDisabled] = useState(false);
+ const [disabled, setDisabled] = useState(false);
const router = useRouter();
const { uploadPlayers } = usePlayers();
@@ -151,11 +151,11 @@ export default function Editor() {
},
};
- let res = await uploadPlayers([player]);
- if (res.status == 200) {
+ try {
+ await uploadPlayers([player]);
router.push("/farmer");
return toast.success("Successfully created your farmer!");
- } else {
+ } catch {
setDisabled(false);
return toast.error("Failed to create your farmer!");
}
@@ -345,6 +345,7 @@ export default function Editor() {
type="number"
placeholder="1000000"
{...field}
+ value={field.value as number}
/>
@@ -368,6 +369,7 @@ export default function Editor() {
type="number"
placeholder="100"
{...field}
+ value={field.value as number}
/>
@@ -445,7 +447,7 @@ export default function Editor() {
- 0
+ 0
1
2
@@ -528,6 +530,7 @@ export default function Editor() {
type="number"
placeholder="40"
{...field}
+ value={field.value as number}
/>
diff --git a/apps/stardew.app/src/pages/editor/edit.tsx b/src/pages/editor/edit.tsx
similarity index 100%
rename from apps/stardew.app/src/pages/editor/edit.tsx
rename to src/pages/editor/edit.tsx
diff --git a/apps/stardew.app/src/pages/farmer.tsx b/src/pages/farmer.tsx
similarity index 70%
rename from apps/stardew.app/src/pages/farmer.tsx
rename to src/pages/farmer.tsx
index 31fca964..a3b236a8 100644
--- a/apps/stardew.app/src/pages/farmer.tsx
+++ b/src/pages/farmer.tsx
@@ -5,11 +5,19 @@ import achievements from "@/data/achievements.json";
import { useEffect, useMemo, useState } from "react";
import { usePlayers } from "@/contexts/players-context";
-import { usePreferences } from "@/contexts/preferences-context";
import { AchievementCard } from "@/components/cards/achievement-card";
import { DialogCard } from "@/components/cards/dialog-card";
import { InfoCard } from "@/components/cards/info-card";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import {
Accordion,
AccordionContent,
@@ -23,6 +31,7 @@ import {
ClockIcon,
CurrencyDollarIcon,
HomeIcon,
+ PencilSquareIcon,
StarIcon,
UserIcon,
} from "@heroicons/react/24/solid";
@@ -82,10 +91,13 @@ const reqs: Record = {
};
export default function Farmer() {
- const { activePlayer } = usePlayers();
- const { show } = usePreferences();
+ const { activePlayer, patchPlayer } = usePlayers();
const [stardrops, setStardrops] = useState(new Set());
+ const [editMoneyOpen, setEditMoneyOpen] = useState(false);
+ const [editQuestsOpen, setEditQuestsOpen] = useState(false);
+ const [editMoneyValue, setEditMoneyValue] = useState(0);
+ const [editQuestsValue, setEditQuestsValue] = useState(0);
useEffect(() => {
if (activePlayer) {
@@ -159,7 +171,7 @@ export default function Farmer() {
/>
-
+
+
+ {activePlayer && (
+
+ )}
+
-
+
+
+ {activePlayer && (
+
+ )}
+
))}
@@ -343,6 +386,64 @@ export default function Farmer() {
>
);
}
diff --git a/apps/stardew.app/src/pages/fishing.tsx b/src/pages/fishing.tsx
similarity index 84%
rename from apps/stardew.app/src/pages/fishing.tsx
rename to src/pages/fishing.tsx
index 0931c76c..672e65ec 100644
--- a/apps/stardew.app/src/pages/fishing.tsx
+++ b/src/pages/fishing.tsx
@@ -7,13 +7,11 @@ import fishes from "@/data/fish.json";
import objects from "@/data/objects.json";
import { usePlayers } from "@/contexts/players-context";
-import { usePreferences } from "@/contexts/preferences-context";
import { useEffect, useState } from "react";
import { AchievementCard } from "@/components/cards/achievement-card";
import { BooleanCard } from "@/components/cards/boolean-card";
import { BulkActionDialog } from "@/components/dialogs/bulk-action-dialog";
-import { UnblurDialog } from "@/components/dialogs/unblur-dialog";
import { FilterSearch } from "@/components/filter-btn";
import { FishSheet } from "@/components/sheets/fish-sheet";
import {
@@ -29,7 +27,7 @@ import { useMultiSelect } from "@/contexts/multi-select-context";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
-import { IconClock, IconCloud } from "@tabler/icons-react";
+import { IconClock, IconCloud, IconMapPin } from "@tabler/icons-react";
const semverGte = require("semver/functions/gte");
@@ -78,6 +76,55 @@ const seasons = [
},
];
+const locationGroups: Record