-
-
Notifications
You must be signed in to change notification settings - Fork 38
feat: search oraganization and view basic details #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
This file was deleted.
| 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 |
| 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>; | ||
| } |
| 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; | ||
| } |
| 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}`); | ||
| } catch (err) { | ||
| const message = | ||
| err instanceof Error ? err.message : "Something went wrong."; | ||
| setError(message); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| } | ||
|
Comment on lines
+16
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing request cancellation for overlapping searches. The linked issue 🛠️ 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 🤖 Prompt for AI Agents |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| </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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URL-encode the fallback navigation path.
When no search results are found, the raw trimmed query
qis used directly in the navigation path. If the query contains special characters or spaces, this could cause routing issues.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents