Skip to content

Commit 54104b7

Browse files
shantanu patilclaude
authored andcommitted
feat: Landing page UX polish with flexible URL parsing and loading states
- Extract parseGitHubUrl() utility supporting full URLs, no-protocol, and user/repo shorthand with specific error messages - Add loading spinner and disabled state during navigation - Live feedback shows parsed repo path below input - Example repo pills fill input before navigating - Autofocus input on page load, aria labels for accessibility - 29 unit tests for URL parsing covering all formats and edge cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 193efd9 commit 54104b7

3 files changed

Lines changed: 357 additions & 29 deletions

File tree

diagrams/src/app/page.tsx

Lines changed: 102 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useState, useRef, useEffect, useCallback } from "react";
44
import { useRouter } from "next/navigation";
55
import { Input } from "~/components/ui/input";
66
import { Button } from "~/components/ui/button";
77
import { Card } from "~/components/ui/card";
88
import { exampleRepos } from "~/lib/exampleRepos";
9+
import { parseGitHubUrl, isParseError } from "~/lib/github-url";
910
import {
1011
FileText,
1112
GitBranch,
1213
MessageCircle,
1314
FolderTree,
1415
ArrowRight,
16+
Loader2,
1517
} from "lucide-react";
1618

1719
const features = [
@@ -44,29 +46,55 @@ const features = [
4446
export default function LandingPage() {
4547
const [repoUrl, setRepoUrl] = useState("");
4648
const [error, setError] = useState("");
49+
const [isNavigating, setIsNavigating] = useState(false);
50+
const [isFocused, setIsFocused] = useState(false);
51+
const inputRef = useRef<HTMLInputElement>(null);
4752
const router = useRouter();
4853

54+
// Autofocus the input on mount
55+
useEffect(() => {
56+
inputRef.current?.focus();
57+
}, []);
58+
59+
// Live-parse the input for preview feedback
60+
const parsed = repoUrl.trim() ? parseGitHubUrl(repoUrl) : null;
61+
const isValid = parsed !== null && !isParseError(parsed);
62+
63+
const navigateTo = useCallback(
64+
(username: string, repo: string) => {
65+
setIsNavigating(true);
66+
setError("");
67+
router.push(
68+
`/${encodeURIComponent(username)}/${encodeURIComponent(repo)}`,
69+
);
70+
},
71+
[router],
72+
);
73+
4974
const handleSubmit = (e: React.FormEvent) => {
5075
e.preventDefault();
76+
if (isNavigating) return;
5177
setError("");
5278

53-
const trimmed = repoUrl.trim();
54-
const githubUrlPattern =
55-
/^https?:\/\/github\.com\/([a-zA-Z0-9-_]+)\/([a-zA-Z0-9-_.]+)\/?$/;
56-
const match = githubUrlPattern.exec(trimmed);
57-
58-
if (!match?.[1] || !match?.[2]) {
59-
setError("Please enter a valid GitHub repository URL");
79+
const result = parseGitHubUrl(repoUrl);
80+
if (isParseError(result)) {
81+
setError(result.error);
6082
return;
6183
}
6284

63-
const username = encodeURIComponent(match[1]);
64-
const repo = encodeURIComponent(match[2]);
65-
router.push(`/${username}/${repo}`);
85+
navigateTo(result.username, result.repo);
6686
};
6787

68-
const handleExampleClick = (path: string) => {
69-
router.push(path);
88+
const handleExampleClick = (name: string, path: string) => {
89+
if (isNavigating) return;
90+
// Show the repo in the input first so users see what they're exploring
91+
setRepoUrl(`github.com${path}`);
92+
setError("");
93+
setIsNavigating(true);
94+
// Brief delay so the user sees the input fill before navigating
95+
setTimeout(() => {
96+
router.push(path);
97+
}, 150);
7098
};
7199

72100
return (
@@ -88,40 +116,88 @@ export default function LandingPage() {
88116
{/* Search Bar */}
89117
<form
90118
onSubmit={handleSubmit}
119+
aria-label="Repository search"
120+
aria-busy={isNavigating}
91121
className="mx-auto mt-8 max-w-2xl"
92122
>
93123
<div className="flex gap-3">
94124
<Input
95-
placeholder="https://github.com/username/repo"
125+
ref={inputRef}
126+
placeholder="Paste a GitHub URL or type user/repo"
127+
aria-label="GitHub repository URL"
96128
className="flex-1 rounded-lg border-stone-300 px-4 py-6 text-base font-medium placeholder:text-stone-400 focus:ring-2 focus:ring-cyan-500 sm:text-lg"
97129
value={repoUrl}
98-
onChange={(e) => setRepoUrl(e.target.value)}
99-
required
130+
onChange={(e) => {
131+
setRepoUrl(e.target.value);
132+
setError("");
133+
}}
134+
onFocus={() => setIsFocused(true)}
135+
onBlur={() => setIsFocused(false)}
136+
disabled={isNavigating}
100137
/>
101138
<Button
102139
type="submit"
103-
className="rounded-lg bg-cyan-600 px-6 py-6 text-base font-semibold text-white shadow-sm transition-colors hover:bg-cyan-700 sm:text-lg"
140+
disabled={isNavigating}
141+
aria-label="Explore repository"
142+
className="rounded-lg bg-cyan-600 px-6 py-6 text-base font-semibold text-white shadow-sm transition-colors hover:bg-cyan-700 disabled:opacity-70 sm:text-lg"
104143
>
105-
Explore
106-
<ArrowRight className="ml-2 h-5 w-5" />
144+
{isNavigating ? (
145+
<>
146+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
147+
Loading...
148+
</>
149+
) : (
150+
<>
151+
Explore
152+
<ArrowRight className="ml-2 h-5 w-5" />
153+
</>
154+
)}
107155
</Button>
108156
</div>
109-
{error && (
110-
<p className="mt-2 text-sm text-red-600">{error}</p>
111-
)}
157+
158+
{/* Feedback row: error, parsed preview, or keyboard hint */}
159+
<div className="mt-2 h-5 text-sm">
160+
{error ? (
161+
<p className="text-red-600" role="alert">
162+
{error}
163+
</p>
164+
) : isNavigating && isValid ? (
165+
<p className="text-cyan-600">
166+
Navigating to{" "}
167+
<span className="font-medium">
168+
{(parsed as { username: string; repo: string }).username}/
169+
{(parsed as { username: string; repo: string }).repo}
170+
</span>
171+
...
172+
</p>
173+
) : isValid ? (
174+
<p className="text-stone-400">
175+
<span className="font-medium text-stone-600">
176+
{(parsed as { username: string; repo: string }).username}/
177+
{(parsed as { username: string; repo: string }).repo}
178+
</span>
179+
{" "}— press Enter to explore
180+
</p>
181+
) : isFocused && !repoUrl.trim() ? (
182+
<p className="text-stone-400">
183+
Try pasting a GitHub URL or typing <span className="font-mono">user/repo</span>
184+
</p>
185+
) : null}
186+
</div>
112187
</form>
113188

114189
{/* Example Repos */}
115-
<div className="mt-6">
190+
<div className="mt-4">
116191
<p className="mb-3 text-sm text-stone-400">
117192
Try an example:
118193
</p>
119194
<div className="flex flex-wrap justify-center gap-2">
120195
{Object.entries(exampleRepos).map(([name, path]) => (
121196
<button
122197
key={name}
123-
onClick={() => handleExampleClick(path)}
124-
className="rounded-full border border-stone-200 bg-white px-4 py-1.5 text-sm font-medium text-stone-600 transition-colors hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700"
198+
onClick={() => handleExampleClick(name, path)}
199+
disabled={isNavigating}
200+
className="rounded-full border border-stone-200 bg-white px-4 py-1.5 text-sm font-medium text-stone-600 transition-colors hover:border-cyan-300 hover:bg-cyan-50 hover:text-cyan-700 disabled:opacity-50"
125201
>
126202
{name}
127203
</button>
@@ -173,10 +249,7 @@ export default function LandingPage() {
173249
</p>
174250
<Button
175251
onClick={() => {
176-
const input = document.querySelector<HTMLInputElement>(
177-
'input[placeholder*="github.com"]',
178-
);
179-
input?.focus();
252+
inputRef.current?.focus();
180253
window.scrollTo({ top: 0, behavior: "smooth" });
181254
}}
182255
className="mt-6 rounded-lg bg-cyan-600 px-8 py-6 text-base font-semibold text-white shadow-sm transition-colors hover:bg-cyan-700"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseGitHubUrl, isParseError } from "./github-url";
3+
4+
describe("parseGitHubUrl", () => {
5+
describe("valid full URLs", () => {
6+
it("parses https://github.com/user/repo", () => {
7+
const result = parseGitHubUrl("https://github.com/user/repo");
8+
expect(result).toEqual({ username: "user", repo: "repo" });
9+
});
10+
11+
it("parses http://github.com/user/repo", () => {
12+
const result = parseGitHubUrl("http://github.com/user/repo");
13+
expect(result).toEqual({ username: "user", repo: "repo" });
14+
});
15+
16+
it("parses URL with trailing slash", () => {
17+
const result = parseGitHubUrl("https://github.com/user/repo/");
18+
expect(result).toEqual({ username: "user", repo: "repo" });
19+
});
20+
21+
it("parses URL with www prefix", () => {
22+
const result = parseGitHubUrl("https://www.github.com/user/repo");
23+
expect(result).toEqual({ username: "user", repo: "repo" });
24+
});
25+
26+
it("strips .git suffix", () => {
27+
const result = parseGitHubUrl("https://github.com/user/repo.git");
28+
expect(result).toEqual({ username: "user", repo: "repo" });
29+
});
30+
31+
it("ignores subpaths (tree/main/src)", () => {
32+
const result = parseGitHubUrl("https://github.com/fastapi/fastapi/tree/main/src");
33+
expect(result).toEqual({ username: "fastapi", repo: "fastapi" });
34+
});
35+
36+
it("ignores query params", () => {
37+
const result = parseGitHubUrl("https://github.com/user/repo?tab=readme");
38+
expect(result).toEqual({ username: "user", repo: "repo" });
39+
});
40+
41+
it("ignores fragment", () => {
42+
const result = parseGitHubUrl("https://github.com/user/repo#readme");
43+
expect(result).toEqual({ username: "user", repo: "repo" });
44+
});
45+
46+
it("handles hyphens and dots in names", () => {
47+
const result = parseGitHubUrl("https://github.com/tom-draper/api-analytics");
48+
expect(result).toEqual({ username: "tom-draper", repo: "api-analytics" });
49+
});
50+
51+
it("handles underscores and dots in repo names", () => {
52+
const result = parseGitHubUrl("https://github.com/user/my_repo.js");
53+
expect(result).toEqual({ username: "user", repo: "my_repo.js" });
54+
});
55+
});
56+
57+
describe("no-protocol URLs", () => {
58+
it("parses github.com/user/repo", () => {
59+
const result = parseGitHubUrl("github.com/user/repo");
60+
expect(result).toEqual({ username: "user", repo: "repo" });
61+
});
62+
63+
it("parses www.github.com/user/repo", () => {
64+
const result = parseGitHubUrl("www.github.com/user/repo");
65+
expect(result).toEqual({ username: "user", repo: "repo" });
66+
});
67+
68+
it("parses github.com/user/repo with subpath", () => {
69+
const result = parseGitHubUrl("github.com/user/repo/pulls");
70+
expect(result).toEqual({ username: "user", repo: "repo" });
71+
});
72+
});
73+
74+
describe("shorthand format", () => {
75+
it("parses user/repo", () => {
76+
const result = parseGitHubUrl("user/repo");
77+
expect(result).toEqual({ username: "user", repo: "repo" });
78+
});
79+
80+
it("parses fastapi/fastapi", () => {
81+
const result = parseGitHubUrl("fastapi/fastapi");
82+
expect(result).toEqual({ username: "fastapi", repo: "fastapi" });
83+
});
84+
85+
it("parses user/repo with trailing slash", () => {
86+
const result = parseGitHubUrl("user/repo/");
87+
expect(result).toEqual({ username: "user", repo: "repo" });
88+
});
89+
90+
it("strips .git from shorthand", () => {
91+
const result = parseGitHubUrl("user/repo.git");
92+
expect(result).toEqual({ username: "user", repo: "repo" });
93+
});
94+
});
95+
96+
describe("whitespace handling", () => {
97+
it("trims leading/trailing whitespace", () => {
98+
const result = parseGitHubUrl(" https://github.com/user/repo ");
99+
expect(result).toEqual({ username: "user", repo: "repo" });
100+
});
101+
102+
it("trims shorthand whitespace", () => {
103+
const result = parseGitHubUrl(" user/repo ");
104+
expect(result).toEqual({ username: "user", repo: "repo" });
105+
});
106+
});
107+
108+
describe("error cases", () => {
109+
it("rejects empty string", () => {
110+
const result = parseGitHubUrl("");
111+
expect(isParseError(result)).toBe(true);
112+
if (isParseError(result)) {
113+
expect(result.error).toBe("Please enter a GitHub repository URL");
114+
}
115+
});
116+
117+
it("rejects whitespace-only string", () => {
118+
const result = parseGitHubUrl(" ");
119+
expect(isParseError(result)).toBe(true);
120+
if (isParseError(result)) {
121+
expect(result.error).toBe("Please enter a GitHub repository URL");
122+
}
123+
});
124+
125+
it("rejects GitLab URL", () => {
126+
const result = parseGitHubUrl("https://gitlab.com/user/repo");
127+
expect(isParseError(result)).toBe(true);
128+
if (isParseError(result)) {
129+
expect(result.error).toBe("Only GitHub repositories are supported");
130+
}
131+
});
132+
133+
it("rejects Bitbucket URL", () => {
134+
const result = parseGitHubUrl("https://bitbucket.org/user/repo");
135+
expect(isParseError(result)).toBe(true);
136+
if (isParseError(result)) {
137+
expect(result.error).toBe("Only GitHub repositories are supported");
138+
}
139+
});
140+
141+
it("rejects single word", () => {
142+
const result = parseGitHubUrl("justarepo");
143+
expect(isParseError(result)).toBe(true);
144+
});
145+
146+
it("rejects bare domain", () => {
147+
const result = parseGitHubUrl("github.com");
148+
expect(isParseError(result)).toBe(true);
149+
});
150+
151+
it("rejects github.com with only username", () => {
152+
const result = parseGitHubUrl("https://github.com/user");
153+
expect(isParseError(result)).toBe(true);
154+
});
155+
156+
it("rejects random URL", () => {
157+
const result = parseGitHubUrl("https://example.com/something");
158+
expect(isParseError(result)).toBe(true);
159+
});
160+
});
161+
162+
describe("isParseError helper", () => {
163+
it("returns true for error results", () => {
164+
expect(isParseError({ error: "test" })).toBe(true);
165+
});
166+
167+
it("returns false for success results", () => {
168+
expect(isParseError({ username: "u", repo: "r" })).toBe(false);
169+
});
170+
});
171+
});

0 commit comments

Comments
 (0)