pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/routes/explore.tsx

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