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.

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