Skip to content

Commit 66c2992

Browse files
committed
feat(app): add Alt-click recipe explorer
1 parent df778e7 commit 66c2992

12 files changed

Lines changed: 537 additions & 192 deletions

app/e2e/explorer-alt-click.e2e.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("Alt+Click opens a focused recipe explorer without navigating away", async ({ page }) => {
4+
test.setTimeout(60_000); // first icon-atlas load can be slow on a cold dev server
5+
6+
await page.goto("/browse");
7+
await page.waitForFunction(
8+
() => {
9+
const nav = document.querySelector("nav");
10+
return !!nav && Object.keys(nav).some((key) => key.startsWith("__reactFiber$"));
11+
},
12+
{ timeout: 30_000 },
13+
);
14+
await page.getByPlaceholder("search items & fluids…").fill("iron plate");
15+
const result = page.getByRole("button", { name: "Iron plate", exact: true }).first();
16+
await result.locator("[data-good-name='iron-plate']").click({ modifiers: ["Alt"] });
17+
18+
const dialog = page.getByRole("dialog", { name: "Recipe explorer" });
19+
await expect(dialog).toBeVisible();
20+
await expect(dialog.getByRole("button", { name: /^Recipes \(\d+\)$/ })).toHaveAttribute(
21+
"aria-pressed",
22+
"true",
23+
);
24+
await expect(dialog.locator("span[style*='background-image']").first()).toBeVisible({
25+
timeout: 30_000,
26+
});
27+
await expect(dialog.getByText("Recipe", { exact: true })).toBeVisible();
28+
await expect(dialog.getByText("Inputs", { exact: true }).first()).toBeVisible();
29+
await expect(dialog.getByText("Outputs", { exact: true }).first()).toBeVisible();
30+
await dialog.getByRole("button", { name: /^Uses \(\d+\)$/ }).click();
31+
await expect(dialog.getByText(/^Uses \(\d+\)$/).last()).toBeVisible();
32+
await expect(page).toHaveURL(/\/browse$/);
33+
});
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { Link } from "@tanstack/react-router";
2+
import { useEffect, useState } from "react";
3+
import { useQuery, keepPreviousData } from "@tanstack/react-query";
4+
import { Flame, Network, Search } from "lucide-react";
5+
import { browseDetailFn } from "../../server/factorio";
6+
import { recordRecent } from "../../lib/recents";
7+
import { Icon } from "../../lib/icons";
8+
import { Button } from "#/components/ui/button.tsx";
9+
import { Callout } from "#/components/ui/callout.tsx";
10+
import { EmptyState } from "#/components/empty-state.tsx";
11+
import { FilterInput } from "#/components/filter-input.tsx";
12+
import { Skeleton } from "#/components/ui/skeleton.tsx";
13+
import { Segmented } from "#/components/ui/segmented.tsx";
14+
import { FlowStaleCallout } from "./flow-stale-callout.tsx";
15+
import { RecipeList } from "./recipe-list.tsx";
16+
17+
type Kind = "item" | "fluid";
18+
19+
/** Full producer/consumer explorer for one good. Shared by the Browse page and
20+
* the global Alt+Click dialog so both surfaces expose the same decisions. */
21+
export function GoodDetail({
22+
name,
23+
onPick,
24+
className = "",
25+
variant = "browse",
26+
}: {
27+
name?: string;
28+
onPick: (name: string) => void;
29+
className?: string;
30+
variant?: "browse" | "dialog";
31+
}) {
32+
const [recipeQuery, setRecipeQuery] = useState("");
33+
const [side, setSide] = useState<"recipes" | "uses">("recipes");
34+
useEffect(() => {
35+
setRecipeQuery("");
36+
setSide("recipes");
37+
}, [name]);
38+
const detail = useQuery({
39+
queryKey: ["browseDetail", name],
40+
queryFn: () => browseDetailFn({ data: name! }),
41+
enabled: !!name,
42+
placeholderData: keepPreviousData,
43+
});
44+
45+
const data = detail.data;
46+
useEffect(() => {
47+
if (!data || data.name !== name) return;
48+
recordRecent({
49+
type: "good",
50+
name: data.name,
51+
goodKind: data.kind as Kind,
52+
display: data.display ?? data.name,
53+
});
54+
}, [data, name]);
55+
56+
return (
57+
<div className={className}>
58+
{!name && (
59+
<EmptyState
60+
className="h-full"
61+
icon={Search}
62+
title="Nothing selected"
63+
description="Search on the left, or Alt+Click any item or fluid icon."
64+
/>
65+
)}
66+
{name && detail.isPending && (
67+
<div className="flex flex-col gap-4">
68+
<div className="flex items-center gap-3">
69+
<Skeleton className="size-12" />
70+
<div className="flex flex-col gap-1.5">
71+
<Skeleton className="h-5 w-40" />
72+
<Skeleton className="h-4 w-64" />
73+
</div>
74+
</div>
75+
<div className={variant === "dialog" ? "" : "grid grid-cols-1 gap-4 2xl:grid-cols-2"}>
76+
<Skeleton className="h-40 w-full" />
77+
{variant === "browse" && <Skeleton className="h-40 w-full" />}
78+
</div>
79+
</div>
80+
)}
81+
{name && detail.isError && (
82+
<Callout tone="destructive">failed to load this item — try again</Callout>
83+
)}
84+
{data && (
85+
<>
86+
<div className="mb-4 flex items-center gap-3">
87+
<Icon kind={data.kind as Kind} name={data.name} size="lg" noTitle />
88+
<div>
89+
<h1 className="text-lg font-semibold tracking-tight">{data.display}</h1>
90+
<div className="flex flex-wrap items-center gap-x-1 text-sm text-muted-foreground">
91+
{data.name} · {data.kind}
92+
{data.item?.stackSize != null && ` · stack ${data.item.stackSize}`}
93+
{data.item?.fuelValueJ != null && (
94+
<span className="inline-flex items-center gap-1">
95+
· <Flame className="size-3.5" /> {fmtJ(data.item.fuelValueJ)} (
96+
{data.item.fuelCategory})
97+
</span>
98+
)}
99+
{data.fluid?.fuelValueJ != null && (
100+
<span className="inline-flex items-center gap-1">
101+
· <Flame className="size-3.5" /> {fmtJ(data.fluid.fuelValueJ)}/unit
102+
</span>
103+
)}
104+
{data.item?.burntResult && ` · burns to ${data.item.burntResult}`}
105+
<Button
106+
asChild
107+
variant="ghost"
108+
size="sm"
109+
className="h-auto gap-1 px-1 py-0 font-normal text-info hover:text-info"
110+
>
111+
<Link to="/deps" search={{ sel: data.name }}>
112+
<Network className="size-3.5" /> dependencies
113+
</Link>
114+
</Button>
115+
</div>
116+
</div>
117+
</div>
118+
119+
{!data.flowComputed && data.producedBy.length + data.consumedBy.length > 0 && (
120+
<FlowStaleCallout />
121+
)}
122+
{variant === "dialog" && (
123+
<Segmented
124+
value={side}
125+
onValueChange={setSide}
126+
aria-label="Recipe explorer view"
127+
options={[
128+
{ value: "recipes", label: `Recipes (${data.producedBy.length})` },
129+
{ value: "uses", label: `Uses (${data.consumedBy.length})` },
130+
]}
131+
className="mb-4"
132+
/>
133+
)}
134+
{data.producedBy.length + data.consumedBy.length > 0 && (
135+
<FilterInput
136+
value={recipeQuery}
137+
onValueChange={setRecipeQuery}
138+
placeholder="filter recipes…"
139+
className="mb-4 max-w-sm"
140+
/>
141+
)}
142+
{variant === "dialog" ? (
143+
side === "recipes" ? (
144+
<RecipeList
145+
title={`Recipes (${data.producedBy.length})`}
146+
cards={data.producedBy}
147+
focus={data.name}
148+
emptyText="nothing makes this — a raw input"
149+
query={recipeQuery}
150+
onClearQuery={() => setRecipeQuery("")}
151+
onPick={onPick}
152+
variant="comfortable"
153+
/>
154+
) : (
155+
<RecipeList
156+
title={`Uses (${data.consumedBy.length})`}
157+
cards={data.consumedBy}
158+
focus={data.name}
159+
emptyText="no consumers"
160+
query={recipeQuery}
161+
onClearQuery={() => setRecipeQuery("")}
162+
onPick={onPick}
163+
variant="comfortable"
164+
/>
165+
)
166+
) : (
167+
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-2">
168+
<RecipeList
169+
title={`Produced by (${data.producedBy.length})`}
170+
cards={data.producedBy}
171+
focus={data.name}
172+
emptyText="nothing makes this — a raw input"
173+
query={recipeQuery}
174+
onClearQuery={() => setRecipeQuery("")}
175+
onPick={onPick}
176+
/>
177+
<RecipeList
178+
title={`Consumed by (${data.consumedBy.length})`}
179+
cards={data.consumedBy}
180+
focus={data.name}
181+
emptyText="no consumers"
182+
query={recipeQuery}
183+
onClearQuery={() => setRecipeQuery("")}
184+
onPick={onPick}
185+
/>
186+
</div>
187+
)}
188+
</>
189+
)}
190+
</div>
191+
);
192+
}
193+
194+
const fmtJ = (j: number) =>
195+
j >= 1e9
196+
? `${(j / 1e9).toFixed(1)} GJ`
197+
: j >= 1e6
198+
? `${(j / 1e6).toFixed(1)} MJ`
199+
: `${(j / 1e3).toFixed(0)} kJ`;

