flagon-io/g1t

public

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

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

225 lines9,362 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, 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 </span>
61 <span className="mt-2 line-clamp-2 grow text-sm text-muted">{repo.description ?? "No description."}</span>
62 {repo.topics.length > 0 && (
63 <span className="mt-3 flex flex-wrap gap-1.5">
64 {repo.topics.slice(0, 4).map((topic) => (
65 <span key={topic} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted ring-1 ring-line">
66 {topic}
67 </span>
68 ))}
69 </span>
70 )}
71 <span className="mt-3 flex items-center gap-3 text-xs text-faint">
72 {repo.language && <span className="font-mono">{repo.language}</span>}
73 <span className="ml-auto">
74 {repo.pushedAt ? (
75 <>
76 Pushed <TimeAgo at={repo.pushedAt} />
77 </>
78 ) : (
79 <>
80 Created <TimeAgo at={repo.createdAt} />
81 </>
82 )}
83 </span>
84 </span>
85 </Link>
86 );
87}
88
89function Chip({ to, current, children }: { to: string; current: boolean; children: ReactNode }) {
90 return (
91 <Link
92 to={to}
93 aria-current={current ? "true" : undefined}
94 className={`rounded-full px-2.5 py-1 text-xs transition-colors ring-1 ${
95 current ? "bg-accent/15 text-fg ring-accent/50" : "text-muted ring-line hover:text-fg hover:ring-line-strong"
96 }`}
97 >
98 {children}
99 </Link>
100 );
101}
102
103export default function ExplorePage({ loaderData }: Route.ComponentProps) {
104 const { sort, language, topic, explore } = loaderData;
105 const current = { sort, language, topic };
106 const filtered = language || topic;
107 return (
108 <main className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
109 <h1 className="text-2xl font-semibold tracking-tight">Explore</h1>
110 <p className="mt-1 text-muted">
111 Public projects on g1t{language ? ` in ${language}` : ""}
112 {topic ? ` about ${topic}` : ""}.
113 </p>
114 <Form action="/search" role="search" className="relative mt-6 max-w-2xl">
115 <Search size={16} className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-faint" />
116 <input
117 name="q"
118 {...notACredential()}
119 placeholder="Search repositories, code, issues and people"
120 aria-label="Search g1t"
121 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"
122 />
123 </Form>
124
125 <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_16rem]">
126 <section>
127 <div className="flex flex-wrap items-center gap-3">
128 <nav aria-label="Order" className="flex items-center gap-1 rounded-lg border border-line bg-bg p-1">
129 {[
130 { value: "active", label: "Recently active" },
131 { value: "new", label: "New" },
132 ].map((option) => (
133 <Link
134 key={option.value}
135 to={href(current, { sort: option.value })}
136 aria-current={sort === option.value ? "page" : undefined}
137 className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
138 sort === option.value ? "bg-raised text-fg ring-1 ring-line" : "text-muted hover:text-fg"
139 }`}
140 >
141 {option.label}
142 </Link>
143 ))}
144 </nav>
145 {filtered && (
146 <Link to={href(current, { language: null, topic: null })} className="text-xs text-muted hover:text-fg">
147 Clear {language ? "language" : "topic"}
148 </Link>
149 )}
150 </div>
151 {!explore ? (
152 <div className="mt-4">
153 <EmptyState title="Explore cannot be reached right now">Try again in a moment.</EmptyState>
154 </div>
155 ) : explore.repos.length === 0 ? (
156 <div className="mt-4">
157 <EmptyState title="No public projects here yet">
158 {filtered ? "Try another language or topic." : "Public projects appear here as they are made."}
159 </EmptyState>
160 </div>
161 ) : (
162 <ul className="mt-4 grid gap-3 sm:grid-cols-2">
163 {explore.repos.map((repo) => (
164 <li key={`${repo.namespace}/${repo.name}`}>
165 <RepoCard repo={repo} />
166 </li>
167 ))}
168 </ul>
169 )}
170 {explore && (explore.page > 1 || explore.more) && (
171 <nav aria-label="Pages" className="mt-6 flex justify-between text-sm">
172 {explore.page > 1 ? (
173 <Link to={href(current, { page: String(explore.page - 1) })} className="text-muted hover:text-fg">
174 Previous
175 </Link>
176 ) : (
177 <span />
178 )}
179 {explore.more && (
180 <Link to={href(current, { page: String(explore.page + 1) })} className="text-muted hover:text-fg">
181 Next
182 </Link>
183 )}
184 </nav>
185 )}
186 </section>
187
188 <aside className="space-y-6">
189 {explore && explore.languages.length > 0 && (
190 <section>
191 <h2 className="text-xs font-medium text-faint">Languages</h2>
192 <div className="mt-2 flex flex-wrap gap-1.5">
193 {explore.languages.map((facet) => (
194 <Chip
195 key={facet.name}
196 to={href(current, { language: language === facet.name ? null : facet.name })}
197 current={language === facet.name}
198 >
199 <span className="font-mono">{facet.name}</span> <span className="text-faint">{facet.count}</span>
200 </Chip>
201 ))}
202 </div>
203 </section>
204 )}
205 {explore && explore.topics.length > 0 && (
206 <section>
207 <h2 className="text-xs font-medium text-faint">Topics</h2>
208 <div className="mt-2 flex flex-wrap gap-1.5">
209 {explore.topics.map((facet) => (
210 <Chip
211 key={facet.name}
212 to={href(current, { topic: topic === facet.name ? null : facet.name })}
213 current={topic === facet.name}
214 >
215 {facet.name} <span className="text-faint">{facet.count}</span>
216 </Chip>
217 ))}
218 </div>
219 </section>
220 )}
221 </aside>
222 </div>
223 </main>
224 );
225}