Skip to content

g1t/apps/web/app/lib/pins.ts

65 lines2,851 bytesCodeBlame
1/**
2 * Pinned and recent projects in the sidebar: the viewer's pins in the
3 * workspace (up to eight, in their order), then what they opened last that
4 * they have not pinned (up to five), then All projects. The projects
5 * service keeps both; opening a project records it after the page is sent.
6 */
7
8/** A project as the sidebar lists it. */
9export type ShortcutProject = { namespace: string; name: string; title?: string; isPrivate: boolean };
10
11export const MAX_PINS = 8;
12export const MAX_RECENT = 5;
13
14const same = (a: { namespace: string; name: string }, b: { namespace: string; name: string }) =>
15 a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
16
17/**
18 * Recent, with the project being looked at first when it is the
19 * workspace's and not pinned: it was opened just now, before the projects
20 * service has heard.
21 */
22export function recentWith(
23 recent: ShortcutProject[],
24 pinned: ShortcutProject[],
25 current: ShortcutProject | null,
26 workspace: string | null,
27): ShortcutProject[] {
28 const here = current && workspace && current.namespace.toLowerCase() === workspace.toLowerCase() && !pinned.some((p) => same(p, current)) ? current : null;
29 const rest = recent.filter((project) => !pinned.some((p) => same(p, project)) && !(here && same(project, here)));
30 return [...(here ? [here] : []), ...rest].slice(0, MAX_RECENT);
31}
32
33/** One change to the viewer's pins, as a form posts it. */
34export type PinChange =
35 | { intent: "pin"; slug: string; position: number | null }
36 | { intent: "unpin"; slug: string }
37 | { intent: "reorder"; slugs: string[] };
38
39/** What a pin form asks for; null when it asks for nothing that makes sense. */
40export function pinFromForm(form: FormData): PinChange | null {
41 const intent = form.get("intent");
42 const slug = String(form.get("slug") ?? "").trim().toLowerCase();
43 if (intent === "reorder") {
44 const slugs = form.getAll("slug").map((value) => String(value).trim().toLowerCase()).filter(Boolean);
45 return slugs.length > 0 && new Set(slugs).size === slugs.length ? { intent, slugs } : null;
46 }
47 if (!slug) return null;
48 if (intent === "unpin") return { intent, slug };
49 if (intent !== "pin") return null;
50 const position = Number.parseInt(String(form.get("position") ?? ""), 10);
51 return { intent, slug, position: Number.isFinite(position) && position >= 0 ? position : null };
52}
53
54/**
55 * The pins in a new order after moving the one at `from` by `by` places
56 * (-1 up, 1 down); null when it cannot move that way.
57 */
58export function movedPin<T>(pins: T[], from: number, by: number): T[] | null {
59 const to = from + by;
60 if (from < 0 || from >= pins.length || to < 0 || to >= pins.length || by === 0) return null;
61 const next = [...pins];
62 const [moving] = next.splice(from, 1);
63 next.splice(to, 0, moving!);
64 return next;
65}