Skip to content

Commit 175f408

Browse files
committed
feat(stacks): AIC-133 — dedicated stack pages /stacks/[stackId]
1 parent 859c03a commit 175f408

5 files changed

Lines changed: 354 additions & 34 deletions

File tree

app/sitemap.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { MetadataRoute } from "next";
22
import { getTools } from "@/lib/data/tools";
33
import { getRelationships } from "@/lib/data/relationships";
4+
import { getStacks } from "@/lib/data/stacks";
45

56
const BASE = "https://aichitect.dev";
67

78
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
89
const now = new Date();
9-
const [tools, relationships] = await Promise.all([getTools(), getRelationships()]);
10+
const [tools, relationships, stacks] = await Promise.all([
11+
getTools(),
12+
getRelationships(),
13+
getStacks(),
14+
]);
1015

1116
// Core pages
1217
const core: MetadataRoute.Sitemap = [
@@ -36,5 +41,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
3641
lastModified: now,
3742
}));
3843

39-
return [...core, ...toolPages, ...comparisonPages];
44+
// Dedicated stack pages — one per curated stack
45+
const stackPages: MetadataRoute.Sitemap = stacks.map((s) => ({
46+
url: `${BASE}/stacks/${s.id}`,
47+
priority: 0.8,
48+
changeFrequency: "weekly" as const,
49+
lastModified: now,
50+
}));
51+
52+
return [...core, ...toolPages, ...stackPages, ...comparisonPages];
4053
}

app/stacks/StacksClient.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,18 @@ function StackGraph({ stack, allTools }: { stack: Stack; allTools: Tool[] }) {
7272
);
7373
}
7474

