Skip to content

Commit d309e1a

Browse files
committed
feat: global Checked Repos via Vercel KV — add @vercel/kv, watchlist API routes, update page to fetch from server
1 parent 583aca0 commit d309e1a

3 files changed

Lines changed: 141 additions & 75 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { kv } from "@vercel/kv";
2+
import { NextResponse } from "next/server";
3+
4+
const KEY = "devlens:watchlist";
5+
const MAX = 100; // keep latest 100 entries
6+
7+
export interface WatchEntry {
8+
slug: string;
9+
score: number;
10+
description: string | null;
11+
language: string | null;
12+
savedAt: string;
13+
}
14+
15+
export async function GET() {
16+
try {
17+
const list = await kv.lrange<WatchEntry>(KEY, 0, MAX - 1);
18+
return NextResponse.json({ list: list ?? [] });
19+
} catch (err) {
20+
console.error("watchlist GET error", err);
21+
return NextResponse.json({ list: [] });
22+
}
23+
}
24+
25+
export async function POST(req: Request) {
26+
try {
27+
const entry: WatchEntry = await req.json();
28+
if (!entry?.slug) return NextResponse.json({ ok: false }, { status: 400 });
29+
30+
// Remove existing entry for same slug to avoid duplicates
31+
const existing = await kv.lrange<WatchEntry>(KEY, 0, MAX - 1);
32+
for (const item of existing ?? []) {
33+
if (item.slug === entry.slug) {
34+
await kv.lrem(KEY, 0, item);
35+
}
36+
}
37+
38+
// Push to front, trim to MAX
39+
await kv.lpush(KEY, entry);
40+
await kv.ltrim(KEY, 0, MAX - 1);
41+
42+
return NextResponse.json({ ok: true });
43+
} catch (err) {
44+
console.error("watchlist POST error", err);
45+
return NextResponse.json({ ok: false }, { status: 500 });
46+
}
47+
}

dashboard/app/page.tsx

Lines changed: 92 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,7 @@ import RepoCard from "@/components/RepoCard";
55
import TrendChart from "@/components/TrendChart";
66
import SnippetModal from "@/components/SnippetModal";
77
import type { RepoReport } from "@/lib/scorer";
8-
9-
interface WatchEntry { slug: string; score: number; description: string | null; language: string | null; savedAt: string; }
10-
11-
const STORAGE_KEY = "devlens_watchlist";
12-
13-
function loadWatchlist(): WatchEntry[] {
14-
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); } catch { return []; }
15-
}
16-
function saveWatchlist(list: WatchEntry[]) {
17-
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
18-
}
8+
import type { WatchEntry } from "@/app/api/watchlist/route";
199

