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