75-
function StacksContent({ stacks, allTools }: { stacks: Stack[]; allTools: Tool[] }) {
75+
function StacksContent({
76+
stacks,
77+
allTools,
78+
initialStackId,
79+
}: {
80+
stacks: Stack[];
81+
allTools: Tool[];
82+
initialStackId?: string;
83+
}) {
7684
const router = useRouter();
7785
const searchParams = useSearchParams();
78-
const stackId = searchParams.get("stack");
86+
const stackId = searchParams.get("stack") ?? initialStackId ?? null;
7987
const clusterParam = (searchParams.get("cluster") as StackCluster) ?? "build";
8088

8189
// Find by stack ID first — cluster is derivable from the stack itself
@@ -90,19 +98,13 @@ function StacksContent({ stacks, allTools }: { stacks: Stack[]; allTools: Tool[]
9098
const isMobile = useIsMobile();
9199

92100
function selectCluster(cluster: StackCluster) {
93-
const params = new URLSearchParams(searchParams.toString());
94-
params.set("cluster", cluster);
95-
params.delete("stack");
96-
router.push(`?${params.toString()}`, { scroll: false });
101+
router.push(`/stacks?cluster=${cluster}`, { scroll: false });
97102
setCompareA(null);
98103
setCompareB(null);
99104
}
100105

101106
function selectStack(s: Stack) {
102-
const params = new URLSearchParams(searchParams.toString());
103-
params.set("stack", s.id);
104-
params.delete("cluster"); // cluster is derived from the stack
105-
router.push(`?${params.toString()}`, { scroll: false });
107+
router.push(`/stacks/${s.id}`, { scroll: false });
106108
setCompareA(null);
107109
setCompareB(null);
108110
}
@@ -348,10 +350,18 @@ function StacksContent({ stacks, allTools }: { stacks: Stack[]; allTools: Tool[]
348350
);
349351
}
350352

351-
export default function StacksClient({ stacks, tools }: { stacks: Stack[]; tools: Tool[] }) {
353+
export default function StacksClient({
354+
stacks,
355+
tools,
356+
initialStackId,
357+
}: {
358+
stacks: Stack[];
359+
tools: Tool[];
360+
initialStackId?: string;
361+
}) {
352362
return (
353363
<Suspense>
354-
<StacksContent stacks={stacks} allTools={tools} />
364+
<StacksContent stacks={stacks} allTools={tools} initialStackId={initialStackId} />
355365
</Suspense>
356366
);
357367
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { ImageResponse } from "next/og";
2+
import stacksData from "@/data/stacks.json";
3+
import toolsData from "@/data/tools.json";
4+
import { getCategoryColor, STACK_CLUSTERS } from "@/lib/types";
5+
import type { Stack, Tool } from "@/lib/types";
6+
7+
export const runtime = "edge";
8+
export const size = { width: 1200, height: 630 };
9+
export const contentType = "image/png";
10+
11+
const CLUSTER_COLOR: Record<string, string> = {
12+
build: "#7c6bff",
13+
automate: "#ff6b6b",
14+
ship: "#26de81",
15+
comply: "#4ecdc4",
16+
understand: "#fdcb6e",
17+
};
18+
19+
export default async function Image({ params }: { params: Promise<{ stackId: string }> }) {
20+
const { stackId } = await params;
21+
const stack = (stacksData as Stack[]).find((s) => s.id === stackId);
22+
23+
if (!stack) {
24+
return new ImageResponse(
25+
<div
26+
style={{
27+
width: 1200,
28+
height: 630,
29+
background: "#0a0a0f",
30+
display: "flex",
31+
alignItems: "center",
32+
justifyContent: "center",
33+
fontFamily: "Inter, sans-serif",
34+
}}
35+
>
36+
<span style={{ fontSize: 32, color: "#44446a" }}>AIchitect — AI Stack Directory</span>
37+
</div>,
38+
{ ...size }
39+
);
40+
}
41+
42+
const clusterMeta = STACK_CLUSTERS.find((c) => c.id === stack.cluster);
43+
const clusterLabel = clusterMeta?.label ?? stack.cluster;
44+
const accent = CLUSTER_COLOR[stack.cluster] ?? "#7c6bff";
45+
46+
const tools = stack.tools
47+
.map((id) => (toolsData as Tool[]).find((t) => t.id === id))
48+
.filter((t): t is Tool => Boolean(t))
49+
.slice(0, 6);
50+
51+
return new ImageResponse(
52+
<div
53+
style={{
54+
width: 1200,
55+
height: 630,
56+
background: "#0a0a0f",
57+
display: "flex",
58+
flexDirection: "column",
59+
fontFamily: "Inter, sans-serif",
60+
position: "relative",
61+
overflow: "hidden",
62+
}}
63+
>
64+
{/* Atmospheric gradients */}
65+
<div
66+
style={{
67+
position: "absolute",
68+
top: 0,
69+
right: 0,
70+
bottom: 0,
71+
left: 0,
72+
backgroundImage: `radial-gradient(ellipse 60% 55% at -5% -5%, ${accent}1a 0%, transparent 60%)`,
73+
}}
74+
/>
75+
<div
76+
style={{
77+
position: "absolute",
78+
top: 0,
79+
right: 0,
80+
bottom: 0,
81+
left: 0,
82+
backgroundImage: `radial-gradient(ellipse 45% 40% at 105% 110%, ${accent}0e 0%, transparent 55%)`,
83+
}}
84+
/>
85+
86+
<div
87+
style={{
88+
display: "flex",
89+
flexDirection: "column",
90+
justifyContent: "space-between",
91+
padding: "44px 56px",
92+
flex: 1,
93+
position: "relative",
94+
}}
95+
>
96+
{/* Top */}
97+
<div style={{ display: "flex", flexDirection: "column" }}>
98+
{/* Brand + cluster badge */}
99+
<div
100+
style={{
101+
display: "flex",
102+
alignItems: "center",
103+
justifyContent: "space-between",
104+
marginBottom: 20,
105+
}}
106+
>
107+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
108+
<div
109+
style={{
110+
width: 40,
111+
height: 40,
112+
borderRadius: 10,
113+
background: "linear-gradient(135deg, #7c6bff, #00d4aa)",
114+
display: "flex",
115+
alignItems: "center",
116+
justifyContent: "center",
117+
fontSize: 22,
118+
fontWeight: 800,
119+
color: "#fff",
120+
}}
121+
>
122+
A
123+
</div>
124+
<span
125+
style={{ fontSize: 26, fontWeight: 700, color: "#e8e8f4", letterSpacing: -0.5 }}
126+
>
127+
AIchitect
128+
</span>
129+
</div>
130+
131+
<div
132+
style={{
133+
display: "flex",
134+
alignItems: "center",
135+
gap: 8,
136+
padding: "8px 20px",
137+
borderRadius: 999,
138+
background: accent + "14",
139+
borderWidth: 1,
140+
borderStyle: "solid",
141+
borderColor: accent + "44",
142+
}}
143+
>
144+
<div
145+
style={{
146+
width: 8,
147+
height: 8,
148+
borderRadius: "50%",
149+
background: accent,
150+
flexShrink: 0,
151+
}}
152+
/>
153+
<span style={{ fontSize: 15, fontWeight: 600, color: accent, letterSpacing: 0.3 }}>
154+
{clusterLabel}
155+
</span>
156+
</div>
157+
</div>
158+
159+
{/* Accent bar */}
160+
<div
161+
style={{
162+
height: 2,
163+
borderRadius: 2,
164+
background: `linear-gradient(to right, ${accent}cc, ${accent}44, transparent)`,
165+
marginBottom: 20,
166+
}}
167+
/>
168+
169+
{/* Stack name */}
170+
<div
171+
style={{
172+
fontSize: 50,
173+
fontWeight: 800,
174+
color: "#f0f0f8",
175+
letterSpacing: -1.5,
176+
lineHeight: 1.05,
177+
marginBottom: 10,
178+
}}
179+
>
180+
{stack.name}
181+
</div>
182+
183+
{/* Target audience */}
184+
<div style={{ fontSize: 18, color: "#44446a", letterSpacing: 0.1 }}>{stack.target}</div>
185+
</div>
186+
187+
{/* Tool pills */}
188+
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
189+
{tools.map((t) => {
190+
const c = getCategoryColor(t.category);
191+
return (
192+
<div
193+
key={t.id}
194+
style={{
195+
display: "flex",
196+
alignItems: "center",
197+
gap: 8,
198+
padding: "8px 16px",
199+
borderRadius: 8,
200+
background: c + "10",
201+
borderWidth: 1,
202+
borderStyle: "solid",
203+
borderColor: c + "30",
204+
}}
205+
>
206+
<div
207+
style={{
208+
width: 7,
209+
height: 7,
210+
borderRadius: "50%",
211+
background: c,
212+
flexShrink: 0,
213+
}}
214+
/>
215+
<span style={{ fontSize: 16, fontWeight: 600, color: "#d0d0e8" }}>{t.name}</span>
216+
</div>
217+
);
218+
})}
219+
{stack.tools.length > 6 && (
220+
<div
221+
style={{
222+
display: "flex",
223+
alignItems: "center",
224+
padding: "8px 16px",
225+
borderRadius: 8,
226+
background: "#ffffff08",
227+
borderWidth: 1,
228+
borderStyle: "solid",
229+
borderColor: "#ffffff14",
230+
}}
231+
>
232+
<span style={{ fontSize: 14, fontWeight: 500, color: "#555577" }}>
233+
+{stack.tools.length - 6} more
234+
</span>
235+
</div>
236+
)}
237+
</div>
238+
239+
{/* Footer */}
240+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
241+
<span style={{ fontSize: 14, color: "#2a2a44" }}>aichitect.dev/stacks/{stackId}</span>
242+
<span style={{ fontSize: 14, color: "#2a2a44" }}>cut the noise. pick your AI stack.</span>
243+
</div>
244+
</div>
245+
</div>,
246+
{ ...size }
247+
);
248+
}

0 commit comments

Comments
 (0)