app/src/components/browse/recipe-list.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function RecipeList({
2020
query,
2121
onClearQuery,
2222
onPick,
23+
variant = "dense",
2324
}: {
2425
title: string;
2526
cards: BrowseCard[];
@@ -30,6 +31,7 @@ export function RecipeList({
3031
query: string;
3132
onClearQuery: () => void;
3233
onPick: (name: string) => void;
34+
variant?: "dense" | "comfortable";
3335
}) {
3436
const [showAll, setShowAll] = useState(false);
3537
const filtered = useFilteredList(cards, query, {
@@ -52,14 +54,25 @@ export function RecipeList({
5254
.filter((g) => g.cards.length > 0);
5355

5456
return (
55-
<Card className="self-start">
56-
<CardHeader>
57-
<CardTitle className="normal-case">{title}</CardTitle>
58-
</CardHeader>
57+
<Card
58+
className={variant === "comfortable" ? "self-start border-0 bg-transparent" : "self-start"}
59+
>
60+
{variant === "dense" && (
61+
<CardHeader>
62+
<CardTitle className="normal-case">{title}</CardTitle>
63+
</CardHeader>
64+
)}
5965
{cards.length === 0 && <div className="px-3 pb-3 text-muted-foreground">{emptyText}</div>}
6066
{cards.length > 0 && filtered.length === 0 && (
6167
<FilterEmptyState className="px-3 pb-3" query={query} onClear={onClearQuery} />
6268
)}
69+
{variant === "comfortable" && filtered.length > 0 && (
70+
<div className="hidden grid-cols-[minmax(13rem,0.8fr)_minmax(0,1fr)_minmax(0,1fr)] gap-3 border border-border bg-muted/40 px-3 py-2 text-sm font-medium text-muted-foreground md:grid">
71+
<span>Recipe</span>
72+
<span>Inputs</span>
73+
<span>Outputs</span>
74+
</div>
75+
)}
6376
{visible.map((g) => (
6477
<div key={g.id}>
6578
<Tooltip content={g.hint}>
@@ -71,7 +84,14 @@ export function RecipeList({
7184
</div>
7285
</Tooltip>
7386
{g.cards.map((c) => (
74-
<RecipeRow key={c.name} card={c} focus={focus} maxFlow={maxFlow} onPick={onPick} />
87+
<RecipeRow
88+
key={c.name}
89+
card={c}
90+
focus={focus}
91+
maxFlow={maxFlow}
92+
onPick={onPick}
93+
variant={variant}
94+
/>
7595
))}
7696
</div>
7797
))}

0 commit comments

Comments
 (0)