Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

240 linesCodeBlameRaw
1import { ArrowUpRight, BookMarked, Search } from "lucide-react";
2import type { ReactNode } from "react";
3import { Form, Link } from "react-router";
4
5import type { ExploreRepo } from "@g1t/contracts";
6
7import type { Route } from "./+types/explore";
8import { EmptyState, TimeAgo, notACredential } from "../components/ui";
9import { Badge } from "../components/ui/badge";
10import { Card } from "../components/ui/card";
11import { page } from "../lib/meta";
12import { DeployLink, host } from "../components/deploy";
13import { projects, search } from "../lib/services.server";
14import { getViewer } from "../lib/session.server";
15
16export function meta({ loaderData, ...args }: Route.MetaArgs) {
17 const filter = loaderData?.language ?? (loaderData?.topic ? `#${loaderData.topic}` : null);
18 return page(args, {
19 title: filter ? `${filter} · Explore · g1t` : "Explore · g1t",
20 description: filter ? `Public projects on g1t in ${filter}.` : "Public projects on g1t: recently active, newly created, and by language and topic.",
21 });
22}
23
24export async function loader({ request, context }: Route.LoaderArgs) {
25 const url = new URL(request.url);
26 const sort = url.searchParams.get("sort") === "new" ? ("new" as const) : ("active" as const);
27 const language = url.searchParams.get("language")?.trim().toLowerCase() || null;
28 const topic = url.searchParams.get("topic")?.trim().toLowerCase() || null;
29 const pageNumber = Math.min(Math.max(Number(url.searchParams.get("page")) || 1, 1), 50);
30 const explore = await search.explore(getViewer(context), { sort, language, topic, page: pageNumber }).catch(() => null);
31 // Each one's homepage or production, and its description, from its project;
32 // the page shows without them.
33 const [links, about] = explore
34 ? await Promise.all([
35 projects.publicLinks(explore.repos.map((repo) => ({ namespace: repo.namespace, name: repo.name }))).catch(() => ({})),
36 projects.descriptions(explore.repos.map((repo) => repo.id).filter(Boolean)).catch(() => ({})),
37 ])
38 : [{}, {}];
39 const described = (about as Record<string, string>) ?? {};
40 // A search from before ids keeps its own copy until it is deployed too.
41 const repos = (explore?.repos ?? []).map((repo) => ({ ...repo, description: repo.id ? (described[repo.id] ?? null) : repo.description }));
42 return { sort, language, topic, explore: explore && { ...explore, repos }, links: links as Record<string, string> };
43}
44
45/** The address of Explore with some of its filters changed. */
46function href(current: { sort: string; language: string | null; topic: string | null }, change: Record<string, string | null>) {
47 const next = { sort: current.sort, language: current.language, topic: current.topic, ...change };
48 const params = new URLSearchParams();
49 if (next.sort && next.sort !== "active") params.set("sort", next.sort);
50 if (next.language) params.set("language", next.language);
51 if (next.topic) params.set("topic", next.topic);
52 if (change.page) params.set("page", change.page);
53 const query = params.toString();
54 return query ? `/explore?${query}` : "/explore";
55}
56
57function RepoCard({ repo, link }: { repo: ExploreRepo; link: string | null }) {
58 return (
59 <Card className="relative flex h-full flex-col p-4 transition-colors hover:border-line-strong">
60 <span className="flex items-center gap-2">
61 <BookMarked size={15} className="shrink-0 text-faint" />
62 <Link prefetch="intent" to={`/${repo.namespace}/${repo.name}`} className="truncate font-mono text-sm after:absolute after:inset-0">
63 <span className="text-muted">{repo.namespace}/</span>
64 <span className="font-medium">{repo.name}</span>
65 </Link>
66 {repo.archived && (
67 <span className="ml-auto shrink-0">
68 <Badge size="md">archived</Badge>
69 </span>
70 )}
71 </span>
72 <span className="mt-2 line-clamp-2 grow text-sm text-muted">{repo.description ?? "No description."}</span>
73 {link && (
74 <DeployLink
75 href={link}
76 rel="nofollow"
77 className="relative z-10 mt-2 flex min-w-0 items-center gap-1 self-start font-mono text-xs text-muted hover:text-accent"
78 >
79 <span className="truncate">{host(link).replace(/\/$/, "")}</span>
80 <ArrowUpRight size={11} className="shrink-0" />
81 </DeployLink>
82 )}
83 {repo.topics.length > 0 && (
84 <span className="mt-3 flex flex-wrap gap-1.5">
85 {repo.topics.slice(0, 4).map((topic) => (
86 <span key={topic} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted ring-1 ring-line">
87 {topic}
88 </span>
89 ))}
90 </span>
91 )}
92 <span className="mt-3 flex items-center gap-3 text-xs text-faint">
93 {repo.language && <span className="font-mono">{repo.language}</span>}
94 <span className="ml-auto">
95 {repo.pushedAt ? (
96 <>
97 Pushed <TimeAgo at={repo.pushedAt} />
98 </>
99 ) : (
100 <>
101 Created <TimeAgo at={repo.createdAt} />
102 </>
103 )}
104 </span>
105 </span>
106 </Card>
107 );
108}
109
110function Chip({ to, current, children }: { to: string; current: boolean; children: ReactNode }) {
111 return (
112 <Link
113 to={to}
114 aria-current={current ? "true" : undefined}
115 className={`rounded-full px-2.5 py-1 text-xs transition-colors ring-1 ${
116 current ? "bg-accent/15 text-fg ring-accent/50" : "text-muted ring-line hover:text-fg hover:ring-line-strong"
117 }`}
118 >
119 {children}
120 </Link>
121 );
122}
123
124export default function ExplorePage({ loaderData }: Route.ComponentProps) {
125 const { sort, language, topic, explore } = loaderData;
126 const current = { sort, language, topic };
127 const filtered = language || topic;
128 return (
129 <main className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
130 <h1 className="text-2xl font-semibold tracking-tight">Explore</h1>
131 <p className="mt-1 text-muted">
132 Public projects on g1t{language ? ` in ${language}` : ""}
133 {topic ? ` about ${topic}` : ""}.
134 </p>
135 <Form action="/search" role="search" className="relative mt-6 max-w-2xl">
136 <Search size={16} className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-faint" />
137 <input
138 name="q"
139 {...notACredential()}
140 placeholder="Search repositories, code, work items and people"
141 aria-label="Search g1t"
142 className="h-10 w-full rounded-lg border border-line bg-surface pr-4 pl-10 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
143 />
144 </Form>
145
146 <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_16rem]">
147 <section>
148 <div className="flex flex-wrap items-center gap-3">
149 <Card asChild tone="bg" radius="lg" className="flex items-center gap-1 p-1">
150 <nav aria-label="Order">
151 {[
152 { value: "active", label: "Recently active" },
153 { value: "new", label: "New" },
154 ].map((option) => (
155 <Link
156 key={option.value}
157 to={href(current, { sort: option.value })}
158 aria-current={sort === option.value ? "page" : undefined}
159 className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
160 sort === option.value ? "bg-raised text-fg ring-1 ring-line" : "text-muted hover:text-fg"
161 }`}
162 >
163 {option.label}
164 </Link>
165 ))}
166 </nav>
167 </Card>
168 {filtered && (
169 <Link to={href(current, { language: null, topic: null })} className="text-xs text-muted hover:text-fg">
170 Clear {language ? "language" : "topic"}
171 </Link>
172 )}
173 </div>
174 {!explore ? (
175 <div className="mt-4">
176 <EmptyState title="Explore cannot be reached right now">Try again in a moment.</EmptyState>
177 </div>
178 ) : explore.repos.length === 0 ? (
179 <div className="mt-4">
180 <EmptyState title="No public projects here yet">
181 {filtered ? "Try another language or topic." : "Public projects appear here as they are made."}
182 </EmptyState>
183 </div>
184 ) : (
185 <ul className="mt-4 grid gap-3 sm:grid-cols-2">
186 {explore.repos.map((repo) => (
187 <li key={`${repo.namespace}/${repo.name}`}>
188 <RepoCard repo={repo} link={loaderData.links[`${repo.namespace}/${repo.name}`.toLowerCase()] ?? null} />
189 </li>
190 ))}
191 </ul>
192 )}
193 {explore && (explore.page > 1 || explore.more) && (
194 <nav aria-label="Pages" className="mt-6 flex justify-between text-sm">
195 {explore.page > 1 ? (
196 <Link to={href(current, { page: String(explore.page - 1) })} className="text-muted hover:text-fg">
197 Previous
198 </Link>
199 ) : (
200 <span />
201 )}
202 {explore.more && (
203 <Link to={href(current, { page: String(explore.page + 1) })} className="text-muted hover:text-fg">
204 Next
205 </Link>
206 )}
207 </nav>
208 )}
209 </section>
210
211 <aside className="space-y-6">
212 {explore && explore.languages.length > 0 && (
213 <section>
214 <h2 className="text-xs font-medium text-faint">Languages</h2>
215 <div className="mt-2 flex flex-wrap gap-1.5">
216 {explore.languages.map((facet) => (
217 <Chip key={facet.name} to={href(current, { language: language === facet.name ? null : facet.name })} current={language === facet.name}>
218 <span className="font-mono">{facet.name}</span> <span className="text-faint">{facet.count}</span>
219 </Chip>
220 ))}
221 </div>
222 </section>
223 )}
224 {explore && explore.topics.length > 0 && (
225 <section>
226 <h2 className="text-xs font-medium text-faint">Topics</h2>
227 <div className="mt-2 flex flex-wrap gap-1.5">
228 {explore.topics.map((facet) => (
229 <Chip key={facet.name} to={href(current, { topic: topic === facet.name ? null : facet.name })} current={topic === facet.name}>
230 {facet.name} <span className="text-faint">{facet.count}</span>
231 </Chip>
232 ))}
233 </div>
234 </section>
235 )}
236 </aside>
237 </div>
238 </main>
239 );
240}