Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
494 changes: 444 additions & 50 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
Expand All @@ -23,6 +25,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "npm:rolldown-vite@7.2.5"
Expand Down
6 changes: 0 additions & 6 deletions src/App.css

This file was deleted.

23 changes: 14 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import './App.css'

function App() {
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { LandingPage } from "./pages/LandingPage";
import { OrgDetailPage } from "./pages/OrgDetailPage";

export default function App() {
return (
<>
<h1>Hello, OrgExplorer!</h1>
</>
)
<BrowserRouter>
<div className="flex min-h-screen flex-col">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/org/:login" element={<OrgDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</BrowserRouter>
);
}

export default App
67 changes: 67 additions & 0 deletions src/api/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const API = "https://api.github.com";

export interface OrgSearchItem {
login: string;
id: number;
avatar_url: string;
html_url: string;
type: string;
}

export interface OrgSearchResult {
total_count: number;
incomplete_results: boolean;
items: OrgSearchItem[];
}

export interface GitHubOrg {
login: string;
id: number;
node_id: string;
url: string;
avatar_url: string;
html_url: string;
name: string | null;
company: string | null;
blog: string | null;
location: string | null;
email: string | null;
twitter_username: string | null;
description: string | null;
public_repos: number;
public_gists: number;
followers: number;
following: number;
created_at: string;
type: string;
}

async function parseError(res: Response): Promise<string> {
try {
const body = await res.json();
if (body && typeof body.message === "string") return body.message;
} catch {
/* ignore */
}
return res.statusText || `Request failed (${res.status})`;
}

export async function searchOrganizations(
query: string,
): Promise<OrgSearchResult> {
const q = `${query.trim()} in:login type:org`.replace(/\s+/g, " ");
const url = `${API}/search/users?q=${encodeURIComponent(q)}&per_page=20`;
const res = await fetch(url, {
headers: { Accept: "application/vnd.github+json" },
});
if (!res.ok) throw new Error(await parseError(res));
return res.json() as Promise<OrgSearchResult>;
}

export async function fetchOrganization(login: string): Promise<GitHubOrg> {
const res = await fetch(`${API}/orgs/${encodeURIComponent(login.trim())}`, {
headers: { Accept: "application/vnd.github+json" },
});
if (!res.ok) throw new Error(await parseError(res));
return res.json() as Promise<GitHubOrg>;
}
31 changes: 20 additions & 11 deletions src/index.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
@import "tailwindcss";

color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
}

html {
@apply antialiased;
}

font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
body {
@apply m-0 min-h-screen font-sans text-zinc-100;
background-color: #09090b;
background-image:
radial-gradient(ellipse 120% 80% at 50% -20%, rgb(16 185 129 / 0.12), transparent),
radial-gradient(ellipse 60% 50% at 100% 50%, rgb(59 130 246 / 0.06), transparent),
radial-gradient(ellipse 50% 40% at 0% 80%, rgb(16 185 129 / 0.05), transparent);
background-attachment: fixed;
}

#root {
@apply min-h-screen;
}
143 changes: 143 additions & 0 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import {
fetchOrganization,
searchOrganizations,
type OrgSearchItem,
} from "../api/github";

export function LandingPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<OrgSearchItem[] | null>(null);