2010
export default function Home() {
2111
const [input, setInput] = useState("");
@@ -25,12 +15,20 @@ export default function Home() {
2515
const [error, setError] = useState("");
2616
const [showSnippet, setShowSnippet] = useState(false);
2717
const [watchlist, setWatchlist] = useState<WatchEntry[]>([]);
18+
const [watchLoading, setWatchLoading] = useState(true);
2819

29-
useEffect(() => { setWatchlist(loadWatchlist()); }, []);
20+
// Load global watchlist on mount
21+
useEffect(() => {
22+
fetch("/api/watchlist")
23+
.then(r => r.json())
24+
.then(d => setWatchlist(d.list ?? []))
25+
.catch(() => {})
26+
.finally(() => setWatchLoading(false));
27+
}, []);
3028

3129
async function analyze(e: React.FormEvent | null, slug?: string) {
3230
if (e) e.preventDefault();
33-
const target = slug ?? input.trim().replace("https://github.com/","").replace(/\/+$/,"");
31+
const target = slug ?? input.trim().replace("https://github.com/", "").replace(/\/+$/, "");
3432
if (!target) return;
3533
setLoading(true); setError(""); setReport(null); setHistory([]);
3634
try {
@@ -43,18 +41,32 @@ export default function Home() {
4341
setReport(data);
4442
const hData = await histRes.json();
4543
if (histRes.ok) setHistory(hData.history ?? []);
46-
const entry: WatchEntry = { slug: target, score: data.health_score, description: data.description, language: data.language, savedAt: new Date().toISOString() };
47-
const updated = [entry, ...loadWatchlist().filter(w => w.slug !== target)];
48-
saveWatchlist(updated);
49-
setWatchlist(updated);
44+
45+
// Save to global KV watchlist
46+
const entry: WatchEntry = {
47+
slug: target,
48+
score: data.health_score,
49+
description: data.description,
50+
language: data.language,
51+
savedAt: new Date().toISOString(),
52+
};
53+
await fetch("/api/watchlist", {
54+
method: "POST",
55+
headers: { "Content-Type": "application/json" },
56+
body: JSON.stringify(entry),
57+
});
58+
// Refresh watchlist from server
59+
const wRes = await fetch("/api/watchlist");
60+
const wData = await wRes.json();
61+
setWatchlist(wData.list ?? []);
5062
} catch { setError("Network error. Please try again."); }
5163
finally { setLoading(false); }
5264
}
5365

54-
function removeFromWatchlist(slug: string) {
55-
const updated = loadWatchlist().filter(w => w.slug !== slug);
56-
saveWatchlist(updated);
57-
setWatchlist(updated);
66+
async function removeFromWatchlist(slug: string) {
67+
// Optimistic UI update
68+
setWatchlist(prev => prev.filter(w => w.slug !== slug));
69+
await fetch(`/api/watchlist?slug=${encodeURIComponent(slug)}`, { method: "DELETE" });
5870
}
5971

6072
const scoreColor = (s: number) => s >= 80 ? "var(--success)" : s >= 50 ? "var(--warning)" : "var(--danger)";
@@ -64,77 +76,83 @@ export default function Home() {
6476
{showSnippet && report && <SnippetModal repo={report.repo} onClose={() => setShowSnippet(false)} />}
6577

6678
{/* Hero */}
67-
<section style={{ padding:"clamp(3rem,8vw,6rem) var(--space-6)",textAlign:"center" }}>
68-
<div style={{ maxWidth:"680px",margin:"0 auto",display:"flex",flexDirection:"column",gap:"var(--space-6)" }}>
69-
<div style={{ display:"flex",flexDirection:"column",gap:"var(--space-3)" }}>
70-
<h1 style={{ fontFamily:"var(--font-display)",fontSize:"var(--text-2xl)",fontWeight:800,color:"var(--text)",lineHeight:1.1 }}>
71-
Repo Health.<br/><span style={{ color:"var(--primary)" }}>In 7 Dimensions.</span>
79+
<section style={{ padding: "clamp(3rem,8vw,6rem) var(--space-6)", textAlign: "center" }}>
80+
<div style={{ maxWidth: "680px", margin: "0 auto", display: "flex", flexDirection: "column", gap: "var(--space-6)" }}>
81+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
82+
<h1 style={{ fontFamily: "var(--font-display)", fontSize: "var(--text-2xl)", fontWeight: 800, color: "var(--text)", lineHeight: 1.1 }}>
83+
Repo Health.<br /><span style={{ color: "var(--primary)" }}>In 7 Dimensions.</span>
7284
</h1>
73-
<p style={{ fontSize:"var(--text-base)",color:"var(--text-muted)",maxWidth:"480px",margin:"0 auto" }}>Paste any public GitHub repo URL and get a live health score — free, forever.</p>
85+
<p style={{ fontSize: "var(--text-base)", color: "var(--text-muted)", maxWidth: "480px", margin: "0 auto" }}>Paste any public GitHub repo URL and get a live health score — free, forever.</p>
7486
</div>
75-
<form onSubmit={(e) => analyze(e)} style={{ display:"flex",gap:"var(--space-2)",flexWrap:"wrap",justifyContent:"center" }}>
76-
<div style={{ position:"relative",flex:"1 1 320px",maxWidth:"460px" }}>
77-
<Search size={16} style={{ position:"absolute",left:"var(--space-3)",top:"50%",transform:"translateY(-50%)",color:"var(--text-faint)",pointerEvents:"none" }}/>
78-
<input value={input} onChange={e=>setInput(e.target.value)} placeholder="owner/repo or github.com/owner/repo" aria-label="GitHub repository"
79-
style={{ width:"100%",padding:"var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + 24px)",border:"1px solid var(--border)",borderRadius:"var(--radius-lg)",background:"var(--surface-2)",color:"var(--text)",fontSize:"var(--text-sm)",outline:"none" }}/>
87+
<form onSubmit={(e) => analyze(e)} style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", justifyContent: "center" }}>
88+
<div style={{ position: "relative", flex: "1 1 320px", maxWidth: "460px" }}>
89+
<Search size={16} style={{ position: "absolute", left: "var(--space-3)", top: "50%", transform: "translateY(-50%)", color: "var(--text-faint)", pointerEvents: "none" }} />
90+
<input value={input} onChange={e => setInput(e.target.value)} placeholder="owner/repo or github.com/owner/repo" aria-label="GitHub repository"
91+
style={{ width: "100%", padding: "var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + 24px)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", background: "var(--surface-2)", color: "var(--text)", fontSize: "var(--text-sm)", outline: "none" }} />
8092
</div>
81-
<button type="submit" disabled={loading} style={{ padding:"var(--space-3) var(--space-6)",background:loading?"var(--primary-hl)":"var(--primary)",color:"white",borderRadius:"var(--radius-lg)",fontWeight:600,fontSize:"var(--text-sm)",display:"flex",alignItems:"center",gap:"var(--space-2)",flexShrink:0 }}>
82-
{loading ? <><Loader2 size={16} style={{ animation:"spin 1s linear infinite" }}/> Analyzing…</> : <><ArrowRight size={16}/> Analyze</>}
93+
<button type="submit" disabled={loading} style={{ padding: "var(--space-3) var(--space-6)", background: loading ? "var(--primary-hl)" : "var(--primary)", color: "white", borderRadius: "var(--radius-lg)", fontWeight: 600, fontSize: "var(--text-sm)", display: "flex", alignItems: "center", gap: "var(--space-2)", flexShrink: 0 }}>
94+
{loading ? <><Loader2 size={16} style={{ animation: "spin 1s linear infinite" }} /> Analyzing…</> : <><ArrowRight size={16} /> Analyze</>}
8395
</button>
8496
</form>
85-
{error && <p style={{ color:"var(--error)",fontSize:"var(--text-sm)" }}>{error}</p>}
97+
{error && <p style={{ color: "var(--error)", fontSize: "var(--text-sm)" }}>{error}</p>}
8698
</div>
8799
</section>
88100

89101
{/* Result */}
90102
{report && (
91-
<section style={{ padding:"0 var(--space-6) var(--space-16)",maxWidth:"780px",margin:"0 auto",display:"flex",flexDirection:"column",gap:"var(--space-6)" }}>
92-
<RepoCard report={report} onSnippet={()=>setShowSnippet(true)}/>
93-
{history.length>0 && <TrendChart data={history}/>}
103+
<section style={{ padding: "0 var(--space-6) var(--space-16)", maxWidth: "780px", margin: "0 auto", display: "flex", flexDirection: "column", gap: "var(--space-6)" }}>
104+
<RepoCard report={report} onSnippet={() => setShowSnippet(true)} />
105+
{history.length > 0 && <TrendChart data={history} />}
94106
</section>
95107
)}
96108

97-
{/* Checked Repos watchlist */}
98-
{watchlist.length > 0 && (
99-
<section style={{ padding:"0 var(--space-6) var(--space-16)",maxWidth:"780px",margin:"0 auto",display:"flex",flexDirection:"column",gap:"var(--space-4)" }}>
100-
<div style={{ display:"flex",alignItems:"center",gap:"var(--space-2)" }}>
101-
<Bookmark size={16} style={{ color:"var(--primary)" }}/>
102-
<h2 style={{ fontFamily:"var(--font-display)",fontSize:"var(--text-base)",fontWeight:800 }}>Checked Repos</h2>
103-
<span style={{ fontSize:"var(--text-xs)",color:"var(--text-faint)",marginLeft:"auto" }}>{watchlist.length} saved</span>
109+
{/* Checked Repos — global, visible to all visitors */}
110+
{(watchlist.length > 0 || watchLoading) && (
111+
<section style={{ padding: "0 var(--space-6) var(--space-16)", maxWidth: "780px", margin: "0 auto", display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
112+
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
113+
<Bookmark size={16} style={{ color: "var(--primary)" }} />
114+
<h2 style={{ fontFamily: "var(--font-display)", fontSize: "var(--text-base)", fontWeight: 800 }}>Checked Repos</h2>
115+
{!watchLoading && <span style={{ fontSize: "var(--text-xs)", color: "var(--text-faint)", marginLeft: "auto" }}>{watchlist.length} repos</span>}
104116
</div>
105-
<div style={{ display:"flex",flexDirection:"column",gap:"var(--space-2)" }}>
106-
{watchlist.map(w => (
107-
<div key={w.slug} style={{ display:"flex",alignItems:"center",gap:"var(--space-3)",background:"var(--surface-off)",border:"1px solid var(--border)",borderRadius:"var(--radius-lg)",padding:"var(--space-3) var(--space-4)",cursor:"pointer",transition:"background .15s" }}
108-
onClick={() => { setInput(w.slug); analyze(null, w.slug); }}>
109-
<span style={{ fontWeight:800,fontSize:"var(--text-sm)",color:scoreColor(w.score),minWidth:"36px",textAlign:"center" }}>{w.score}</span>
110-
<div style={{ flex:1,overflow:"hidden" }}>
111-
<p style={{ fontWeight:600,fontSize:"var(--text-sm)",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis" }}>{w.slug}</p>
112-
{w.description && <p style={{ fontSize:"var(--text-xs)",color:"var(--text-muted)",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis" }}>{w.description}</p>}
117+
{watchLoading ? (
118+
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", color: "var(--text-faint)", fontSize: "var(--text-sm)" }}>
119+
<Loader2 size={14} style={{ animation: "spin 1s linear infinite" }} /> Loading…
120+
</div>
121+
) : (
122+
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
123+
{watchlist.map(w => (
124+
<div key={w.slug} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", background: "var(--surface-off)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: "var(--space-3) var(--space-4)", cursor: "pointer", transition: "background .15s" }}
125+
onClick={() => { setInput(w.slug); analyze(null, w.slug); }}>
126+
<span style={{ fontWeight: 800, fontSize: "var(--text-sm)", color: scoreColor(w.score), minWidth: "36px", textAlign: "center" }}>{w.score}</span>
127+
<div style={{ flex: 1, overflow: "hidden" }}>
128+
<p style={{ fontWeight: 600, fontSize: "var(--text-sm)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.slug}</p>
129+
{w.description && <p style={{ fontSize: "var(--text-xs)", color: "var(--text-muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.description}</p>}
130+
</div>
131+
{w.language && <span style={{ fontSize: "var(--text-xs)", color: "var(--text-faint)", flexShrink: 0 }}>{w.language}</span>}
132+
<a href={`/repo/${w.slug}`} onClick={e => e.stopPropagation()} target="_blank" rel="noopener noreferrer"
133+
style={{ color: "var(--text-faint)", flexShrink: 0, display: "flex" }} title="Open permalink">
134+
<ExternalLink size={14} />
135+
</a>
136+
<button onClick={e => { e.stopPropagation(); removeFromWatchlist(w.slug); }}
137+
style={{ color: "var(--text-faint)", flexShrink: 0, display: "flex", background: "none", border: "none", cursor: "pointer", padding: 0 }} title="Remove">
138+
<X size={14} />
139+
</button>
113140
</div>
114-
{w.language && <span style={{ fontSize:"var(--text-xs)",color:"var(--text-faint)",flexShrink:0 }}>{w.language}</span>}
115-
<a href={`/repo/${w.slug}`} onClick={e=>e.stopPropagation()} target="_blank" rel="noopener noreferrer"
116-
style={{ color:"var(--text-faint)",flexShrink:0,display:"flex" }} title="Open permalink">
117-
<ExternalLink size={14}/>
118-
</a>
119-
<button onClick={e=>{ e.stopPropagation(); removeFromWatchlist(w.slug); }}
120-
style={{ color:"var(--text-faint)",flexShrink:0,display:"flex",background:"none",border:"none",cursor:"pointer",padding:0 }} title="Remove">
121-
<X size={14}/>
122-
</button>
123-
</div>
124-
))}
125-
</div>
141+
))}
142+
</div>
143+
)}
126144
</section>
127145
)}
128146

129147
{/* Feature grid (empty state) */}
130-
{!report && !loading && watchlist.length === 0 && (
131-
<section style={{ padding:"0 var(--space-6) var(--space-16)",maxWidth:"960px",margin:"0 auto" }}>
132-
<div style={{ display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(min(260px,100%), 1fr))",gap:"var(--space-4)" }}>
133-
{[{emoji:"📝",title:"README Quality",desc:"Length, sections, badges, code blocks"},{emoji:"🔥",title:"Commit Activity",desc:"Push frequency over last 90 days"},{emoji:"🌿",title:"Repo Freshness",desc:"Days since last push to main"},{emoji:"📚",title:"Documentation",desc:"LICENSE, CONTRIBUTING, CHANGELOG, SECURITY"},{emoji:"⚙️",title:"CI/CD Setup",desc:"GitHub Actions workflows detected"},{emoji:"🎯",title:"Issue Response",desc:"Closed vs open issue ratio"},{emoji:"⭐",title:"Community Signal",desc:"Stars, forks, watchers momentum"}].map(f=>(
134-
<div key={f.title} style={{ background:"var(--surface)",border:"1px solid var(--border)",borderRadius:"var(--radius-lg)",padding:"var(--space-5)",display:"flex",flexDirection:"column",gap:"var(--space-2)" }}>
135-
<span style={{ fontSize:"1.5rem" }}>{f.emoji}</span>
136-
<span style={{ fontWeight:700,fontSize:"var(--text-sm)" }}>{f.title}</span>
137-
<span style={{ fontSize:"var(--text-xs)",color:"var(--text-muted)" }}>{f.desc}</span>
148+
{!report && !loading && watchlist.length === 0 && !watchLoading && (
149+
<section style={{ padding: "0 var(--space-6) var(--space-16)", maxWidth: "960px", margin: "0 auto" }}>
150+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(min(260px,100%), 1fr))", gap: "var(--space-4)" }}>
151+
{[{ emoji: "📝", title: "README Quality", desc: "Length, sections, badges, code blocks" }, { emoji: "🔥", title: "Commit Activity", desc: "Push frequency over last 90 days" }, { emoji: "🌿", title: "Repo Freshness", desc: "Days since last push to main" }, { emoji: "📚", title: "Documentation", desc: "LICENSE, CONTRIBUTING, CHANGELOG, SECURITY" }, { emoji: "⚙️", title: "CI/CD Setup", desc: "GitHub Actions workflows detected" }, { emoji: "🎯", title: "Issue Response", desc: "Closed vs open issue ratio" }, { emoji: "⭐", title: "Community Signal", desc: "Stars, forks, watchers momentum" }].map(f => (
152+
<div key={f.title} style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: "var(--space-5)", display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
153+
<span style={{ fontSize: "1.5rem" }}>{f.emoji}</span>
154+
<span style={{ fontWeight: 700, fontSize: "var(--text-sm)" }}>{f.title}</span>
155+
<span style={{ fontSize: "var(--text-xs)", color: "var(--text-muted)" }}>{f.desc}</span>
138156
</div>
139157
))}
140158
</div>
@@ -143,4 +161,4 @@ export default function Home() {
143161
<style>{`@keyframes spin{to{transform:rotate(360deg)}} input:focus{border-color:var(--primary)!important;box-shadow:0 0 0 3px var(--primary-hl)}`}</style>
144162
</>
145163
);
146-
}
164+
}

dashboard/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"lucide-react": "^0.479.0",
1818
"clsx": "^2.1.1",
1919
"@vercel/analytics": "^1.5.0",
20-
"@vercel/speed-insights": "^1.2.0"
20+
"@vercel/speed-insights": "^1.2.0",
21+
"@vercel/kv": "^3.0.0"
2122
},
2223
"devDependencies": {
2324
"@types/node": "^20",

0 commit comments

Comments
 (0)