Skip to content
223 linesCodeBlameRaw
1/**
2 * A workspace's Projects page, for a workspace with hundreds of them: what
3 * the address asks for (a search, filters, a sort, a view and a page), and
4 * the list that answers it. Read from and written to the address, so a
5 * filtered list can be shared, bookmarked and gone back to.
6 */
7
8import type { Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts";
9
10export type ProjectSort = "updated" | "name" | "active" | "pushed";
11export type ProjectView = "list" | "grid";
12
13/** The Kind filter's choices: a kind of project. */
14export type ProjectKindFilter = "all" | ProjectKind;
15
16export type ProjectQuery = {
17 /** Words in the name, slug or description. */
18 q: string;
19 visibility: "all" | "public" | "private";
20 kind: ProjectKindFilter;
21 /** A language, from the manifests at the project's root. */
22 language: string | null;
23 /** Only projects with Deployments on. */
24 deployments: boolean;
25 /** Archived projects: left out (the default), shown too, or only them. */
26 archived: "hide" | "include" | "only";
27 sort: ProjectSort;
28 view: ProjectView;
29 /** 1 first. */
30 page: number;
31};
32
33/** Projects to a page. */
34export const PAGE_SIZE = 30;
35
36export const DEFAULT_QUERY: ProjectQuery = {
37 q: "",
38 visibility: "all",
39 kind: "all",
40 language: null,
41 deployments: false,
42 archived: "hide",
43 sort: "updated",
44 view: "list",
45 page: 1,
46};
47
48export const SORTS: { value: ProjectSort; label: string }[] = [
49 { value: "updated", label: "Recently updated" },
50 { value: "pushed", label: "Recently pushed" },
51 { value: "active", label: "Most active" },
52 { value: "name", label: "Name" },
53];
54
55/** The language a project's manifests say it is written in. */
56export const LANGUAGE_OF: Record<ProjectEcosystem, string> = {
57 npm: "JavaScript",
58 cargo: "Rust",
59 go: "Go",
60 composer: "PHP",
61 python: "Python",
62};
63
64/** What the list needs of a project, with whether its Deployments are on. */
65export type Listed = Pick<
66 Project,
67 "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity"
68> & { deploying: boolean };
69
70const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
71 (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback;
72
73/** What the address asks for; anything it does not say, or says wrong, is the default. */
74export function readProjectQuery(params: URLSearchParams): ProjectQuery {
75 const page = Number.parseInt(params.get("page") ?? "", 10);
76 return {
77 q: (params.get("q") ?? "").trim().slice(0, 100),
78 visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"),
79 kind: one(params.get("kind"), ["all", "app", "library", "tool", "docs", "other"] as const, "all"),
80 language: params.get("language")?.trim() || null,
81 deployments: params.get("deployments") === "on",
82 archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"),
83 sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"),
84 view: one(params.get("view"), ["list", "grid"] as const, "list"),
85 page: Number.isFinite(page) && page > 0 ? page : 1,
86 };
87}
88
89/**
90 * The address for `query` with `change` made, leaving out what is the
91 * default. Changing anything but the page goes back to the first page.
92 */
93export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string {
94 const next = { ...query, ...change };
95 if (!("page" in change)) next.page = 1;
96 const params = new URLSearchParams();
97 if (next.q) params.set("q", next.q);
98 if (next.visibility !== "all") params.set("visibility", next.visibility);
99 if (next.kind !== "all") params.set("kind", next.kind);
100 if (next.language) params.set("language", next.language);
101 if (next.deployments) params.set("deployments", "on");
102 if (next.archived !== "hide") params.set("archived", next.archived);
103 if (next.sort !== "updated") params.set("sort", next.sort);
104 if (next.view !== "list") params.set("view", next.view);
105 if (next.page > 1) params.set("page", String(next.page));
106 const text = params.toString();
107 return text ? `?${text}` : "";
108}
109
110/** Whether anything narrows the list, beyond leaving archived projects out. */
111export function isFiltered(query: ProjectQuery): boolean {
112 return Boolean(
113 query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide",
114 );
115}
116
117/** When it last changed: its own settings, or a push, whichever is later. */
118export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string {
119 return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt;
120}
121
122function matches(project: Listed, words: string[]): boolean {
123 const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase();
124 return words.every((word) => text.includes(word));
125}
126
127/** The projects `query` asks for, in its order, before paging. */
128export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] {
129 const words = query.q.toLowerCase().split(/\s+/).filter(Boolean);
130 const shown = projects.filter(
131 (project) =>
132 (query.archived === "include" || (query.archived === "only") === project.archived) &&
133 (query.visibility === "all" || (query.visibility === "private") === project.private) &&
134 (query.kind === "all" || project.kind === query.kind) &&
135 (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) &&
136 (!query.deployments || project.deploying) &&
137 (words.length === 0 || matches(project, words)),
138 );
139 return sortProjects(shown, query.sort, words);
140}
141
142const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug);
143
144/**
145 * In `sort`'s order, ties by name. While searching, a name that starts
146 * with what was typed comes first.
147 */
148export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] {
149 const first = words[0];
150 const leads = (project: Listed) =>
151 first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1;
152 const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = {
153 name: byName,
154 updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)),
155 pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""),
156 active: (a, b) => b.activity - a.activity,
157 };
158 return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b));
159}
160
161/** One page of them, and where it sits among the rest. */
162export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } {
163 const pages = Math.max(1, Math.ceil(list.length / size));
164 const current = Math.min(Math.max(1, page), pages);
165 const start = (current - 1) * size;
166 const items = list.slice(start, start + size);
167 return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length };
168}
169
170export type Facets = {
171 visibility: { public: number; private: number };
172 kind: Record<ProjectKind, number>;
173 languages: { name: string; count: number }[];
174 deploying: number;
175 archived: number;
176};
177
178/** How many of the workspace's projects each filter would show, before any is chosen. */
179export function facetsOf(projects: Listed[]): Facets {
180 const languages = new Map<string, number>();
181 const facets: Facets = {
182 visibility: { public: 0, private: 0 },
183 kind: { app: 0, library: 0, tool: 0, docs: 0, other: 0 },
184 languages: [],
185 deploying: 0,
186 archived: 0,
187 };
188 for (const project of projects) {
189 facets.visibility[project.private ? "private" : "public"]++;
190 facets.kind[project.kind]++;
191 if (project.deploying) facets.deploying++;
192 if (project.archived) facets.archived++;
193 if (project.ecosystem) {
194 const language = LANGUAGE_OF[project.ecosystem];
195 languages.set(language, (languages.get(language) ?? 0) + 1);
196 }
197 }
198 facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
199 return facets;
200}
201
202/**
203 * The index of the row to move to from `at` by key, in a list of `count`
204 * rows: arrows and j/k move one, Home and End to either end. Null for any
205 * other key.
206 */
207export function nextRow(key: string, at: number, count: number): number | null {
208 if (count === 0) return null;
209 switch (key) {
210 case "ArrowDown":
211 case "j":
212 return Math.min(count - 1, at + 1);
213 case "ArrowUp":
214 case "k":
215 return Math.max(0, at - 1);
216 case "Home":
217 return 0;
218 case "End":
219 return count - 1;
220 default:
221 return null;
222 }
223}