async function handleSubmit(e: FormEvent) {
e.preventDefault();
const q = query.trim();
if (!q) {
setError("Enter an organization name or login.");
setResults(null);
return;
}

setLoading(true);
setError(null);
setResults(null);

try {
const data = await searchOrganizations(q);
if (data.items.length > 0) {
setResults(data.items);
if (data.items.length === 1) {
navigate(`/org/${data.items[0].login}`);
}
return;
}

await fetchOrganization(q);
navigate(`/org/${q}`);
Comment on lines +39 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

URL-encode the fallback navigation path.

When no search results are found, the raw trimmed query q is used directly in the navigation path. If the query contains special characters or spaces, this could cause routing issues.

🛡️ Proposed fix
       await fetchOrganization(q);
-      navigate(`/org/${q}`);
+      navigate(`/org/${encodeURIComponent(q)}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await fetchOrganization(q);
navigate(`/org/${q}`);
await fetchOrganization(q);
navigate(`/org/${encodeURIComponent(q)}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/LandingPage.tsx` around lines 39 - 40, The fallback navigation uses
the raw trimmed query q which can break routes for spaces/special chars; update
the code that calls navigate(`/org/${q}`) (after await fetchOrganization(q)) to
URL-encode the query using encodeURIComponent(q) so the path is safe (e.g.,
navigate(`/org/${encodeURIComponent(q)}`)); keep using the same q and
fetchOrganization call but ensure the encoded value is used only for the
navigation.

} catch (err) {
const message =
err instanceof Error ? err.message : "Something went wrong.";
setError(message);
} finally {
setLoading(false);
}
}
Comment on lines +16 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing request cancellation for overlapping searches.

The linked issue #47 explicitly requires "cancel overlapping requests so results remain current." Currently, if a user submits multiple searches quickly, stale responses could overwrite fresher results. Consider using AbortController to cancel in-flight requests when a new search begins.

🛠️ Suggested approach
+import { useRef } from "react";
+// ...
+
 export function LandingPage() {
   const navigate = useNavigate();
+  const abortRef = useRef<AbortController | null>(null);
   const [query, setQuery] = useState("");
   // ...
 
   async function handleSubmit(e: FormEvent) {
     e.preventDefault();
     const q = query.trim();
     if (!q) {
       setError("Enter an organization name or login.");
       setResults(null);
       return;
     }
 
+    // Cancel any in-flight request
+    abortRef.current?.abort();
+    abortRef.current = new AbortController();
+
     setLoading(true);
     setError(null);
     setResults(null);
 
     try {
-      const data = await searchOrganizations(q);
+      const data = await searchOrganizations(q, abortRef.current.signal);
       // ...

This requires updating searchOrganizations in src/api/github.ts to accept an optional AbortSignal and pass it to fetch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/LandingPage.tsx` around lines 16 - 48, handleSubmit can start
overlapping requests which allow stale responses to overwrite newer results;
create and use an AbortController per submit to cancel any previous in-flight
request before starting a new one, pass controller.signal into
searchOrganizations (update src/api/github.ts to accept an optional AbortSignal
and forward it to fetch) and into fetchOrganization if it is used for the
fallback, store the current controller (e.g., in a ref) and call abort() before
creating a new controller, and ensure you clean up the controller in the finally
block so setResults/setError come from the latest non-aborted response.


function openOrg(login: string) {
navigate(`/org/${login}`);
}

return (
<div className="mx-auto flex w-full max-w-lg flex-1 flex-col px-4 py-14 sm:py-20">
<header className="mb-10 text-center">

<h1 className="mb-4 text-4xl font-bold tracking-tight text-white sm:text-5xl">
OrgExplorer
</h1>
<p className="mx-auto max-w-md text-pretty text-sm leading-relaxed text-zinc-400 sm:text-base">
Search public GitHub organizations by name or handle. Open a profile
for a quick summary.
</p>
Comment on lines +58 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

User-visible strings are hardcoded.

Per coding guidelines, user-visible strings should be externalized to resource files for internationalization (i18n). Strings like "OrgExplorer", "Search public GitHub organizations...", "Pick an organization", "Organization on GitHub", etc. are currently hardcoded throughout the component.

Also applies to: 109-110, 128-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/LandingPage.tsx` around lines 58 - 64, The component LandingPage
currently has hardcoded user-facing strings (e.g., "OrgExplorer", the paragraph
"Search public GitHub organizations by name or handle. Open a profile for a
quick summary.", and other strings like "Pick an organization" and "Organization
on GitHub"); replace these with i18n resource keys by adding entries to your
locale/resource file and using your project's translation helper in LandingPage
(e.g., import and call the translation function such as useTranslation/t or
similar) to render t('landing.title'), t('landing.description'),
t('landing.pickOrganization'), t('landing.organizationOnGithub'), etc.; ensure
you update all occurrences referenced in the comment (including strings around
lines 58–64, 109–110, 128–133), add the new keys to the resource file(s), and
run/localize tests to confirm rendering works.

</header>

<form
onSubmit={handleSubmit}
role="search"
className="rounded-2xl border border-zinc-800/80 bg-zinc-900/60 p-5 shadow-xl shadow-black/20 ring-1 ring-white/5 backdrop-blur-md"
>
<label className="sr-only" htmlFor="org-search">
Organization search
</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch">
<input
id="org-search"
type="search"
name="q"
placeholder="Try aossie, vercel, mozilla…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
disabled={loading}
className="min-h-11 flex-1 rounded-xl border border-zinc-700/80 bg-zinc-950/80 px-4 text-sm text-zinc-100 placeholder:text-zinc-500 outline-none transition focus:border-emerald-500/50 focus:ring-2 focus:ring-emerald-500/25 disabled:opacity-60"
/>
<button
type="submit"
disabled={loading}
className="inline-flex min-h-11 shrink-0 items-center justify-center rounded-xl bg-emerald-500 px-6 text-sm font-semibold text-zinc-950 transition hover:bg-emerald-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? "Searching…" : "Search"}
</button>
</div>
</form>

{error && (
<div
className="mt-5 rounded-xl border border-red-500/30 bg-red-950/40 px-4 py-3 text-sm text-red-200"
role="alert"
>
{error}
</div>
)}

{results && results.length > 1 && (
<section className="mt-10" aria-label="Matching organizations">
<h2 className="mb-4 text-sm font-medium text-zinc-500">
Pick an organization
</h2>
<ul className="flex flex-col gap-2">
{results.map((org) => (
<li key={org.id}>
<button
type="button"
onClick={() => openOrg(org.login)}
className="flex w-full items-center gap-4 rounded-xl border border-zinc-800/80 bg-zinc-900/40 px-4 py-3 text-left transition hover:border-emerald-500/40 hover:bg-zinc-900/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500"
>
<img
src={org.avatar_url}
alt=""
width={48}
height={48}
className="size-12 shrink-0 rounded-xl border border-zinc-700/50"
/>
<div className="min-w-0">
<span className="block truncate font-semibold text-zinc-100">
{org.login}
</span>
<span className="text-xs text-zinc-500">
Organization on GitHub
</span>
</div>
</button>
</li>
))}
</ul>
</section>
)}
</div>
);
}
Loading
Loading