Skip to content

Commit 89b9127

Browse files
authored
Merge pull request #55 from letsgogeeky/feat/AIC-85
feat(usage): AIC-85 — per-tool 'I use this' signal, badges, and profi…
2 parents bc373be + a27ff90 commit 89b9127

12 files changed

Lines changed: 610 additions & 25 deletions

File tree

app/badge/tool/[toolId]/route.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import toolsData from "@/data/tools.json";
3+
import type { Tool } from "@/lib/types";
4+
import { getCategoryColor } from "@/lib/types";
5+
6+
export const runtime = "edge";
7+
8+
export async function GET(_req: NextRequest, { params }: { params: Promise<{ toolId: string }> }) {
9+
const { toolId } = await params;
10+
const tool = (toolsData as Tool[]).find((t) => t.id === toolId);
11+
12+
if (!tool) {
13+
return new NextResponse("Not found", { status: 404 });
14+
}
15+
16+
const color = getCategoryColor(tool.category);
17+
const label = tool.name;
18+
const value = "on AIchitect";
19+
20+
// Approximate text widths (6.5px per char at 11px font)
21+
const labelW = label.length * 6.5 + 16;
22+
const valueW = value.length * 6.5 + 16;
23+
const totalW = Math.round(labelW + valueW);
24+
25+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${label}: ${value}">
26+
<title>${label}: ${value}</title>
27+
<defs>
28+
<linearGradient id="s" x1="0" x2="0" y1="0" y2="1">
29+
<stop offset="0" stop-color="#fff" stop-opacity=".1"/>
30+
<stop offset="1" stop-opacity=".1"/>
31+
</linearGradient>
32+
<clipPath id="r"><rect width="${totalW}" height="20" rx="3"/></clipPath>
33+
</defs>
34+
<g clip-path="url(#r)">
35+
<rect width="${Math.round(labelW)}" height="20" fill="${color}"/>
36+
<rect x="${Math.round(labelW)}" width="${Math.round(valueW)}" height="20" fill="#111118"/>
37+
<rect width="${totalW}" height="20" fill="url(#s)"/>
38+
</g>
39+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
40+
<text x="${Math.round(labelW / 2)}" y="15" fill="#000" fill-opacity=".2">${label}</text>
41+
<text x="${Math.round(labelW / 2)}" y="14">${label}</text>
42+
<text x="${Math.round(labelW + valueW / 2)}" y="15" fill="#000" fill-opacity=".2">${value}</text>
43+
<text x="${Math.round(labelW + valueW / 2)}" y="14" fill="#e0e0f0">${value}</text>
44+
</g>
45+
</svg>`;
46+
47+
return new NextResponse(svg, {
48+
headers: {
49+
"Content-Type": "image/svg+xml",
50+
"Cache-Control": "public, max-age=3600, s-maxage=3600",
51+
},
52+
});
53+
}

app/builder/components/BuilderSlotList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SLOT_AUTONOMY } from "@/lib/stackStory";
88
import StackHealthPanel from "@/components/panels/StackHealthPanel";
99
import Link from "next/link";
1010
import { SITE_URL } from "@/lib/constants";
11+
import { ToolUsageButton } from "@/components/ui/ToolUsageButton";
1112

1213
export function BuilderSlotList({
1314
slots,
@@ -206,6 +207,7 @@ export function BuilderSlotList({
206207
<span className="ml-auto text-[9px] text-[var(--success)]">OSS</span>
207208
)}
208209
</button>
210+
{active && <ToolUsageButton toolId={t.id} color={color} compact />}
209211
<button
210212
onClick={(e) => onCompareClick(t, e)}
211213
title={isCompareA ? "Staged for comparison" : `Compare ${t.name}`}

app/page.tsx

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Link from "next/link";
22
import Logo from "@/components/ui/Logo";
3+
import { LandingAuthButton } from "@/components/ui/LandingAuthButton";
34
import { GITHUB_URL } from "@/lib/constants";
45
import { FindMyStackButton } from "@/components/ui/StackQuizModal";
56
import { getCounts } from "@/lib/data/counts";
@@ -571,30 +572,33 @@ export default async function LandingPage() {
571572
<Logo size={28} id="hero-logo-g" />
572573
<span style={{ fontSize: 15, fontWeight: 600, color: "#f0f0f8" }}>AIchitect</span>
573574
</div>
574-
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
575-
{[
576-
{ href: "/stacks", label: "Stacks", Icon: IconLayers },
577-
{ href: "/explore", label: "Graph", Icon: IconNetwork },
578-
{ href: "/builder", label: "Builder", Icon: IconSettings2 },
579-
{ href: "/compare", label: "Compare", Icon: IconCompare },
580-
{ href: "/genome", label: "Genome", Icon: IconGenome },
581-
].map(({ href, label, Icon }) => (
582-
<Link
583-
key={href}
584-
href={href}
585-
className="flex items-center gap-[6px] rounded-[7px] text-[#8888aa] hover:text-[#f0f0f8] hover:bg-[#1c1c28] transition-colors"
586-
style={{
587-
padding: "0 10px",
588-
height: 34,
589-
fontSize: 12,
590-
fontWeight: 500,
591-
textDecoration: "none",
592-
}}
593-
>
594-
<Icon />
595-
{label}
596-
</Link>
597-
))}
575+
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
576+
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
577+
{[
578+
{ href: "/stacks", label: "Stacks", Icon: IconLayers },
579+
{ href: "/explore", label: "Graph", Icon: IconNetwork },
580+
{ href: "/builder", label: "Builder", Icon: IconSettings2 },
581+
{ href: "/compare", label: "Compare", Icon: IconCompare },
582+
{ href: "/genome", label: "Genome", Icon: IconGenome },
583+
].map(({ href, label, Icon }) => (
584+
<Link
585+
key={href}
586+
href={href}
587+
className="flex items-center gap-[6px] rounded-[7px] text-[#8888aa] hover:text-[#f0f0f8] hover:bg-[#1c1c28] transition-colors"
588+
style={{
589+
padding: "0 10px",
590+
height: 34,
591+
fontSize: 12,
592+
fontWeight: 500,
593+
textDecoration: "none",
594+
}}
595+
>
596+
<Icon />
597+
{label}
598+
</Link>
599+
))}
600+
</div>
601+
<LandingAuthButton />
598602
</div>
599603
</header>
600604

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import Link from "next/link";
5+
import { createSupabaseBrowserClient } from "@/lib/db";
6+
import toolsData from "@/data/tools.json";
7+
import { Tool, getCategoryColor } from "@/lib/types";
8+
import { SITE_URL } from "@/lib/constants";
9+
10+
const allTools = toolsData as Tool[];
11+
12+
interface ToolUsageRow {
13+
tool_id: string;
14+
avatar_url: string | null;
15+
used_at: string;
16+
}
17+
18+
interface Props {
19+
username: string;
20+
}
21+
22+
export default function ProfileClient({ username }: Props) {
23+
const [rows, setRows] = useState<ToolUsageRow[]>([]);
24+
const [loading, setLoading] = useState(createSupabaseBrowserClient() !== null);
25+
const [copiedAll, setCopiedAll] = useState(false);
26+
const [copiedId, setCopiedId] = useState<string | null>(null);
27+
28+
useEffect(() => {
29+
const supabase = createSupabaseBrowserClient();
30+
if (!supabase) return;
31+
supabase
32+
.from("tool_usage")
33+
.select("tool_id, avatar_url, used_at")
34+
.eq("github_username", username)
35+
.order("used_at", { ascending: false })
36+
.then(({ data }) => {
37+
setRows((data as ToolUsageRow[]) ?? []);
38+
setLoading(false);
39+
});
40+
}, [username]);
41+
42+
const tools = rows.map((r) => allTools.find((t) => t.id === r.tool_id)).filter(Boolean) as Tool[];
43+
44+
const avatarUrl = rows.find((r) => r.avatar_url)?.avatar_url ?? null;
45+
46+
function badgeUrl(toolId: string) {
47+
return `${SITE_URL}/badge/tool/${toolId}`;
48+
}
49+
50+
function badgeMarkdown(tool: Tool) {
51+
return `[![${tool.name}](${badgeUrl(tool.id)})](${SITE_URL}/explore)`;
52+
}
53+
54+
function copyBadge(tool: Tool) {
55+
navigator.clipboard.writeText(badgeMarkdown(tool)).then(() => {
56+
setCopiedId(tool.id);
57+
setTimeout(() => setCopiedId(null), 2000);
58+
});
59+
}
60+
61+
function copyAll() {
62+
const markdown = tools.map(badgeMarkdown).join("\n");
63+
navigator.clipboard.writeText(markdown).then(() => {
64+
setCopiedAll(true);
65+
setTimeout(() => setCopiedAll(false), 2000);
66+
});
67+
}
68+
69+
return (
70+
<div className="max-w-2xl mx-auto px-6 py-10">
71+
{/* Breadcrumb */}
72+
<nav
73+
className="flex items-center gap-1.5 text-[11px] mb-8"
74+
style={{ color: "var(--text-muted)" }}
75+
>
76+
<Link href="/" className="hover:underline">
77+
AIchitect
78+
</Link>
79+
<span>/</span>
80+
<span style={{ color: "var(--text-secondary)" }}>{username}</span>
81+
</nav>
82+
83+
{/* Header */}
84+
<div className="flex items-center gap-4 mb-8">
85+
{avatarUrl ? (
86+
// eslint-disable-next-line @next/next/no-img-element
87+
<img
88+
src={avatarUrl}
89+
alt={username}
90+
className="w-12 h-12 rounded-full"
91+
style={{ border: "2px solid var(--border)" }}
92+
/>
93+
) : (
94+
<div
95+
className="w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold"
96+
style={{
97+
background: "#7c6bff22",
98+
color: "var(--accent)",
99+
border: "2px solid #7c6bff44",
100+
}}
101+
>
102+
{username[0]?.toUpperCase()}
103+
</div>
104+
)}
105+
<div>
106+
<h1 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
107+
@{username}
108+
</h1>
109+
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
110+
AI tools I use
111+
</p>
112+
</div>
113+
</div>
114+
115+
{loading && (
116+
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
117+
Loading…
118+
</p>
119+
)}
120+
121+
{!loading && tools.length === 0 && (
122+
<div
123+
className="rounded-xl p-8 text-center"
124+
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
125+
>
126+
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
127+
No tools marked yet.
128+
</p>
129+
<Link
130+
href="/explore"
131+
className="inline-block mt-3 text-xs font-medium px-3 py-1.5 rounded-md"
132+
style={{
133+
background: "#7c6bff18",
134+
color: "var(--accent)",
135+
border: "1px solid #7c6bff44",
136+
}}
137+
>
138+
Explore tools →
139+
</Link>
140+
</div>
141+
)}
142+
143+
{!loading && tools.length > 0 && (
144+
<>
145+
{/* Badge wall */}
146+
<div className="flex flex-wrap gap-2 mb-6">
147+
{tools.map((tool) => {
148+
const color = getCategoryColor(tool.category);
149+
const isCopied = copiedId === tool.id;
150+
return (
151+
<button
152+
key={tool.id}
153+
onClick={() => copyBadge(tool)}
154+
title={isCopied ? "Copied!" : `Copy badge for ${tool.name}`}
155+
className="group relative"
156+
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
157+
>
158+
{/* eslint-disable-next-line @next/next/no-img-element */}
159+
<img
160+
src={badgeUrl(tool.id)}
161+
alt={`${tool.name} badge`}
162+
className="h-5 transition-opacity"
163+
style={{ opacity: isCopied ? 0.6 : 1 }}
164+
/>
165+
<span
166+
className="absolute inset-0 flex items-center justify-center text-[9px] font-semibold rounded opacity-0 group-hover:opacity-100 transition-opacity"
167+
style={{
168+
background: color + "dd",
169+
color: "#fff",
170+
fontSize: 9,
171+
}}
172+
>
173+
{isCopied ? "Copied!" : "Copy"}
174+
</span>
175+
</button>
176+
);
177+
})}
178+
</div>
179+
180+
{/* Copy all section */}
181+
<div
182+
className="rounded-xl p-4 space-y-3"
183+
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
184+
>
185+
<div>
186+
<p className="text-[11px] font-semibold" style={{ color: "var(--text-primary)" }}>
187+
Add to your GitHub README
188+
</p>
189+
<p className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>
190+
Paste all {tools.length} badge{tools.length !== 1 ? "s" : ""} as Markdown — each
191+
links back to AIchitect.
192+
</p>
193+
</div>
194+
<div
195+
className="rounded px-2 py-2 font-mono text-[9px] break-all leading-relaxed max-h-24 overflow-y-auto"
196+
style={{ background: "var(--surface-2)", color: "var(--text-muted)" }}
197+
>
198+
{tools.map(badgeMarkdown).join("\n")}
199+
</div>
200+
<button
201+
onClick={copyAll}
202+
className="w-full py-2 rounded text-[10px] font-semibold transition-all"
203+
style={{
204+
background: copiedAll ? "#26de8122" : "var(--accent)",
205+
color: copiedAll ? "var(--success)" : "#fff",
206+
border: copiedAll ? "1px solid #26de8144" : "none",
207+
}}
208+
>
209+
{copiedAll
210+
? "Copied!"
211+
: `Copy all ${tools.length} badge${tools.length !== 1 ? "s" : ""}`}
212+
</button>
213+
</div>
214+
</>
215+
)}
216+
</div>
217+
);
218+
}

app/profile/[username]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import ProfileClient from "./ProfileClient";
2+
3+
interface Props {
4+
params: Promise<{ username: string }>;
5+
}
6+
7+
export default async function ProfilePage({ params }: Props) {
8+
const { username } = await params;
9+
return <ProfileClient username={username} />;
10+
}

components/panels/ComparisonPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import toolsData from "@/data/tools.json";
77
import { healthColor, healthLabel, relLabel, relBadgeStyle } from "@/lib/health";
88
import { CloseButton } from "@/components/ui/CloseButton";
99
import { ColorDot } from "@/components/ui/ColorDot";
10+
import { ToolUsageButton } from "@/components/ui/ToolUsageButton";
1011
import {
1112
Row,
1213
CategoryPill,
@@ -102,13 +103,15 @@ export default function ComparisonPanel({ toolA, toolB, onClose, onSwap }: Compa
102103
<span className="text-sm font-semibold text-[var(--text-primary)] truncate">
103104
{toolA.name}
104105
</span>
106+
<ToolUsageButton toolId={toolA.id} color={colorA} compact />
105107
</div>
106108
<span className="text-[var(--text-muted)] text-xs flex-shrink-0">vs</span>
107109
<div className="flex items-center gap-1.5 min-w-0">
108110
<ColorDot color={colorB} />
109111
<span className="text-sm font-semibold text-[var(--text-primary)] truncate">
110112
{toolB.name}
111113
</span>
114+
<ToolUsageButton toolId={toolB.id} color={colorB} compact />
112115
</div>
113116
</div>
114117
</div>

0 commit comments

Comments
 (0)