| 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. */ |
| 9 | export type ShortcutProject = { namespace: string; name: string; title?: string; isPrivate: boolean }; |
| 10 | |
| 11 | export const MAX_PINS = 8; |
| 12 | export const MAX_RECENT = 5; |
| 13 | |
| 14 | const 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 | */ |
| 22 | export 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. */ |
| 34 | export 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. */ |
| 40 | export 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 | */ |
| 58 | export 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 | } |