Skip to content

g1t/apps/web/app/routes/workspace/projects.tsx

489 lines21,440 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Navigation: the workspace's own page with tabs, pins and recent in the sidebar1import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react";
2import { type KeyboardEvent, useEffect, useRef } from "react";
3import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router";
4
5import type { Route } from "./+types/projects";
6import { host } from "../../components/deploy";
7import { PinButton } from "../../components/pin-button";
8import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui";
9import { page as pageMeta } from "../../lib/meta";
10import {
11 LANGUAGE_OF,
12 type Listed,
13 type ProjectQuery,
14 SORTS,
15 facetsOf,
16 filterProjects,
17 isFiltered,
18 lastUpdated,
19 nextRow,
20 pageOf,
21 projectQueryString,
22 readProjectQuery,
23} from "../../lib/project-list";
24import { deployments, projects } from "../../lib/services.server";
25import { getViewer, roleIn } from "../../lib/session.server";
26import { workspaceProjects } from "../../lib/workspace-projects.server";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return pageMeta(args, { title: `Projects · ${params.owner} · g1t` });
30}
31
32/**
33 * Every project the viewer can see, filtered, sorted and paged here: one
34 * listing (shared with the sidebar's), with Deployments and the viewer's
35 * pins asked for at the same time.
36 */
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 const slug = params.owner.toLowerCase();
40 const role = roleIn(viewer, slug);
41 const query = readProjectQuery(new URL(request.url).searchParams);
42 const [listed, deploys, shortcuts] = await Promise.all([
43 workspaceProjects(slug, viewer),
44 role ? deployments.overview(slug, viewer).catch(() => null) : null,
45 role ? projects.shortcuts(slug, viewer).catch(() => null) : null,
46 ]);
47 const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
48 const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({
49 id: project.id,
50 slug: project.slug,
51 name: project.name,
52 description: project.description,
53 private: project.private,
54 archived: project.archived,
55 kind: project.kind,
56 ecosystem: project.ecosystem,
57 updatedAt: project.updatedAt,
58 pushedAt: project.pushedAt ?? null,
59 activity: project.activity ?? 0,
60 deploying: live.get(project.slug)?.enabled ?? false,
61 }));
62 const matched = filterProjects(all, query);
63 const shown = pageOf(matched, query.page);
64 return {
65 slug,
66 role,
67 query: { ...query, page: shown.page },
68 total: all.length,
69 matched: matched.length,
70 page: { page: shown.page, pages: shown.pages, from: shown.from, to: shown.to },
71 items: shown.items.map((project) => ({ ...project, production: live.get(project.slug)?.production?.url ?? null })),
72 facets: facetsOf(all),
73 pinned: (shortcuts?.pinned ?? []).map((project) => project.id),
74 failed: !listed.ok,
75 };
76}
77
78type Item = Route.ComponentProps["loaderData"]["items"][number];
79
80const CONTROL =
81 "h-9 rounded-md border border-line bg-surface px-2.5 text-sm text-fg outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
82
83/** Its language, its kind when it is not an app, and its state, as small words. */
84function Facts({ project }: { project: Item }) {
85 const language = project.ecosystem ? LANGUAGE_OF[project.ecosystem] : null;
86 return (
87 <>
88 {language && <span>{language}</span>}
89 {project.kind === "library" && <span>Library</span>}
90 {project.deploying && (
91 <span className="inline-flex items-center gap-1 text-accent">
92 <Rocket size={11} />
93 Deploys
94 </span>
95 )}
96 <span className="text-faint">
97 Updated <TimeAgo at={lastUpdated(project)} />
98 </span>
99 </>
100 );
101}
102
103function ProjectRow({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
104 const to = `/${slug}/${project.slug}`;
105 return (
106 <li className="group relative flex items-center gap-3 px-4 py-3 transition-colors focus-within:bg-raised/60 hover:bg-raised/60">
107 <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
108 {project.private ? <Lock size={14} /> : <Box size={14} />}
109 </span>
110 <div className="min-w-0 grow">
111 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
112 <Link
113 to={to}
114 prefetch="intent"
115 data-row
116 className="truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
117 >
118 {project.name}
119 </Link>
120 {project.name.toLowerCase() !== project.slug && <span className="truncate font-mono text-xs text-faint">{project.slug}</span>}
121 {project.private && <Pill>private</Pill>}
122 {project.archived && <Pill>archived</Pill>}
123 </div>
124 {project.description && <p className="mt-0.5 truncate text-sm text-muted">{project.description}</p>}
125 <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted">
126 <Facts project={project} />
127 </p>
128 </div>
129 {project.production && (
130 <a
131 href={project.production}
132 className="relative z-10 hidden shrink-0 items-center gap-1 font-mono text-xs text-muted hover:text-accent md:flex"
133 >
134 {host(project.production)}
135 <ArrowUpRight size={11} />
136 </a>
137 )}
138 {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact />}
139 </li>
140 );
141}
142
143function ProjectTile({ project, slug, pinned, member }: { project: Item; slug: string; pinned: boolean; member: boolean }) {
144 return (
145 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors focus-within:border-line-strong hover:border-line-strong">
146 <div className="flex items-start gap-3">
147 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
148 {project.private ? <Lock size={15} /> : <Box size={15} />}
149 </span>
150 <div className="min-w-0 grow">
151 <Link
152 to={`/${slug}/${project.slug}`}
153 prefetch="intent"
154 data-row
155 className="block truncate font-medium outline-none after:absolute after:inset-0 hover:underline focus-visible:underline"
156 >
157 {project.name}
158 </Link>
159 {project.production ? (
160 <a href={project.production} className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent">
161 {host(project.production)}
162 <ArrowUpRight size={11} className="shrink-0" />
163 </a>
164 ) : (
165 <p className="mt-0.5 truncate font-mono text-xs text-faint">{project.slug}</p>
166 )}
167 </div>
168 {member && <PinButton workspace={slug} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1 -mr-2" />}
169 </div>
170 <p className="mt-3 line-clamp-2 min-h-10 text-sm text-muted">{project.description ?? ""}</p>
171 <p className="mt-auto flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-line pt-3 text-xs text-muted">
172 {project.private && <span>Private</span>}
173 {project.archived && <span>Archived</span>}
174 <Facts project={project} />
175 </p>
176 </li>
177 );
178}
179
180/** The pages around the current one: the first, the last, and two either side. */
181function pageNumbers(page: number, pages: number): (number | null)[] {
182 const wanted = new Set([1, pages, page - 2, page - 1, page, page + 1, page + 2].filter((n) => n >= 1 && n <= pages));
183 const sorted = [...wanted].sort((a, b) => a - b);
184 return sorted.flatMap((n, i) => (i > 0 && n - sorted[i - 1]! > 1 ? [null, n] : [n]));
185}
186
187function Pages({ query, page, pages }: { query: ProjectQuery; page: number; pages: number }) {
188 if (pages <= 1) return null;
189 const link = "flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm transition-colors";
190 return (
191 <nav aria-label="Pages" className="mt-6 flex flex-wrap items-center justify-center gap-1">
192 {page > 1 ? (
193 <Link to={projectQueryString(query, { page: page - 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
194 <ChevronLeft size={14} />
195 Previous
196 </Link>
197 ) : null}
198 {pageNumbers(page, pages).map((n, i) =>
199 n == null ? (
200 <span key={`gap${i}`} className="px-1 text-faint">
201 …
202 </span>
203 ) : (
204 <Link
205 key={n}
206 to={projectQueryString(query, { page: n })}
207 aria-current={n === page ? "page" : undefined}
208 className={`${link} tabular-nums ${n === page ? "bg-raised font-medium text-fg ring-1 ring-line" : "text-muted hover:bg-raised hover:text-fg"}`}
209 >
210 {n}
211 </Link>
212 ),
213 )}
214 {page < pages ? (
215 <Link to={projectQueryString(query, { page: page + 1 })} className={`${link} gap-1 text-muted hover:bg-raised hover:text-fg`}>
216 Next
217 <ChevronRight size={14} />
218 </Link>
219 ) : null}
220 </nav>
221 );
222}
223
224export default function WorkspaceProjects({ loaderData }: Route.ComponentProps) {
225 const { slug, role, query, total, matched, page, items, facets, pinned, failed } = loaderData;
226 const submit = useSubmit();
227 const navigation = useNavigation();
228 const { pathname } = useLocation();
229 const form = useRef<HTMLFormElement>(null);
230 const searchBox = useRef<HTMLInputElement>(null);
231 const typing = useRef<ReturnType<typeof setTimeout> | null>(null);
232 const member = role != null;
233 const pins = new Set(pinned);
234 // A filter or page of this list on its way: the list says it is busy.
235 const busy = navigation.state === "loading" && navigation.location?.pathname === pathname;
236
237 const apply = (delay = 0) => {
238 if (typing.current) clearTimeout(typing.current);
239 typing.current = setTimeout(() => {
240 if (form.current) submit(form.current, { replace: true, preventScrollReset: true });
241 }, delay);
242 };
243 useEffect(() => () => void (typing.current && clearTimeout(typing.current)), []);
244 // Back and forward change the address, not the fields: they follow it,
245 // except the one being used.
246 useEffect(() => {
247 const fields = form.current?.elements;
248 if (!fields) return;
249 const values: Record<string, string> = {
250 q: query.q,
251 visibility: query.visibility,
252 kind: query.kind,
253 language: query.language ?? "",
254 archived: query.archived,
255 sort: query.sort,
256 };
257 for (const [name, value] of Object.entries(values)) {
258 const field = fields.namedItem(name) as HTMLInputElement | HTMLSelectElement | null;
259 if (field && field !== document.activeElement && "value" in field) field.value = value;
260 }
261 const deploys = fields.namedItem("deployments") as HTMLInputElement | null;
262 if (deploys && deploys !== document.activeElement) deploys.checked = query.deployments;
263 }, [query]);
264
265 // `/` goes to the search, from anywhere on the page that is not a field.
266 useEffect(() => {
267 const onKey = (event: globalThis.KeyboardEvent) => {
268 const target = event.target as HTMLElement | null;
269 if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
270 if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
271 event.preventDefault();
272 searchBox.current?.focus();
273 };
274 window.addEventListener("keydown", onKey);
275 return () => window.removeEventListener("keydown", onKey);
276 }, []);
277
278 // The arrows (and j and k) move between projects; Enter opens one.
279 const list = useRef<HTMLDivElement>(null);
280 const onListKey = (event: KeyboardEvent<HTMLDivElement>) => {
281 const rows = [...(list.current?.querySelectorAll<HTMLAnchorElement>("a[data-row]") ?? [])];
282 const at = rows.indexOf(document.activeElement as HTMLAnchorElement);
283 const key = event.key === "ArrowRight" ? "ArrowDown" : event.key === "ArrowLeft" ? "ArrowUp" : event.key;
284 if (at < 0 && key !== "ArrowDown" && key !== "j") return;
285 const to = nextRow(key, at, rows.length);
286 if (to == null || event.metaKey || event.ctrlKey || event.altKey) return;
287 event.preventDefault();
288 rows[to]?.focus();
289 };
290 // From the search box, down goes to the first project.
291 const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
292 if (event.key === "ArrowDown") {
293 event.preventDefault();
294 list.current?.querySelector<HTMLAnchorElement>("a[data-row]")?.focus();
295 }
296 if (event.key === "Escape" && event.currentTarget.value) {
297 event.currentTarget.value = "";
298 apply();
299 }
300 };
301
302 const filtered = isFiltered(query);
303 const option = (label: string, count: number) => `${label} (${count})`;
304
305 return (
306 <div>
307 <Form ref={form} id="project-filters" method="get" role="search" aria-label="Find projects" className="flex flex-wrap items-center gap-2">
308 <div className="relative w-full sm:w-72">
309 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
310 <input
311 ref={searchBox}
312 type="search"
313 name="q"
314 defaultValue={query.q}
315 {...notACredential()}
316 placeholder="Find a project…"
317 aria-label="Find a project"
318 aria-keyshortcuts="/"
319 onChange={() => apply(250)}
320 onKeyDown={onSearchKey}
321 className={`${CONTROL} w-full pr-8 pl-8 placeholder:text-faint`}
322 />
323 <kbd className="pointer-events-none absolute top-1/2 right-2 hidden -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line sm:block">
324 /
325 </kbd>
326 </div>
327 <select name="visibility" aria-label="Visibility" defaultValue={query.visibility} onChange={() => apply()} className={CONTROL}>
328 <option value="all">All visibility</option>
329 <option value="public">{option("Public", facets.visibility.public)}</option>
330 <option value="private">{option("Private", facets.visibility.private)}</option>
331 </select>
332 <select name="kind" aria-label="Kind" defaultValue={query.kind} onChange={() => apply()} className={CONTROL}>
333 <option value="all">All kinds</option>
334 <option value="app">{option("Apps", facets.kind.app)}</option>
335 <option value="library">{option("Libraries", facets.kind.library)}</option>
336 </select>
337 {(facets.languages.length > 0 || query.language) && (
338 <select name="language" aria-label="Language" defaultValue={query.language ?? ""} onChange={() => apply()} className={CONTROL}>
339 <option value="">Any language</option>
340 {facets.languages.map((language) => (
341 <option key={language.name} value={language.name}>
342 {option(language.name, language.count)}
343 </option>
344 ))}
345 </select>
346 )}
347 {(facets.archived > 0 || query.archived !== "hide") && (
348 <select name="archived" aria-label="Archived" defaultValue={query.archived} onChange={() => apply()} className={CONTROL}>
349 <option value="hide">Without archived</option>
350 <option value="include">With archived</option>
351 <option value="only">{option("Only archived", facets.archived)}</option>
352 </select>
353 )}
354 {member && (
355 <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}>
356 <input
357 type="checkbox"
358 name="deployments"
359 value="on"
360 defaultChecked={query.deployments}
361 onChange={() => apply()}
362 className="accent-[var(--color-accent)]"
363 />
364 Deploys
365 <span className="text-xs tabular-nums text-faint">{facets.deploying}</span>
366 </label>
367 )}
368 {query.view !== "list" && <input type="hidden" name="view" value={query.view} />}
369 <noscript>
370 <button type="submit" className={CONTROL}>
371 Apply
372 </button>
373 </noscript>
374 </Form>
375
376 <div className="mt-4 flex flex-wrap items-center gap-3 text-sm">
377 <p className="text-muted" aria-live="polite">
378 {filtered ? (
379 <>
380 <span className="font-medium text-fg tabular-nums">{matched}</span> of {total} {total === 1 ? "project" : "projects"}
381 {query.q && (
382 <>
383 {" "}
384 matching <span className="text-fg">“{query.q}”</span>
385 </>
386 )}
387 </>
388 ) : (
389 <>
390 <span className="font-medium text-fg tabular-nums">{total}</span> {total === 1 ? "project" : "projects"}
391 </>
392 )}
393 {page.pages > 1 && (
394 <span className="text-faint">
395 {" "}
396 · showing {page.from}–{page.to}
397 </span>
398 )}
399 </p>
400 {filtered && (
401 <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-xs text-muted hover:text-fg">
402 Clear filters
403 </Link>
404 )}
405 <div className="ml-auto flex items-center gap-2">
406 {/* Part of the filters' form, beside the view it orders. */}
407 <select form="project-filters" name="sort" aria-label="Sort" defaultValue={query.sort} onChange={() => apply()} className={`${CONTROL} h-8`}>
408 {SORTS.map((sort) => (
409 <option key={sort.value} value={sort.value}>
410 {sort.label}
411 </option>
412 ))}
413 </select>
414 <nav aria-label="View" className="flex items-center gap-0.5 rounded-lg border border-line bg-bg p-0.5">
415 {(
416 [
417 ["list", "List", <List key="list" size={14} />],
418 ["grid", "Grid", <LayoutGrid key="grid" size={14} />],
419 ] as const
420 ).map(([view, label, icon]) => (
421 <Link
422 key={view}
423 to={projectQueryString(query, { view, page: query.page })}
424 replace
425 preventScrollReset
426 aria-label={`${label} view`}
427 aria-current={query.view === view ? "true" : undefined}
428 className={`flex size-7 items-center justify-center rounded-md transition-colors ${
429 query.view === view ? "bg-raised text-fg ring-1 ring-line" : "text-faint hover:text-fg"
430 }`}
431 >
432 {icon}
433 </Link>
434 ))}
435 </nav>
436 </div>
437 </div>
438
439 <div ref={list} onKeyDown={onListKey} aria-busy={busy || undefined} className={`mt-4 transition-opacity ${busy ? "opacity-60" : ""}`}>
440 {failed ? (
441 <EmptyState title="Projects cannot be listed right now">Try again in a moment.</EmptyState>
442 ) : total === 0 ? (
443 <EmptyState title="No projects yet">
444 {member ? (
445 <>
446 <Link to={`/new?workspace=${slug}`} className="text-accent hover:underline">
447 Create one
448 </Link>
449 , or push a repository and it becomes one.
450 </>
451 ) : (
452 "This workspace has no public projects."
453 )}
454 </EmptyState>
455 ) : items.length === 0 ? (
456 <EmptyState title="No projects match">
457 Try other words or filters, or{" "}
458 <Link to={projectQueryString({ ...query, q: "", visibility: "all", kind: "all", language: null, deployments: false, archived: "hide" })} className="text-accent hover:underline">
459 clear them
460 </Link>
461 .
462 </EmptyState>
463 ) : query.view === "grid" ? (
464 <ul aria-label="Projects" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
465 {items.map((project) => (
466 <ProjectTile key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
467 ))}
468 </ul>
469 ) : (
470 <ul aria-label="Projects" className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
471 {items.map((project) => (
472 <ProjectRow key={project.id} project={project} slug={slug} pinned={pins.has(project.id)} member={member} />
473 ))}
474 </ul>
475 )}
476 </div>
477 <Pages query={query} page={page.page} pages={page.pages} />
478 {items.length > 0 && (
479 <p className="mt-4 hidden text-center text-xs text-faint sm:block">
480 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">/</kbd> to find,{" "}
481 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↑</kbd>{" "}
482 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">↓</kbd> to move,{" "}
483 <kbd className="rounded bg-raised px-1 font-mono ring-1 ring-line">Enter</kbd> to open
484 </p>
485 )}
486 </div>
487 );
488}
489