Navigation: the workspace's own page with tabs, pins and recent in the sidebar
One rule for the sidebar: it is the workspace the switcher names. Mission control stays its first item; Overview leaves the list for the switcher (Workspace overview, All projects) and Explore, all of g1t, moves to the top bar beside Docs (an icon on a phone) and stays in the palette. The workspace's page at /<workspace> has a header and tabs, styled as a project's: Overview, Projects (count), Packages, Teams (soon), People (count; Members before), Insights (soon) and, for owners, Settings. The Overview leads with the viewer's pinned projects, then the most active ones and All projects. The new Projects tab handles hundreds: search, filters for visibility, kind, language, Deployments and archived, four sorts, list and grid views, pages of 30, counts, and the keyboard (/ to find, arrows or j/k to move, Enter to open). Its loader asks for the shared project listing, Deployments and pins at once. The sidebar's Projects lists the viewer's pins (drag or Alt+arrows to reorder), then what they opened last, then All projects (N); with no pins it says how to pin. Pin buttons sit on a project's header, on Overview cards and on every Projects row. Opening a project records it after the page is sent. A Workspace label heads Agent fleet to Roadmap. Old addresses keep working: -/members and -/overview redirect, Soon pages for Teams and Insights go to their tabs, and ?tab=repositories and the like open that tab. Skeletons stand in for a tab on its way.
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 27 | 27 | import { withNext } from "../lib/next"; | |
| 28 | 28 | import { useSignUpCopy } from "../lib/registration"; | |
| 29 | 29 | import { STATUS_URL, statusTitle } from "../lib/status"; | |
| 30 | + | import { type ShortcutProject, movedPin, recentWith } from "../lib/pins"; | |
| 30 | 31 | import type { AccountMenuData } from "../routes/settings-menu-json"; | |
| 31 | 32 | ||
| 32 | 33 | /** | |
| 37 | 38 | export type ShellData = { | |
| 38 | 39 | /** The workspace the sidebar is about: the one being looked at, or their first. */ | |
| 39 | 40 | workspace: Membership | null; | |
| 40 | − | /** Its projects, by name: `name` is the slug in their address. */ | |
| 41 | − | repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[]; | |
| 41 | + | /** All its projects, by name, for the palette: `name` is the slug in their address. */ | |
| 42 | + | repos: ShortcutProject[]; | |
| 43 | + | /** The person's pinned projects in it, in their order (lib/pins.ts). */ | |
| 44 | + | pinned?: ShortcutProject[]; | |
| 45 | + | /** What they opened there last, latest first, leaving out the pinned. */ | |
| 46 | + | recent?: ShortcutProject[]; | |
| 42 | 47 | /** Repositories shared with them in workspaces they do not belong to. */ | |
| 43 | 48 | shared?: { namespace: string; name: string; isPrivate: boolean }[]; | |
| 44 | 49 | /** The project being looked at, if any, whoever owns it. */ | |
| 250 | 255 | <ChevronsUpDown size={14} /> | |
| 251 | 256 | </DropdownMenuTrigger> | |
| 252 | 257 | <DropdownMenuContent align="start" className="w-64"> | |
| 258 | + | {workspace && ( | |
| 259 | + | <> | |
| 260 | + | {/* The workspace's own page: its projects, packages and people. */} | |
| 261 | + | <DropdownMenuItem asChild> | |
| 262 | + | <Link to={`/${workspace.slug}`}> | |
| 263 | + | <LayoutGrid /> | |
| 264 | + | Workspace overview | |
| 265 | + | </Link> | |
| 266 | + | </DropdownMenuItem> | |
| 267 | + | <DropdownMenuItem asChild> | |
| 268 | + | <Link to={`/${workspace.slug}/-/projects`}> | |
| 269 | + | <Box /> | |
| 270 | + | All projects | |
| 271 | + | </Link> | |
| 272 | + | </DropdownMenuItem> | |
| 273 | + | <DropdownMenuSeparator /> | |
| 274 | + | </> | |
| 275 | + | )} | |
| 253 | 276 | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> | |
| 254 | 277 | {(user.workspaces ?? []).map((membership) => ( | |
| 255 | 278 | <DropdownMenuItem asChild key={membership.slug}> | |
| 550 | 573 | return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />; | |
| 551 | 574 | } | |
| 552 | 575 | ||
| 576 | + | /** A quiet heading inside a group: Pinned, Recent. */ | |
| 577 | + | function SidebarSubhead({ children }: { children: ReactNode }) { | |
| 578 | + | return <h3 className="px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium tracking-wide text-faint">{children}</h3>; | |
| 579 | + | } | |
| 580 | + | ||
| 553 | 581 | /** | |
| 582 | + | * The workspace's projects as the sidebar keeps them, however many there | |
| 583 | + | * are: the person's pins in their order, what they opened last, and the | |
| 584 | + | * way to all of them. Pins move by dragging, or with Alt and the arrow | |
| 585 | + | * keys; the projects service keeps the order. | |
| 586 | + | */ | |
| 587 | + | function SidebarProjects({ slug, shell }: { slug: string; shell: ShellData }) { | |
| 588 | + | const reorder = useFetcher({ key: `pins:${slug}` }); | |
| 589 | + | const saved = shell.pinned ?? []; | |
| 590 | + | // While a new order is on its way, it shows as made. | |
| 591 | + | const asked = reorder.formData?.get("intent") === "reorder" ? reorder.formData.getAll("slug").map(String) : null; | |
| 592 | + | const pinned = asked | |
| 593 | + | ? asked.map((name) => saved.find((project) => project.name === name)).filter((project): project is ShortcutProject => project != null) | |
| 594 | + | : saved; | |
| 595 | + | const active = shell.repo ? { namespace: shell.repo.namespace, name: shell.repo.name, isPrivate: false } : null; | |
| 596 | + | const recent = recentWith(shell.recent ?? [], pinned, active, slug); | |
| 597 | + | const [dragging, setDragging] = useState<number | null>(null); | |
| 598 | + | const move = (from: number, by: number) => { | |
| 599 | + | const next = movedPin(pinned, from, by); | |
| 600 | + | if (!next) return; | |
| 601 | + | const form = new FormData(); | |
| 602 | + | form.set("intent", "reorder"); | |
| 603 | + | for (const project of next) form.append("slug", project.name); | |
| 604 | + | reorder.submit(form, { method: "post", action: `/${slug}/-/pins` }); | |
| 605 | + | }; | |
| 606 | + | const row = (project: ShortcutProject) => ( | |
| 607 | + | <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover"> | |
| 608 | + | {project.title ?? project.name} | |
| 609 | + | </SidebarLink> | |
| 610 | + | ); | |
| 611 | + | return ( | |
| 612 | + | <SidebarGroup | |
| 613 | + | title="Projects" | |
| 614 | + | action={ | |
| 615 | + | <Link to={`/new?workspace=${slug}`} aria-label="New project" className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"> | |
| 616 | + | <Plus size={13} /> | |
| 617 | + | </Link> | |
| 618 | + | } | |
| 619 | + | > | |
| 620 | + | <SidebarSubhead>Pinned</SidebarSubhead> | |
| 621 | + | {pinned.length === 0 ? ( | |
| 622 | + | <p className="px-2 pb-1 text-xs text-faint">Pin projects to keep them here.</p> | |
| 623 | + | ) : ( | |
| 624 | + | <ul aria-label="Pinned projects" className="space-y-px"> | |
| 625 | + | {pinned.map((project, index) => ( | |
| 626 | + | <li | |
| 627 | + | key={project.name} | |
| 628 | + | draggable={pinned.length > 1} | |
| 629 | + | onDragStart={(event) => { | |
| 630 | + | setDragging(index); | |
| 631 | + | event.dataTransfer.effectAllowed = "move"; | |
| 632 | + | }} | |
| 633 | + | onDragOver={(event) => { | |
| 634 | + | if (dragging != null) event.preventDefault(); | |
| 635 | + | }} | |
| 636 | + | onDrop={(event) => { | |
| 637 | + | event.preventDefault(); | |
| 638 | + | if (dragging != null && dragging !== index) move(dragging, index - dragging); | |
| 639 | + | setDragging(null); | |
| 640 | + | }} | |
| 641 | + | onDragEnd={() => setDragging(null)} | |
| 642 | + | onKeyDown={(event) => { | |
| 643 | + | if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; | |
| 644 | + | event.preventDefault(); | |
| 645 | + | move(index, event.key === "ArrowUp" ? -1 : 1); | |
| 646 | + | }} | |
| 647 | + | title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined} | |
| 648 | + | className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`} | |
| 649 | + | > | |
| 650 | + | {row(project)} | |
| 651 | + | {pinned.length > 1 && ( | |
| 652 | + | <GripVertical | |
| 653 | + | size={12} | |
| 654 | + | aria-hidden="true" | |
| 655 | + | className="pointer-events-none absolute top-1/2 -left-1.5 -translate-y-1/2 text-faint opacity-0 transition-opacity group-hover/pin:opacity-100" | |
| 656 | + | /> | |
| 657 | + | )} | |
| 658 | + | </li> | |
| 659 | + | ))} | |
| 660 | + | </ul> | |
| 661 | + | )} | |
| 662 | + | {recent.length > 0 && ( | |
| 663 | + | <> | |
| 664 | + | <SidebarSubhead>Recent</SidebarSubhead> | |
| 665 | + | {recent.map((project) => ( | |
| 666 | + | <div key={`${project.namespace}/${project.name}`}>{row(project)}</div> | |
| 667 | + | ))} | |
| 668 | + | </> | |
| 669 | + | )} | |
| 670 | + | <div className="pt-1"> | |
| 671 | + | <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length}> | |
| 672 | + | All projects | |
| 673 | + | </SidebarLink> | |
| 674 | + | </div> | |
| 675 | + | </SidebarGroup> | |
| 676 | + | ); | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | /** | |
| 554 | 680 | * The main list: where you go, the projects, what the workspace builds and | |
| 555 | 681 | * runs with across them, then its usage, support and settings. | |
| 556 | 682 | */ | |
| 557 | 683 | function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) { | |
| 558 | 684 | const ws = shell.workspace; | |
| 559 | − | const active = shell.repo; | |
| 560 | − | // The repository being looked at is listed even when it is someone else's. | |
| 561 | − | const listed = | |
| 562 | − | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 563 | − | ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos] | |
| 564 | − | : shell.repos; | |
| 565 | 685 | // A visitor browses: no workspace, no projects of their own. | |
| 566 | 686 | if (!user) { | |
| 567 | 687 | return ( | |
| 578 | 698 | } | |
| 579 | 699 | return ( | |
| 580 | 700 | <nav aria-label="g1t" className={PANEL}> | |
| 701 | + | {/* Everything here is the workspace the switcher names: its home first. | |
| 702 | + | The workspace's own page is the switcher's; Explore, all of g1t, | |
| 703 | + | is in the top bar. */} | |
| 581 | 704 | <div className="mt-3 space-y-px"> | |
| 582 | 705 | <SidebarLink to="/" end icon={<House size={15} />}> | |
| 583 | 706 | Mission control | |
| 584 | − | </SidebarLink> | |
| 585 | − | {ws && ( | |
| 586 | − | <SidebarLink to={`/${ws.slug}`} end icon={<LayoutGrid size={15} />}> | |
| 587 | − | Overview | |
| 588 | − | </SidebarLink> | |
| 589 | − | )} | |
| 590 | − | <SidebarLink to="/explore" icon={<Compass size={15} />}> | |
| 591 | − | Explore | |
| 592 | 707 | </SidebarLink> | |
| 593 | 708 | </div> | |
| 594 | 709 | ||
| 595 | 710 | <Rule /> | |
| 596 | − | <SidebarGroup | |
| 597 | − | title="Projects" | |
| 598 | − | action={ | |
| 599 | − | ws && ( | |
| 600 | − | <Link | |
| 601 | − | to={`/new?workspace=${ws.slug}`} | |
| 602 | − | aria-label="New project" | |
| 603 | − | className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg" | |
| 604 | − | > | |
| 605 | − | <Plus size={13} /> | |
| 606 | − | </Link> | |
| 607 | − | ) | |
| 608 | − | } | |
| 609 | − | > | |
| 610 | − | {listed.length === 0 && <p className="px-2 py-1 text-xs text-faint">None yet.</p>} | |
| 611 | − | {listed.map((repo) => ( | |
| 612 | − | <SidebarLink | |
| 613 | − | key={`${repo.namespace}/${repo.name}`} | |
| 614 | − | to={`/${repo.namespace}/${repo.name}`} | |
| 615 | − | icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />} | |
| 616 | − | drill="hover" | |
| 617 | − | > | |
| 618 | − | {repo.namespace !== ws?.slug && <span className="font-mono text-faint">{repo.namespace}/</span>} | |
| 619 | − | {"title" in repo && repo.title ? repo.title : repo.name} | |
| 620 | − | </SidebarLink> | |
| 621 | − | ))} | |
| 622 | − | </SidebarGroup> | |
| 711 | + | {ws ? ( | |
| 712 | + | <SidebarProjects slug={ws.slug} shell={shell} /> | |
| 713 | + | ) : ( | |
| 714 | + | <SidebarGroup title="Projects"> | |
| 715 | + | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 716 | + | </SidebarGroup> | |
| 717 | + | )} | |
| 623 | 718 | {(shell.shared ?? []).length > 0 && ( | |
| 624 | 719 | <SidebarGroup title="Shared with you" className="mt-3"> | |
| 625 | 720 | {(shell.shared ?? []).map((repo) => ( | |
| 639 | 734 | {ws && ( | |
| 640 | 735 | <> | |
| 641 | 736 | <Rule /> | |
| 642 | − | <div className="space-y-px"> | |
| 737 | + | <SidebarGroup title="Workspace"> | |
| 643 | 738 | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 644 | 739 | Agent fleet | |
| 645 | 740 | </SidebarLink> | |
| 655 | 750 | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}> | |
| 656 | 751 | Packages | |
| 657 | 752 | </SidebarLink> | |
| 658 | − | {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => ( | |
| 753 | + | {roadmapIn("Workspace").filter((item) => item.key !== "teams" && item.key !== "insights").map((item) => ( | |
| 659 | 754 | <SidebarSoonLink | |
| 660 | 755 | key={item.key} | |
| 661 | 756 | to={`/${ws.slug}/-/soon/${item.key}`} | |
| 665 | 760 | {item.title === "Board" ? "Boards" : item.title} | |
| 666 | 761 | </SidebarSoonLink> | |
| 667 | 762 | ))} | |
| 668 | − | </div> | |
| 763 | + | </SidebarGroup> | |
| 669 | 764 | ||
| 670 | 765 | <Rule /> | |
| 671 | 766 | <div className="space-y-px"> | |
| 672 | 767 | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 673 | 768 | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 674 | − | Members | |
| 769 | + | People | |
| 675 | 770 | </SidebarLink> | |
| 676 | 771 | {roadmapIn("Workspace") | |
| 677 | 772 | .filter((item) => item.key === "teams") | |
| 678 | 773 | .map((item) => ( | |
| 679 | − | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 774 | + | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 680 | 775 | {item.title} | |
| 681 | 776 | </SidebarSoonLink> | |
| 682 | 777 | ))} | |
| 1131 | 1226 | const back = !user | |
| 1132 | 1227 | ? { to: "/explore", label: "Explore" } | |
| 1133 | 1228 | : home | |
| 1134 | − | ? { to: `/${home.slug}`, label: "All projects" } | |
| 1229 | + | ? { to: `/${home.slug}/-/projects`, label: "All projects" } | |
| 1135 | 1230 | : { to: "/", label: "Mission control" }; | |
| 1136 | 1231 | trail.push({ | |
| 1137 | 1232 | key, | |
| 1219 | 1314 | code: "Files", | |
| 1220 | 1315 | secrets: "Secrets and variables", | |
| 1221 | 1316 | settings: "Settings", | |
| 1222 | − | people: "Members", | |
| 1317 | + | people: "People", | |
| 1318 | + | projects: "Projects", | |
| 1319 | + | teams: "Teams", | |
| 1223 | 1320 | tokens: "Access tokens", | |
| 1224 | 1321 | usage: "Usage", | |
| 1225 | 1322 | billing: "Billing and plans", | |
| 1386 | 1483 | for (const membership of user.workspaces ?? []) { | |
| 1387 | 1484 | commands.push( | |
| 1388 | 1485 | { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> }, | |
| 1486 | + | { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> }, | |
| 1487 | + | { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> }, | |
| 1389 | 1488 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> }, | |
| 1390 | 1489 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 1391 | 1490 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, | |
| 1566 | 1665 | </kbd> | |
| 1567 | 1666 | </Form> | |
| 1568 | 1667 | <div className="ml-auto flex items-center gap-1.5 md:ml-0"> | |
| 1668 | + | {/* All of g1t's public projects: not any one workspace's, so here, not in the sidebar. */} | |
| 1669 | + | <NavLink | |
| 1670 | + | to="/explore" | |
| 1671 | + | prefetch="intent" | |
| 1672 | + | aria-label="Explore" | |
| 1673 | + | className={({ isActive }) => | |
| 1674 | + | `flex h-9 items-center gap-1.5 rounded-md px-2 text-sm transition-colors hover:bg-raised hover:text-fg sm:px-2.5 ${isActive ? "text-fg" : "text-muted"}` | |
| 1675 | + | } | |
| 1676 | + | > | |
| 1677 | + | <Compass size={16} className="sm:hidden" /> | |
| 1678 | + | <span className="hidden sm:inline">Explore</span> | |
| 1679 | + | </NavLink> | |
| 1569 | 1680 | <a | |
| 1570 | 1681 | href="https://docs.g1t.sh/" | |
| 1571 | 1682 | className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block" |
| 1 | + | /** | |
| 2 | + | * The shapes of a workspace's tabs while they load: the same layout as | |
| 3 | + | * the real thing, so nothing moves when it arrives (components/ui/skeleton.tsx). | |
| 4 | + | */ | |
| 5 | + | import type { WorkspaceTabKey } from "../lib/workspace-nav"; | |
| 6 | + | import { Skeleton, SkeletonRows, SkeletonText } from "./ui/skeleton"; | |
| 7 | + | ||
| 8 | + | /** A project card, as the Overview and the Projects grid show them. */ | |
| 9 | + | export function ProjectCardSkeleton() { | |
| 10 | + | return ( | |
| 11 | + | <li aria-hidden="true" className="flex flex-col rounded-2xl border border-line bg-surface p-5"> | |
| 12 | + | <div className="flex items-start gap-3"> | |
| 13 | + | <Skeleton className="size-9 shrink-0 rounded-lg" /> | |
| 14 | + | <div className="grow space-y-2 pt-1"> | |
| 15 | + | <Skeleton className="h-3.5 w-32" /> | |
| 16 | + | <Skeleton className="h-3 w-48" /> | |
| 17 | + | </div> | |
| 18 | + | </div> | |
| 19 | + | <div className="mt-8 flex gap-4 border-t border-line pt-3"> | |
| 20 | + | <Skeleton className="h-3 w-12" /> | |
| 21 | + | <Skeleton className="h-3 w-8" /> | |
| 22 | + | <Skeleton className="h-3 w-8" /> | |
| 23 | + | </div> | |
| 24 | + | </li> | |
| 25 | + | ); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** The Projects page's rows. */ | |
| 29 | + | export function ProjectRowsSkeleton({ rows = 8 }: { rows?: number }) { | |
| 30 | + | return ( | |
| 31 | + | <div aria-hidden="true" className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 32 | + | {Array.from({ length: rows }, (_, index) => ( | |
| 33 | + | <div key={index} className="flex items-center gap-3 border-b border-line px-4 py-3.5 last:border-b-0"> | |
| 34 | + | <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 35 | + | <div className="grow space-y-2"> | |
| 36 | + | <Skeleton className="h-3.5" style={{ width: `${30 + ((index * 13) % 25)}%` }} /> | |
| 37 | + | <Skeleton className="h-3" style={{ width: `${45 + ((index * 7) % 30)}%` }} /> | |
| 38 | + | </div> | |
| 39 | + | <Skeleton className="hidden h-3 w-16 sm:block" /> | |
| 40 | + | <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 41 | + | </div> | |
| 42 | + | ))} | |
| 43 | + | </div> | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** The Projects page: its search and filters, then rows. */ | |
| 48 | + | export function ProjectsSkeleton() { | |
| 49 | + | return ( | |
| 50 | + | <div aria-busy="true" aria-label="Loading projects" className="space-y-4"> | |
| 51 | + | <div className="flex flex-wrap gap-2"> | |
| 52 | + | <Skeleton className="h-9 w-full rounded-md sm:w-72" /> | |
| 53 | + | <Skeleton className="h-9 w-28 rounded-md" /> | |
| 54 | + | <Skeleton className="h-9 w-28 rounded-md" /> | |
| 55 | + | <Skeleton className="h-9 w-36 rounded-md" /> | |
| 56 | + | </div> | |
| 57 | + | <ProjectRowsSkeleton /> | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** The Overview: pinned cards, then recent ones beside the aside. */ | |
| 63 | + | export function OverviewSkeleton() { | |
| 64 | + | return ( | |
| 65 | + | <div aria-busy="true" aria-label="Loading" className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 66 | + | <div className="space-y-10"> | |
| 67 | + | <div> | |
| 68 | + | <Skeleton className="h-3.5 w-20" /> | |
| 69 | + | <ul className="mt-3 grid gap-4 sm:grid-cols-2"> | |
| 70 | + | {Array.from({ length: 4 }, (_, index) => ( | |
| 71 | + | <ProjectCardSkeleton key={index} /> | |
| 72 | + | ))} | |
| 73 | + | </ul> | |
| 74 | + | </div> | |
| 75 | + | <div> | |
| 76 | + | <Skeleton className="h-3.5 w-28" /> | |
| 77 | + | <SkeletonRows rows={4} className="mt-3 rounded-xl border border-line bg-surface" /> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | <div className="space-y-4"> | |
| 81 | + | <Skeleton className="h-40 rounded-xl" /> | |
| 82 | + | <SkeletonText lines={4} /> | |
| 83 | + | </div> | |
| 84 | + | </div> | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** Whichever tab is on its way. */ | |
| 89 | + | export function WorkspaceTabSkeleton({ tab }: { tab: WorkspaceTabKey }) { | |
| 90 | + | if (tab === "overview") return <OverviewSkeleton />; | |
| 91 | + | if (tab === "projects") return <ProjectsSkeleton />; | |
| 92 | + | return ( | |
| 93 | + | <div aria-busy="true" aria-label="Loading" className="space-y-4"> | |
| 94 | + | <Skeleton className="h-3.5 w-2/3 max-w-xl" /> | |
| 95 | + | <SkeletonRows rows={6} className="rounded-xl border border-line bg-surface" /> | |
| 96 | + | </div> | |
| 97 | + | ); | |
| 98 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { MAX_RECENT, movedPin, pinFromForm, recentWith } from "./pins.ts"; | |
| 5 | + | ||
| 6 | + | const p = (name: string, namespace = "acme") => ({ namespace, name, isPrivate: false }); | |
| 7 | + | const names = (list: { name: string }[]) => list.map((project) => project.name); | |
| 8 | + | ||
| 9 | + | test("the project being looked at leads recent, once", () => { | |
| 10 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("c"), "acme")), ["c", "a", "b"]); | |
| 11 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("b"), "acme")), ["b", "a"]); | |
| 12 | + | }); | |
| 13 | + | ||
| 14 | + | test("pinned projects are never also recent", () => { | |
| 15 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [p("a")], p("b"), "acme")), ["b"]); | |
| 16 | + | assert.deepEqual(names(recentWith([p("a")], [p("c")], p("c"), "acme")), ["a"]); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("another workspace's project is not this one's recent", () => { | |
| 20 | + | assert.deepEqual(names(recentWith([p("a")], [], p("x", "other"), "acme")), ["a"]); | |
| 21 | + | assert.deepEqual(names(recentWith([p("a")], [], p("x"), null)), ["a"]); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("recent holds a few", () => { | |
| 25 | + | const many = Array.from({ length: 9 }, (_, i) => p(`r${i}`)); | |
| 26 | + | assert.equal(recentWith(many, [], p("now"), "acme").length, MAX_RECENT); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("a pin form says what to change", () => { | |
| 30 | + | const form = (entries: [string, string][]) => { | |
| 31 | + | const data = new FormData(); | |
| 32 | + | for (const [key, value] of entries) data.append(key, value); | |
| 33 | + | return data; | |
| 34 | + | }; | |
| 35 | + | assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", " Web "]])), { intent: "pin", slug: "web", position: null }); | |
| 36 | + | assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", "web"], ["position", "2"]])), { intent: "pin", slug: "web", position: 2 }); | |
| 37 | + | assert.deepEqual(pinFromForm(form([["intent", "unpin"], ["slug", "web"]])), { intent: "unpin", slug: "web" }); | |
| 38 | + | assert.deepEqual(pinFromForm(form([["intent", "reorder"], ["slug", "b"], ["slug", "a"]])), { intent: "reorder", slugs: ["b", "a"] }); | |
| 39 | + | assert.equal(pinFromForm(form([["intent", "reorder"], ["slug", "a"], ["slug", "a"]])), null); | |
| 40 | + | assert.equal(pinFromForm(form([["intent", "pin"]])), null); | |
| 41 | + | assert.equal(pinFromForm(form([["intent", "star"], ["slug", "web"]])), null); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("a pin moves up or down within the list", () => { | |
| 45 | + | assert.deepEqual(movedPin(["a", "b", "c"], 2, -1), ["a", "c", "b"]); | |
| 46 | + | assert.deepEqual(movedPin(["a", "b", "c"], 0, 1), ["b", "a", "c"]); | |
| 47 | + | assert.equal(movedPin(["a", "b", "c"], 0, -1), null); | |
| 48 | + | assert.equal(movedPin(["a", "b", "c"], 2, 1), null); | |
| 49 | + | }); |
| 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 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | DEFAULT_QUERY, | |
| 6 | + | type Listed, | |
| 7 | + | PAGE_SIZE, | |
| 8 | + | facetsOf, | |
| 9 | + | filterProjects, | |
| 10 | + | isFiltered, | |
| 11 | + | nextRow, | |
| 12 | + | pageOf, | |
| 13 | + | projectQueryString, | |
| 14 | + | readProjectQuery, | |
| 15 | + | } from "./project-list.ts"; | |
| 16 | + | ||
| 17 | + | function project(slug: string, more: Partial<Listed> = {}): Listed { | |
| 18 | + | return { | |
| 19 | + | id: `prj_${slug}`, | |
| 20 | + | slug, | |
| 21 | + | name: slug, | |
| 22 | + | description: null, | |
| 23 | + | private: false, | |
| 24 | + | archived: false, | |
| 25 | + | kind: "app", | |
| 26 | + | ecosystem: null, | |
| 27 | + | updatedAt: "2026-10-01T00:00:00.000Z", | |
| 28 | + | pushedAt: null, | |
| 29 | + | activity: 0, | |
| 30 | + | deploying: false, | |
| 31 | + | ...more, | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | const q = (search: string) => readProjectQuery(new URLSearchParams(search)); | |
| 36 | + | ||
| 37 | + | test("an empty address is the default list", () => { | |
| 38 | + | assert.deepEqual(q(""), DEFAULT_QUERY); | |
| 39 | + | assert.equal(projectQueryString(DEFAULT_QUERY), ""); | |
| 40 | + | assert.equal(isFiltered(DEFAULT_QUERY), false); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("what the address says is read, and nonsense is the default", () => { | |
| 44 | + | const query = q("q=%20api%20&visibility=private&kind=library&language=Rust&deployments=on&archived=only&sort=active&view=grid&page=3"); | |
| 45 | + | assert.deepEqual(query, { | |
| 46 | + | q: "api", | |
| 47 | + | visibility: "private", | |
| 48 | + | kind: "library", | |
| 49 | + | language: "Rust", | |
| 50 | + | deployments: true, | |
| 51 | + | archived: "only", | |
| 52 | + | sort: "active", | |
| 53 | + | view: "grid", | |
| 54 | + | page: 3, | |
| 55 | + | }); | |
| 56 | + | assert.deepEqual(q("visibility=secret&sort=random&page=-2&view=table"), DEFAULT_QUERY); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("the address round-trips, and a change goes back to page one", () => { | |
| 60 | + | const query = q("q=web&sort=name&page=4"); | |
| 61 | + | assert.equal(projectQueryString(query, { page: 4 }), "?q=web&sort=name&page=4"); | |
| 62 | + | assert.equal(projectQueryString(query, { kind: "library" }), "?q=web&kind=library&sort=name"); | |
| 63 | + | assert.deepEqual(q(projectQueryString(query, { page: 4 }).slice(1)), query); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("archived projects are left out unless asked for", () => { | |
| 67 | + | const list = [project("live"), project("old", { archived: true })]; | |
| 68 | + | assert.deepEqual(filterProjects(list, DEFAULT_QUERY).map((p) => p.slug), ["live"]); | |
| 69 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, archived: "only" }).map((p) => p.slug), ["old"]); | |
| 70 | + | assert.equal(filterProjects(list, { ...DEFAULT_QUERY, archived: "include" }).length, 2); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("filters narrow by visibility, kind, language and deployments", () => { | |
| 74 | + | const list = [ | |
| 75 | + | project("site", { deploying: true, ecosystem: "npm" }), | |
| 76 | + | project("lib", { kind: "library", ecosystem: "cargo", private: true }), | |
| 77 | + | project("tool", { ecosystem: "go" }), | |
| 78 | + | ]; | |
| 79 | + | const slugs = (change: object) => filterProjects(list, { ...DEFAULT_QUERY, ...change }).map((p) => p.slug).sort(); | |
| 80 | + | assert.deepEqual(slugs({ visibility: "private" }), ["lib"]); | |
| 81 | + | assert.deepEqual(slugs({ visibility: "public" }), ["site", "tool"]); | |
| 82 | + | assert.deepEqual(slugs({ kind: "library" }), ["lib"]); | |
| 83 | + | assert.deepEqual(slugs({ language: "Rust" }), ["lib"]); | |
| 84 | + | assert.deepEqual(slugs({ language: "JavaScript" }), ["site"]); | |
| 85 | + | assert.deepEqual(slugs({ deployments: true }), ["site"]); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("search matches every word, in the name, slug or description, names that start with it first", () => { | |
| 89 | + | const list = [ | |
| 90 | + | project("docs-site", { name: "Docs site", description: "The API reference" }), | |
| 91 | + | project("api", { name: "API" }), | |
| 92 | + | project("billing", { description: "charges cards" }), | |
| 93 | + | ]; | |
| 94 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api" }).map((p) => p.slug), ["api", "docs-site"]); | |
| 95 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api reference" }).map((p) => p.slug), ["docs-site"]); | |
| 96 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "CARDS" }).map((p) => p.slug), ["billing"]); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | test("each sort orders as it says, ties by name", () => { | |
| 100 | + | const list = [ | |
| 101 | + | project("b", { updatedAt: "2026-10-01T00:00:00Z", pushedAt: "2026-10-05T00:00:00Z", activity: 1 }), | |
| 102 | + | project("a", { updatedAt: "2026-10-03T00:00:00Z", activity: 9 }), | |
| 103 | + | project("c", { updatedAt: "2026-10-02T00:00:00Z", pushedAt: "2026-10-04T00:00:00Z", activity: 1 }), | |
| 104 | + | ]; | |
| 105 | + | const order = (sort: string) => filterProjects(list, { ...DEFAULT_QUERY, sort: sort as never }).map((p) => p.slug); | |
| 106 | + | assert.deepEqual(order("name"), ["a", "b", "c"]); | |
| 107 | + | // Updated is the later of its own changes and its last push. | |
| 108 | + | assert.deepEqual(order("updated"), ["b", "c", "a"]); | |
| 109 | + | assert.deepEqual(order("pushed"), ["b", "c", "a"]); | |
| 110 | + | assert.deepEqual(order("active"), ["a", "b", "c"]); | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test("pages hold PAGE_SIZE, and a page past the end is the last", () => { | |
| 114 | + | const list = Array.from({ length: 412 }, (_, i) => i); | |
| 115 | + | const first = pageOf(list, 1); | |
| 116 | + | assert.equal(first.items.length, PAGE_SIZE); | |
| 117 | + | assert.equal(first.pages, 14); | |
| 118 | + | assert.deepEqual([first.from, first.to], [1, 30]); | |
| 119 | + | const last = pageOf(list, 99); | |
| 120 | + | assert.equal(last.page, 14); | |
| 121 | + | assert.deepEqual([last.from, last.to], [391, 412]); | |
| 122 | + | assert.deepEqual(pageOf([], 1), { items: [], page: 1, pages: 1, from: 0, to: 0 }); | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | test("facets count the whole workspace", () => { | |
| 126 | + | const facets = facetsOf([ | |
| 127 | + | project("a", { ecosystem: "npm", deploying: true }), | |
| 128 | + | project("b", { ecosystem: "npm", private: true, kind: "library" }), | |
| 129 | + | project("c", { ecosystem: "go", archived: true }), | |
| 130 | + | ]); | |
| 131 | + | assert.deepEqual(facets.visibility, { public: 2, private: 1 }); | |
| 132 | + | assert.deepEqual(facets.kind, { app: 2, library: 1 }); | |
| 133 | + | assert.deepEqual(facets.languages, [{ name: "JavaScript", count: 2 }, { name: "Go", count: 1 }]); | |
| 134 | + | assert.equal(facets.deploying, 1); | |
| 135 | + | assert.equal(facets.archived, 1); | |
| 136 | + | }); | |
| 137 | + | ||
| 138 | + | test("the keyboard moves through rows and stops at the ends", () => { | |
| 139 | + | assert.equal(nextRow("ArrowDown", -1, 3), 0); | |
| 140 | + | assert.equal(nextRow("j", 0, 3), 1); | |
| 141 | + | assert.equal(nextRow("ArrowDown", 2, 3), 2); | |
| 142 | + | assert.equal(nextRow("k", 0, 3), 0); | |
| 143 | + | assert.equal(nextRow("End", 0, 3), 2); | |
| 144 | + | assert.equal(nextRow("Home", 2, 3), 0); | |
| 145 | + | assert.equal(nextRow("x", 0, 3), null); | |
| 146 | + | assert.equal(nextRow("ArrowDown", 0, 0), null); | |
| 147 | + | }); |
| 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 | + | ||
| 8 | + | import type { Project, ProjectEcosystem } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | export type ProjectSort = "updated" | "name" | "active" | "pushed"; | |
| 11 | + | export type ProjectView = "list" | "grid"; | |
| 12 | + | ||
| 13 | + | export type ProjectQuery = { | |
| 14 | + | /** Words in the name, slug or description. */ | |
| 15 | + | q: string; | |
| 16 | + | visibility: "all" | "public" | "private"; | |
| 17 | + | kind: "all" | "app" | "library"; | |
| 18 | + | /** A language, from the manifests at the project's root. */ | |
| 19 | + | language: string | null; | |
| 20 | + | /** Only projects with Deployments on. */ | |
| 21 | + | deployments: boolean; | |
| 22 | + | /** Archived projects: left out (the default), shown too, or only them. */ | |
| 23 | + | archived: "hide" | "include" | "only"; | |
| 24 | + | sort: ProjectSort; | |
| 25 | + | view: ProjectView; | |
| 26 | + | /** 1 first. */ | |
| 27 | + | page: number; | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | /** Projects to a page. */ | |
| 31 | + | export const PAGE_SIZE = 30; | |
| 32 | + | ||
| 33 | + | export const DEFAULT_QUERY: ProjectQuery = { | |
| 34 | + | q: "", | |
| 35 | + | visibility: "all", | |
| 36 | + | kind: "all", | |
| 37 | + | language: null, | |
| 38 | + | deployments: false, | |
| 39 | + | archived: "hide", | |
| 40 | + | sort: "updated", | |
| 41 | + | view: "list", | |
| 42 | + | page: 1, | |
| 43 | + | }; | |
| 44 | + | ||
| 45 | + | export const SORTS: { value: ProjectSort; label: string }[] = [ | |
| 46 | + | { value: "updated", label: "Recently updated" }, | |
| 47 | + | { value: "pushed", label: "Recently pushed" }, | |
| 48 | + | { value: "active", label: "Most active" }, | |
| 49 | + | { value: "name", label: "Name" }, | |
| 50 | + | ]; | |
| 51 | + | ||
| 52 | + | /** The language a project's manifests say it is written in. */ | |
| 53 | + | export const LANGUAGE_OF: Record<ProjectEcosystem, string> = { | |
| 54 | + | npm: "JavaScript", | |
| 55 | + | cargo: "Rust", | |
| 56 | + | go: "Go", | |
| 57 | + | composer: "PHP", | |
| 58 | + | python: "Python", | |
| 59 | + | }; | |
| 60 | + | ||
| 61 | + | /** What the list needs of a project, with whether its Deployments are on. */ | |
| 62 | + | export type Listed = Pick< | |
| 63 | + | Project, | |
| 64 | + | "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity" | |
| 65 | + | > & { deploying: boolean }; | |
| 66 | + | ||
| 67 | + | const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T => | |
| 68 | + | (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback; | |
| 69 | + | ||
| 70 | + | /** What the address asks for; anything it does not say, or says wrong, is the default. */ | |
| 71 | + | export function readProjectQuery(params: URLSearchParams): ProjectQuery { | |
| 72 | + | const page = Number.parseInt(params.get("page") ?? "", 10); | |
| 73 | + | return { | |
| 74 | + | q: (params.get("q") ?? "").trim().slice(0, 100), | |
| 75 | + | visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"), | |
| 76 | + | kind: one(params.get("kind"), ["all", "app", "library"] as const, "all"), | |
| 77 | + | language: params.get("language")?.trim() || null, | |
| 78 | + | deployments: params.get("deployments") === "on", | |
| 79 | + | archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"), | |
| 80 | + | sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"), | |
| 81 | + | view: one(params.get("view"), ["list", "grid"] as const, "list"), | |
| 82 | + | page: Number.isFinite(page) && page > 0 ? page : 1, | |
| 83 | + | }; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /** | |
| 87 | + | * The address for `query` with `change` made, leaving out what is the | |
| 88 | + | * default. Changing anything but the page goes back to the first page. | |
| 89 | + | */ | |
| 90 | + | export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string { | |
| 91 | + | const next = { ...query, ...change }; | |
| 92 | + | if (!("page" in change)) next.page = 1; | |
| 93 | + | const params = new URLSearchParams(); | |
| 94 | + | if (next.q) params.set("q", next.q); | |
| 95 | + | if (next.visibility !== "all") params.set("visibility", next.visibility); | |
| 96 | + | if (next.kind !== "all") params.set("kind", next.kind); | |
| 97 | + | if (next.language) params.set("language", next.language); | |
| 98 | + | if (next.deployments) params.set("deployments", "on"); | |
| 99 | + | if (next.archived !== "hide") params.set("archived", next.archived); | |
| 100 | + | if (next.sort !== "updated") params.set("sort", next.sort); | |
| 101 | + | if (next.view !== "list") params.set("view", next.view); | |
| 102 | + | if (next.page > 1) params.set("page", String(next.page)); | |
| 103 | + | const text = params.toString(); | |
| 104 | + | return text ? `?${text}` : ""; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Whether anything narrows the list, beyond leaving archived projects out. */ | |
| 108 | + | export function isFiltered(query: ProjectQuery): boolean { | |
| 109 | + | return Boolean( | |
| 110 | + | query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide", | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /** When it last changed: its own settings, or a push, whichever is later. */ | |
| 115 | + | export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string { | |
| 116 | + | return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function matches(project: Listed, words: string[]): boolean { | |
| 120 | + | const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase(); | |
| 121 | + | return words.every((word) => text.includes(word)); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** The projects `query` asks for, in its order, before paging. */ | |
| 125 | + | export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] { | |
| 126 | + | const words = query.q.toLowerCase().split(/\s+/).filter(Boolean); | |
| 127 | + | const shown = projects.filter( | |
| 128 | + | (project) => | |
| 129 | + | (query.archived === "include" || (query.archived === "only") === project.archived) && | |
| 130 | + | (query.visibility === "all" || (query.visibility === "private") === project.private) && | |
| 131 | + | (query.kind === "all" || project.kind === query.kind) && | |
| 132 | + | (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) && | |
| 133 | + | (!query.deployments || project.deploying) && | |
| 134 | + | (words.length === 0 || matches(project, words)), | |
| 135 | + | ); | |
| 136 | + | return sortProjects(shown, query.sort, words); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug); | |
| 140 | + | ||
| 141 | + | /** | |
| 142 | + | * In `sort`'s order, ties by name. While searching, a name that starts | |
| 143 | + | * with what was typed comes first. | |
| 144 | + | */ | |
| 145 | + | export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] { | |
| 146 | + | const first = words[0]; | |
| 147 | + | const leads = (project: Listed) => | |
| 148 | + | first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1; | |
| 149 | + | const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = { | |
| 150 | + | name: byName, | |
| 151 | + | updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)), | |
| 152 | + | pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""), | |
| 153 | + | active: (a, b) => b.activity - a.activity, | |
| 154 | + | }; | |
| 155 | + | return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b)); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** One page of them, and where it sits among the rest. */ | |
| 159 | + | export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } { | |
| 160 | + | const pages = Math.max(1, Math.ceil(list.length / size)); | |
| 161 | + | const current = Math.min(Math.max(1, page), pages); | |
| 162 | + | const start = (current - 1) * size; | |
| 163 | + | const items = list.slice(start, start + size); | |
| 164 | + | return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length }; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | export type Facets = { | |
| 168 | + | visibility: { public: number; private: number }; | |
| 169 | + | kind: { app: number; library: number }; | |
| 170 | + | languages: { name: string; count: number }[]; | |
| 171 | + | deploying: number; | |
| 172 | + | archived: number; | |
| 173 | + | }; | |
| 174 | + | ||
| 175 | + | /** How many of the workspace's projects each filter would show, before any is chosen. */ | |
| 176 | + | export function facetsOf(projects: Listed[]): Facets { | |
| 177 | + | const languages = new Map<string, number>(); | |
| 178 | + | const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0 }, languages: [], deploying: 0, archived: 0 }; | |
| 179 | + | for (const project of projects) { | |
| 180 | + | facets.visibility[project.private ? "private" : "public"]++; | |
| 181 | + | facets.kind[project.kind]++; | |
| 182 | + | if (project.deploying) facets.deploying++; | |
| 183 | + | if (project.archived) facets.archived++; | |
| 184 | + | if (project.ecosystem) { | |
| 185 | + | const language = LANGUAGE_OF[project.ecosystem]; | |
| 186 | + | languages.set(language, (languages.get(language) ?? 0) + 1); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); | |
| 190 | + | return facets; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /** | |
| 194 | + | * The index of the row to move to from `at` by key, in a list of `count` | |
| 195 | + | * rows: arrows and j/k move one, Home and End to either end. Null for any | |
| 196 | + | * other key. | |
| 197 | + | */ | |
| 198 | + | export function nextRow(key: string, at: number, count: number): number | null { | |
| 199 | + | if (count === 0) return null; | |
| 200 | + | switch (key) { | |
| 201 | + | case "ArrowDown": | |
| 202 | + | case "j": | |
| 203 | + | return Math.min(count - 1, at + 1); | |
| 204 | + | case "ArrowUp": | |
| 205 | + | case "k": | |
| 206 | + | return Math.max(0, at - 1); | |
| 207 | + | case "Home": | |
| 208 | + | return 0; | |
| 209 | + | case "End": | |
| 210 | + | return count - 1; | |
| 211 | + | default: | |
| 212 | + | return null; | |
| 213 | + | } | |
| 214 | + | } |
| 66 | 66 | today: { label: "Outcomes", path: "plans" }, | |
| 67 | 67 | }, | |
| 68 | 68 | { | |
| 69 | + | key: "insights", | |
| 70 | + | title: "Insights", | |
| 71 | + | section: "Workspace", | |
| 72 | + | summary: "How the whole workspace delivers: lead time, reviews, what agents do and what it costs, across every project.", | |
| 73 | + | why: "Each project will have its own numbers. The workspace's put them side by side, so you can see which projects ship steadily, where work waits, and where the money goes.", | |
| 74 | + | plans: [ | |
| 75 | + | "Delivery metrics for every project, side by side", | |
| 76 | + | "Where work waits: review, the merge queue, checks", | |
| 77 | + | "Agents' share of merged changes, and how often it lands first time", | |
| 78 | + | "Cost by project, by kind of work and per merged change", | |
| 79 | + | ], | |
| 80 | + | }, | |
| 81 | + | { | |
| 69 | 82 | key: "milestones", | |
| 70 | 83 | title: "Milestones", | |
| 71 | 84 | section: "Issues", |
| 1 | + | import type { Project, Result, Viewer } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import { shortCache } from "./cache.server"; | |
| 4 | + | import { projects } from "./services.server"; | |
| 5 | + | ||
| 6 | + | /** How long one isolate keeps a person's list of a workspace's projects. */ | |
| 7 | + | export const PROJECTS_TTL_MS = 15_000; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A workspace's projects as the viewer may see them: one listing, kept for | |
| 11 | + | * a few seconds per person and workspace (lib/cache.server.ts), so the | |
| 12 | + | * sidebar, the workspace's tabs and its Projects page share it. | |
| 13 | + | */ | |
| 14 | + | export function workspaceProjects(slug: string, viewer: Viewer): Promise<Result<Project[]>> { | |
| 15 | + | const workspace = slug.toLowerCase(); | |
| 16 | + | return shortCache(`shell:projects:${viewer?.id ?? "visitor"}:${workspace}`, PROJECTS_TTL_MS, () => projects.list(workspace, viewer)); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** What a project is in the sidebar and the command palette. */ | |
| 20 | + | export function shortcutOf(project: Project): { namespace: string; name: string; title: string; isPrivate: boolean } { | |
| 21 | + | return { namespace: project.workspace, name: project.slug, title: project.name, isPrivate: project.private }; | |
| 22 | + | } |
| 58 | 58 | import { countsFor, readableRepos } from "./lib/access.server"; | |
| 59 | 59 | import { shortCache } from "./lib/cache.server"; | |
| 60 | 60 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 61 | + | import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server"; | |
| 61 | 62 | import { registrationMode } from "./lib/registration.server"; | |
| 62 | 63 | import { addresses } from "./lib/addresses.server"; | |
| 63 | 64 | import { useSignUpCopy } from "./lib/registration"; | |
| 140 | 141 | // something (lib/cache.server.ts). | |
| 141 | 142 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 142 | 143 | workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null); | |
| 143 | − | const [listed, counts, status, usage, limit, entitlements, shared, unread] = await Promise.all([ | |
| 144 | − | kept("projects", () => projects.list(workspace!.slug, user)), | |
| 144 | + | const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts] = await Promise.all([ | |
| 145 | + | // Every project, for the palette and the count; the sidebar lists only | |
| 146 | + | // the person's pinned and recent ones (lib/pins.ts). | |
| 147 | + | workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null), | |
| 145 | 148 | path ? countsFor(context, params) : Promise.resolve(null), | |
| 146 | 149 | // Whether billing is on, without reading the account: that asks the | |
| 147 | 150 | // card processor about the workspace's cards, too slow for every page. | |
| 152 | 155 | sharedRepos(user), | |
| 153 | 156 | // The bell's count: one query, never kept, so marking an item shows at once. | |
| 154 | 157 | inbox.counts(user.username).catch(() => null), | |
| 158 | + | // Never kept: opening a project moves it up Recent. | |
| 159 | + | workspace ? projects.shortcuts(workspace.slug, user).catch(() => null) : Promise.resolve(null), | |
| 155 | 160 | ]); | |
| 156 | 161 | return { | |
| 157 | 162 | workspace, | |
| 158 | 163 | // Projects are what the sidebar lists: what the workspace builds and runs. | |
| 159 | − | repos: listed?.ok | |
| 160 | − | ? listed.value.map((project) => ({ | |
| 161 | − | namespace: project.workspace, | |
| 162 | − | name: project.slug, | |
| 163 | − | title: project.name, | |
| 164 | − | isPrivate: project.private, | |
| 165 | − | })) | |
| 166 | − | : [], | |
| 164 | + | repos: listed?.ok ? listed.value.map(shortcutOf) : [], | |
| 165 | + | pinned: (shortcuts?.pinned ?? []).map(shortcutOf), | |
| 166 | + | recent: (shortcuts?.recent ?? []).map(shortcutOf), | |
| 167 | 167 | repo: | |
| 168 | 168 | path && counts?.ok | |
| 169 | 169 | ? { |
| 62 | 62 | // A workspace's own pages sit under `-`, which no repository can be named. | |
| 63 | 63 | route(":owner", "routes/workspace/layout.tsx", [ | |
| 64 | 64 | index("routes/workspace/overview.tsx"), | |
| 65 | + | // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights. | |
| 66 | + | route("-/projects", "routes/workspace/projects.tsx"), | |
| 65 | 67 | route("-/people", "routes/workspace/people.tsx"), | |
| 68 | + | route("-/teams", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/teams" }), | |
| 69 | + | route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }), | |
| 70 | + | // Pinning its projects, for the person signed in. | |
| 71 | + | route("-/pins", "routes/workspace/pins.ts"), | |
| 72 | + | // Pages that moved: Members is People, and the overview is the workspace. | |
| 73 | + | route("-/members", "routes/workspace/moved.ts", { id: "routes/workspace/moved-members" }), | |
| 74 | + | route("-/overview", "routes/workspace/moved.ts", { id: "routes/workspace/moved-overview" }), | |
| 66 | 75 | route("-/tokens", "routes/workspace/tokens.tsx"), | |
| 67 | 76 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 68 | 77 | route("-/billing", "routes/workspace/billing.tsx"), |
| 1 | + | import { waitUntil } from "cloudflare:workers"; | |
| 1 | 2 | import { Box, Lock } from "lucide-react"; | |
| 2 | 3 | import type { ReactNode } from "react"; | |
| 3 | 4 | import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 10 | 11 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 11 | 12 | import { Pill } from "../../components/ui"; | |
| 12 | 13 | import { WatchMenu } from "../../components/notifications"; | |
| 14 | + | import { PinButton } from "../../components/pin-button"; | |
| 13 | 15 | import { ArchivedBanner } from "../../components/repo-lifecycle"; | |
| 14 | 16 | import { WelcomeBanner } from "../../components/welcome"; | |
| 15 | 17 | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| 17 | 19 | import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server"; | |
| 18 | 20 | import { accessFor, countsFor, repoFor } from "../../lib/access.server"; | |
| 19 | 21 | import { inbox, projects } from "../../lib/services.server"; | |
| 20 | − | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 22 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 21 | 23 | ||
| 22 | 24 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 23 | 25 | return page(args, { title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` }); | |
| 26 | 28 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 27 | 29 | const viewer = getViewer(context); | |
| 28 | 30 | const path = { namespace: params.owner, name: params.repo }; | |
| 29 | − | const [repo, counts, found, watching] = await Promise.all([ | |
| 31 | + | // Members pin the workspace's projects, and what they open is their Recent. | |
| 32 | + | const member = viewer ? roleIn(viewer, params.owner) != null : false; | |
| 33 | + | const [repo, counts, found, watching, shortcuts] = await Promise.all([ | |
| 30 | 34 | repoFor(context, params), | |
| 31 | 35 | countsFor(context, params), | |
| 32 | 36 | projects.get(params.owner, params.repo, viewer), | |
| 35 | 39 | viewer | |
| 36 | 40 | ? repoFor(context, params).then((found) => (found.ok ? inbox.watching(viewer.username, found.value.id).catch(() => null) : null)) | |
| 37 | 41 | : null, | |
| 42 | + | // Whether they pinned it, for the header's Pin button. | |
| 43 | + | member ? projects.shortcuts(params.owner, viewer).catch(() => null) : null, | |
| 38 | 44 | ]); | |
| 39 | 45 | if (!repo.ok && !found.ok) { | |
| 40 | 46 | // Under a workspace's old name, after a rename: the project is at the new one. | |
| 49 | 55 | const own = await projects.byRepo(repo.value.id); | |
| 50 | 56 | project = own.find((p) => p.slug === params.repo.toLowerCase()) ?? own[0] ?? null; | |
| 51 | 57 | } | |
| 58 | + | // Opened just now: it leads their Recent, recorded after the page is sent. | |
| 59 | + | if (member && project) waitUntil(projects.visited(viewer!, project.id).catch(() => undefined)); | |
| 52 | 60 | const value = unwrap(repo); | |
| 53 | 61 | // The viewer's role on the repository and what it lets them do, for the | |
| 54 | 62 | // pages under it and the sidebar. | |
| 64 | 72 | access, | |
| 65 | 73 | member: access.insider, | |
| 66 | 74 | watching, | |
| 75 | + | // Null when they cannot pin it: not one of their workspaces. | |
| 76 | + | pinned: member && project ? (shortcuts?.pinned ?? []).some((pinned) => pinned.id === project.id) : null, | |
| 67 | 77 | }, { headers }); | |
| 68 | 78 | } | |
| 69 | 79 | ||
| 161 | 171 | } | |
| 162 | 172 | ||
| 163 | 173 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 164 | − | const { repo, project, member, access, welcome, watching } = loaderData; | |
| 174 | + | const { repo, project, member, access, welcome, watching, pinned } = loaderData; | |
| 165 | 175 | const base = `/${repo.namespace}/${repo.name}`; | |
| 166 | 176 | // The project's own description, else the repository's as it is now. | |
| 167 | 177 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| 184 | 194 | // The files' own About says it there, as the one place. | |
| 185 | 195 | description={filesPage ? null : description} | |
| 186 | 196 | actions={ | |
| 187 | − | watching ? ( | |
| 188 | − | <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} /> | |
| 197 | + | watching || (project && pinned != null) ? ( | |
| 198 | + | <> | |
| 199 | + | {project && pinned != null && ( | |
| 200 | + | <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} /> | |
| 201 | + | )} | |
| 202 | + | {watching && <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} />} | |
| 203 | + | </> | |
| 189 | 204 | ) : null | |
| 190 | 205 | } | |
| 191 | 206 | /> |
| 1 | 1 | import { Plus } from "lucide-react"; | |
| 2 | − | import { Outlet, data, useLocation } from "react-router"; | |
| 2 | + | import { NavLink, Outlet, data, redirect, useLocation, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/layout"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { notFound } from "../../lib/not-found.server"; | |
| 10 | 10 | import { redirectIfRenamed } from "../../lib/renamed.server"; | |
| 11 | 11 | import { rememberWorkspace } from "../../lib/workspace-choice"; | |
| 12 | + | import { type WorkspaceTab, type WorkspaceTabKey, workspaceRedirect, workspaceTab, workspaceTabs } from "../../lib/workspace-nav"; | |
| 13 | + | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 14 | + | import { WorkspaceTabSkeleton } from "../../components/workspace-skeletons"; | |
| 12 | 15 | import { identity } from "../../lib/services.server"; | |
| 13 | 16 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 14 | 17 | ||
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 20 | − | const workspace = await identity.getWorkspace(params.owner); | |
| 23 | + | const url = new URL(request.url); | |
| 24 | + | // `?tab=projects` and the like, from habit: that tab's own address. | |
| 25 | + | const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null; | |
| 26 | + | if (moved) throw redirect(moved); | |
| 27 | + | const viewer = getViewer(context); | |
| 28 | + | // Under the tabs, the count of projects beside Projects: the same | |
| 29 | + | // listing the sidebar and the Projects page read, asked at once. | |
| 30 | + | const tabbed = workspaceTab(url.pathname, params.owner) != null; | |
| 31 | + | const [workspace, listed] = await Promise.all([ | |
| 32 | + | identity.getWorkspace(params.owner), | |
| 33 | + | tabbed ? workspaceProjects(params.owner, viewer).catch(() => null) : null, | |
| 34 | + | ]); | |
| 21 | 35 | if (!workspace) { | |
| 22 | 36 | // A workspace's old name, after a rename: its pages are at the new one. | |
| 23 | 37 | await redirectIfRenamed(request, params.owner); | |
| 24 | 38 | throw notFound("workspace"); | |
| 25 | 39 | } | |
| 26 | − | const role = roleIn(getViewer(context), workspace.slug); | |
| 40 | + | const role = roleIn(viewer, workspace.slug); | |
| 41 | + | const projectCount = listed?.ok ? listed.value.length : null; | |
| 27 | 42 | // Opening one of your workspaces makes it the one you are in. | |
| 28 | − | if (!role) return { workspace, role, welcome: false }; | |
| 43 | + | if (!role) return { workspace, role, welcome: false, projectCount }; | |
| 29 | 44 | const secure = new URL(request.url).protocol === "https:"; | |
| 30 | 45 | const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) }); | |
| 31 | 46 | // Just joined with an invite: welcomed once (routes/invite.tsx). | |
| 32 | 47 | const welcome = welcomes(request.headers.get("cookie"), workspace.slug); | |
| 33 | 48 | if (welcome) headers.append("Set-Cookie", clearWelcome(secure)); | |
| 34 | − | return data({ workspace, role, welcome }, { headers }); | |
| 49 | + | return data({ workspace, role, welcome, projectCount }, { headers }); | |
| 35 | 50 | } | |
| 36 | 51 | ||
| 37 | 52 | /** A workspace's own pages, each with its title and what it is for. */ | |
| 38 | 53 | const PAGES: Record<string, { title: string; about: string }> = { | |
| 39 | 54 | settings: { title: "General", about: "The workspace's name, icon, address and description, and deleting it." }, | |
| 40 | 55 | people: { | |
| 41 | − | title: "Members", | |
| 56 | + | title: "People", | |
| 42 | 57 | about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.", | |
| 43 | 58 | }, | |
| 59 | + | projects: { | |
| 60 | + | title: "Projects", | |
| 61 | + | about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.", | |
| 62 | + | }, | |
| 44 | 63 | repositories: { | |
| 45 | 64 | title: "Repositories", | |
| 46 | 65 | about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.", | |
| 97 | 116 | }, | |
| 98 | 117 | }; | |
| 99 | 118 | ||
| 119 | + | /** | |
| 120 | + | * The workspace's tabs, as a project's are: a row under its header that | |
| 121 | + | * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with | |
| 122 | + | * a dot. | |
| 123 | + | */ | |
| 124 | + | function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) { | |
| 125 | + | return ( | |
| 126 | + | <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 127 | + | {tabs.map((tab) => ( | |
| 128 | + | <NavLink | |
| 129 | + | key={tab.key} | |
| 130 | + | to={tab.to} | |
| 131 | + | end={tab.key === "overview"} | |
| 132 | + | prefetch="intent" | |
| 133 | + | aria-current={current === tab.key ? "page" : undefined} | |
| 134 | + | className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ | |
| 135 | + | current === tab.key | |
| 136 | + | ? "border-accent font-medium text-fg" | |
| 137 | + | : tab.soon | |
| 138 | + | ? "border-transparent text-faint hover:text-muted" | |
| 139 | + | : "border-transparent text-muted hover:text-fg" | |
| 140 | + | }`} | |
| 141 | + | > | |
| 142 | + | {tab.label} | |
| 143 | + | {tab.count != null && ( | |
| 144 | + | <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span> | |
| 145 | + | )} | |
| 146 | + | {tab.soon && ( | |
| 147 | + | <> | |
| 148 | + | <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" /> | |
| 149 | + | <span className="sr-only">(soon)</span> | |
| 150 | + | </> | |
| 151 | + | )} | |
| 152 | + | </NavLink> | |
| 153 | + | ))} | |
| 154 | + | </nav> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 100 | 158 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 101 | − | const { workspace, role, welcome } = loaderData; | |
| 102 | − | // The sidebar finds the workspace's pages, for everyone, so its pages | |
| 159 | + | const { workspace, role, welcome, projectCount } = loaderData; | |
| 160 | + | const { pathname } = useLocation(); | |
| 161 | + | const going = useNavigation().location; | |
| 162 | + | const tab = workspaceTab(pathname, workspace.slug); | |
| 163 | + | // The sidebar finds the workspace's other pages, for everyone, so they | |
| 103 | 164 | // need a title, not the workspace's whole header again. | |
| 104 | − | const parts = (useLocation().pathname.split("/-/")[1] ?? "").split("/").filter(Boolean); | |
| 105 | − | const page = PAGES[parts[0] ?? ""]; | |
| 165 | + | const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean); | |
| 166 | + | const page = tab ? undefined : PAGES[parts[0] ?? ""]; | |
| 106 | 167 | // A page within one (a single package) has its own heading. | |
| 107 | 168 | if (page && parts.length > 1) { | |
| 108 | 169 | return ( | |
| 122 | 183 | </div> | |
| 123 | 184 | ); | |
| 124 | 185 | } | |
| 186 | + | const tabs = workspaceTabs(workspace.slug, { | |
| 187 | + | member: role != null, | |
| 188 | + | owner: role === "owner", | |
| 189 | + | projects: projectCount, | |
| 190 | + | people: role ? workspace.memberCount : null, | |
| 191 | + | }); | |
| 192 | + | // On the way to another of its tabs: that tab's shape until it arrives. | |
| 193 | + | const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null; | |
| 125 | 194 | return ( | |
| 126 | 195 | <> | |
| 127 | − | {/* The workspace's own header band. */} | |
| 196 | + | {/* The workspace's own header band, with its tabs along the bottom. */} | |
| 128 | 197 | <div className="border-b border-line bg-surface/60"> | |
| 129 | − | <div className="mx-auto max-w-6xl px-4 pt-8 pb-8"> | |
| 198 | + | <div className="mx-auto max-w-6xl px-4 pt-8"> | |
| 130 | 199 | {welcome && ( | |
| 131 | 200 | <div className="mb-6"> | |
| 132 | 201 | <WelcomeBanner title={`You're in ${workspace.name}`}> | |
| 155 | 224 | {workspace.description && ( | |
| 156 | 225 | <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p> | |
| 157 | 226 | )} | |
| 227 | + | <div className="mt-6"> | |
| 228 | + | <WorkspaceTabs tabs={tabs} current={next ?? tab} /> | |
| 229 | + | </div> | |
| 158 | 230 | </div> | |
| 159 | 231 | </div> | |
| 160 | 232 | <div className="mx-auto max-w-6xl px-4 py-8"> | |
| 161 | − | <Outlet /> | |
| 233 | + | {/* What a tab is for, said once above it. */} | |
| 234 | + | {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>} | |
| 235 | + | {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />} | |
| 162 | 236 | </div> | |
| 163 | 237 | </> | |
| 164 | 238 | ); |
| 1 | + | /** | |
| 2 | + | * Workspace pages that moved: `-/members` is People now, and `-/overview` | |
| 3 | + | * the workspace's own page (lib/workspace-nav.ts). | |
| 4 | + | */ | |
| 5 | + | import { data, redirect } from "react-router"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/moved"; | |
| 8 | + | import { workspaceRedirect } from "../../lib/workspace-nav"; | |
| 9 | + | ||
| 10 | + | export function loader({ request }: Route.LoaderArgs) { | |
| 11 | + | const url = new URL(request.url); | |
| 12 | + | const to = workspaceRedirect(url.pathname, url.search); | |
| 13 | + | if (!to) throw data(null, { status: 404 }); | |
| 14 | + | return redirect(to, 301); | |
| 15 | + | } |
| 1 | − | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react"; | |
| 1 | + | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts"; | |
| 12 | 12 | import { openedBy } from "../../lib/opened-by"; | |
| 13 | 13 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 14 | 14 | import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind"; | |
| 15 | + | import { PinButton } from "../../components/pin-button"; | |
| 16 | + | import { sortProjects } from "../../lib/project-list"; | |
| 15 | 17 | import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server"; | |
| 16 | 18 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 19 | + | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 17 | 20 | ||
| 18 | − | /** Projects whose open issues and pull requests are counted. */ | |
| 21 | + | /** Projects whose open issues and pull requests are counted: the most active. */ | |
| 19 | 22 | const MAX_COUNTED = 30; | |
| 23 | + | /** Projects shown under Recently active, after the pins. */ | |
| 24 | + | const MAX_ACTIVE = 6; | |
| 20 | 25 | /** Projects whose pull requests are listed under "In progress". */ | |
| 21 | 26 | const MAX_LISTED = 8; | |
| 22 | 27 | const MAX_PULLS = 8; | |
| 58 | 63 | const viewer = getViewer(context); | |
| 59 | 64 | const slug = params.owner.toLowerCase(); | |
| 60 | 65 | const role = roleIn(viewer, slug); | |
| 61 | − | const [listed, members, deploys, usage, published] = await Promise.all([ | |
| 62 | − | projectsApi.list(slug, viewer), | |
| 66 | + | const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([ | |
| 67 | + | // The listing the sidebar and the Projects tab share. | |
| 68 | + | workspaceProjects(slug, viewer), | |
| 63 | 69 | role ? identity.listMembers(slug, viewer) : null, | |
| 64 | 70 | role ? deployments.overview(slug, viewer) : null, | |
| 65 | 71 | role ? usageFor(slug, viewer) : null, | |
| 66 | 72 | // One listing for every card: a library's card shows its package where an app's shows production. | |
| 67 | 73 | packages.list(slug, viewer).catch(() => null), | |
| 74 | + | // The viewer's pins lead the page, as they lead the sidebar. | |
| 75 | + | role ? projectsApi.shortcuts(slug, viewer).catch(() => null) : null, | |
| 68 | 76 | ]); | |
| 69 | 77 | const projects = listed.ok ? listed.value : []; | |
| 78 | + | const pinnedIds = new Set((shortcuts?.pinned ?? []).map((project) => project.id)); | |
| 79 | + | const byId = new Map(projects.map((project) => [project.id, project])); | |
| 80 | + | const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null); | |
| 81 | + | // However many there are: the most active first, archived ones last. | |
| 82 | + | const byActivity = sortProjects( | |
| 83 | + | projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false })), | |
| 84 | + | "active", | |
| 85 | + | ) | |
| 86 | + | .sort((a, b) => Number(a.archived) - Number(b.archived)) | |
| 87 | + | .map((listedProject) => byId.get(listedProject.id)!); | |
| 88 | + | const active = byActivity.filter((project) => !pinnedIds.has(project.id)).slice(0, MAX_ACTIVE); | |
| 70 | 89 | ||
| 71 | 90 | const pathOf = (project: Project) => | |
| 72 | 91 | project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug }; | |
| 73 | − | const counted = projects.slice(0, MAX_COUNTED); | |
| 92 | + | const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length)); | |
| 74 | 93 | const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer))); | |
| 75 | 94 | const open: Record<string, { issues: number; pulls: number }> = {}; | |
| 76 | 95 | counted.forEach((project, i) => { | |
| 93 | 112 | if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]); | |
| 94 | 113 | } | |
| 95 | 114 | const packageOf: Record<string, PackageSummary> = {}; | |
| 96 | − | for (const project of projects) { | |
| 115 | + | for (const project of [...pinned, ...active]) { | |
| 97 | 116 | if (project.kind !== "library" || project.source.kind !== "hosted") continue; | |
| 98 | 117 | const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0]; | |
| 99 | 118 | if (first) packageOf[project.id] = first; | |
| 102 | 121 | return { | |
| 103 | 122 | slug, | |
| 104 | 123 | role, | |
| 105 | − | projects, | |
| 124 | + | total: projects.length, | |
| 125 | + | pinned, | |
| 126 | + | active, | |
| 106 | 127 | open, | |
| 107 | 128 | deploys: bySlug, | |
| 108 | 129 | packageOf, | |
| 154 | 175 | deploys, | |
| 155 | 176 | pkg, | |
| 156 | 177 | member, | |
| 178 | + | pinned, | |
| 157 | 179 | }: { | |
| 158 | 180 | project: Project; | |
| 159 | 181 | open: { issues: number; pulls: number } | undefined; | |
| 161 | 183 | /** For a library, the package it publishes. */ | |
| 162 | 184 | pkg: PackageSummary | undefined; | |
| 163 | 185 | member: boolean; | |
| 186 | + | /** Whether the viewer pinned it; null when they cannot pin. */ | |
| 187 | + | pinned: boolean | null; | |
| 164 | 188 | }) { | |
| 165 | 189 | const library = project.kind === "library"; | |
| 166 | 190 | const base = `/${project.workspace}/${project.slug}`; | |
| 204 | 228 | {project.archived && <Pill>archived</Pill>} | |
| 205 | 229 | </span> | |
| 206 | 230 | )} | |
| 231 | + | {pinned != null && ( | |
| 232 | + | <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1.5 -mr-2" /> | |
| 233 | + | )} | |
| 207 | 234 | </div> | |
| 208 | 235 | ||
| 209 | 236 | {project.description && (production || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>} | |
| 248 | 275 | } | |
| 249 | 276 | ||
| 250 | 277 | export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) { | |
| 251 | − | const { slug, role, projects, open, deploys, pulls, members, usage } = loaderData; | |
| 278 | + | const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData; | |
| 279 | + | const card = (project: Project, isPinned: boolean) => ( | |
| 280 | + | <ProjectCard | |
| 281 | + | key={project.id} | |
| 282 | + | project={project} | |
| 283 | + | open={open[project.id]} | |
| 284 | + | deploys={deploys[project.slug]} | |
| 285 | + | pkg={loaderData.packageOf[project.id]} | |
| 286 | + | member={role != null} | |
| 287 | + | pinned={role ? isPinned : null} | |
| 288 | + | /> | |
| 289 | + | ); | |
| 252 | 290 | const totals = Object.values(open).reduce( | |
| 253 | 291 | (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }), | |
| 254 | 292 | { issues: 0, pulls: 0 }, | |
| 257 | 295 | return ( | |
| 258 | 296 | <div className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 259 | 297 | <div className="min-w-0 space-y-10"> | |
| 260 | − | {projects.length === 0 && role ? ( | |
| 298 | + | {total === 0 && role ? ( | |
| 261 | 299 | <GetStarted slug={slug} /> | |
| 262 | 300 | ) : ( | |
| 263 | 301 | <> | |
| 264 | 302 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> | |
| 265 | − | <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} /> | |
| 303 | + | <Stat value={total} label={total === 1 ? "Project" : "Projects"} /> | |
| 266 | 304 | <Stat value={liveApps} label="In production" /> | |
| 267 | 305 | <Stat value={totals.issues} label="Open issues" /> | |
| 268 | 306 | <Stat value={totals.pulls} label="Pull requests in progress" /> | |
| 269 | 307 | </div> | |
| 270 | 308 | ||
| 271 | − | <section> | |
| 272 | − | <div className="flex items-center justify-between"> | |
| 273 | − | <h2 className="text-sm font-medium text-muted">Projects</h2> | |
| 274 | − | {role && ( | |
| 275 | − | <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 276 | − | <Plus size={13} /> | |
| 277 | − | New project | |
| 278 | − | </Link> | |
| 309 | + | {role && ( | |
| 310 | + | <section aria-labelledby="pinned"> | |
| 311 | + | <h2 id="pinned" className="text-sm font-medium text-muted"> | |
| 312 | + | Pinned | |
| 313 | + | </h2> | |
| 314 | + | {pinned.length > 0 ? ( | |
| 315 | + | <ul className="mt-3 grid gap-4 sm:grid-cols-2">{pinned.map((project) => card(project, true))}</ul> | |
| 316 | + | ) : ( | |
| 317 | + | <p className="mt-3 flex items-center gap-2 rounded-xl border border-dashed border-line px-4 py-3 text-sm text-muted"> | |
| 318 | + | <Pin size={14} className="shrink-0 text-faint" /> | |
| 319 | + | Pin the projects you use most, from their page or from Projects, to keep them here and in your sidebar. | |
| 320 | + | </p> | |
| 279 | 321 | )} | |
| 280 | − | </div> | |
| 281 | − | <ul className="mt-3 grid gap-4 sm:grid-cols-2"> | |
| 282 | − | {projects.map((project) => ( | |
| 283 | − | <ProjectCard | |
| 284 | − | key={project.id} | |
| 285 | − | project={project} | |
| 286 | − | open={open[project.id]} | |
| 287 | − | deploys={deploys[project.slug]} | |
| 288 | − | pkg={loaderData.packageOf[project.id]} | |
| 289 | − | member={role != null} | |
| 290 | − | /> | |
| 291 | − | ))} | |
| 292 | − | </ul> | |
| 293 | − | </section> | |
| 322 | + | </section> | |
| 323 | + | )} | |
| 324 | + | ||
| 325 | + | {active.length > 0 && ( | |
| 326 | + | <section aria-labelledby="active"> | |
| 327 | + | <div className="flex items-center justify-between"> | |
| 328 | + | <h2 id="active" className="text-sm font-medium text-muted"> | |
| 329 | + | {role ? "Recently active" : "Projects"} | |
| 330 | + | </h2> | |
| 331 | + | {role && ( | |
| 332 | + | <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 333 | + | <Plus size={13} /> | |
| 334 | + | New project | |
| 335 | + | </Link> | |
| 336 | + | )} | |
| 337 | + | </div> | |
| 338 | + | <ul className="mt-3 grid gap-4 sm:grid-cols-2">{active.map((project) => card(project, false))}</ul> | |
| 339 | + | </section> | |
| 340 | + | )} | |
| 341 | + | ||
| 342 | + | <Link | |
| 343 | + | to={`/${slug}/-/projects`} | |
| 344 | + | prefetch="intent" | |
| 345 | + | className="flex items-center justify-between rounded-xl border border-line bg-surface px-4 py-3 text-sm transition-colors hover:border-line-strong" | |
| 346 | + | > | |
| 347 | + | <span> | |
| 348 | + | All projects <span className="tabular-nums text-muted">({total})</span> | |
| 349 | + | </span> | |
| 350 | + | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 351 | + | Search, filter and sort | |
| 352 | + | <ArrowRight size={13} /> | |
| 353 | + | </span> | |
| 354 | + | </Link> | |
| 294 | 355 | ||
| 295 | 356 | {pulls.length > 0 && ( | |
| 296 | 357 | <section> |
| 27 | 27 | } from "../../lib/session.server"; | |
| 28 | 28 | ||
| 29 | 29 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 30 | − | return page(args, { title: `Members · ${params.owner} · g1t` }); | |
| 30 | + | return page(args, { title: `People · ${params.owner} · g1t` }); | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 1 | + | /** | |
| 2 | + | * Pinning a workspace's projects, for the person signed in: `intent` pin | |
| 3 | + | * (with `slug`, and `position` to put it somewhere other than the end), | |
| 4 | + | * unpin (with `slug`), or reorder (with every pinned `slug`, in order). | |
| 5 | + | * Posted by the pin buttons on a project's header and the Projects page, | |
| 6 | + | * and by dragging pins in the sidebar (lib/pins.ts). The projects service | |
| 7 | + | * keeps them; the sidebar and pages reload after. | |
| 8 | + | */ | |
| 9 | + | import { data, redirect } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { Route } from "./+types/pins"; | |
| 12 | + | import { pinFromForm } from "../../lib/pins"; | |
| 13 | + | import { projects } from "../../lib/services.server"; | |
| 14 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 15 | + | ||
| 16 | + | /** Nothing to see here: the workspace's projects are. */ | |
| 17 | + | export function loader({ params }: Route.LoaderArgs) { | |
| 18 | + | return redirect(`/${params.owner}/-/projects`); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 22 | + | assertSameOrigin(request); | |
| 23 | + | const user = requireUser(context, request); | |
| 24 | + | const change = pinFromForm(await request.formData()); | |
| 25 | + | if (!change) return data({ error: "Nothing to do." }, { status: 400 }); | |
| 26 | + | try { | |
| 27 | + | const result = | |
| 28 | + | change.intent === "pin" | |
| 29 | + | ? await projects.pin(user, params.owner, change.slug, change.position) | |
| 30 | + | : change.intent === "unpin" | |
| 31 | + | ? await projects.unpin(user, params.owner, change.slug) | |
| 32 | + | : await projects.reorderPins(user, params.owner, change.slugs); | |
| 33 | + | if (!result.ok) return data({ error: result.error.message }, { status: result.error.code === "not_found" ? 404 : 400 }); | |
| 34 | + | return { error: null, pinned: result.value.map((project) => project.slug) }; | |
| 35 | + | } catch { | |
| 36 | + | return data({ error: "That did not save. Try again in a moment." }, { status: 503 }); | |
| 37 | + | } | |
| 38 | + | } |
| 1 | + | import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react"; | |
| 2 | + | import { type KeyboardEvent, useEffect, useRef } from "react"; | |
| 3 | + | import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/projects"; | |
| 6 | + | import { host } from "../../components/deploy"; | |
| 7 | + | import { PinButton } from "../../components/pin-button"; | |
| 8 | + | import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui"; | |
| 9 | + | import { page as pageMeta } from "../../lib/meta"; | |
| 10 | + | import { | |
| 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"; | |
| 24 | + | import { deployments, projects } from "../../lib/services.server"; | |
| 25 | + | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 26 | + | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 27 | + | ||
| 28 | + | export 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 | + | */ | |
| 37 | + | export 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 | + | ||
| 78 | + | type Item = Route.ComponentProps["loaderData"]["items"][number]; | |
| 79 | + | ||
| 80 | + | const 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. */ | |
| 84 | + | function 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 | + | ||
| 103 | + | function 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 | + | ||
| 143 | + | function 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. */ | |
| 181 | + | function 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 | + | ||
| 187 | + | function 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 | + | ||
| 224 | + | export 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 | + |
| 1 | − | import { data } from "react-router"; | |
| 1 | + | import { data, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/soon"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | 5 | import { roadmapItem } from "../../lib/roadmap"; | |
| 6 | 6 | import { projects } from "../../lib/services.server"; | |
| 7 | 7 | import { getViewer } from "../../lib/session.server"; | |
| 8 | + | import { workspaceRedirect } from "../../lib/workspace-nav"; | |
| 8 | 9 | import { SoonView } from "../repo/soon"; | |
| 9 | 10 | ||
| 10 | 11 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | 17 | }); | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 20 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 21 | + | // Teams and Insights are tabs of the workspace's page now. | |
| 22 | + | const moved = workspaceRedirect(new URL(request.url).pathname); | |
| 23 | + | if (moved) throw redirect(moved, 301); | |
| 20 | 24 | const item = roadmapItem(params.feature); | |
| 21 | 25 | // Only what spans projects lives under the workspace. | |
| 22 | 26 | if (!item || item.section !== "Workspace") throw data(null, { status: 404 }); |
| 1 | + | import { data } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { Route } from "./+types/tab-soon"; | |
| 4 | + | import { page } from "../../lib/meta"; | |
| 5 | + | import { roadmapItem } from "../../lib/roadmap"; | |
| 6 | + | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 7 | + | import { SoonView } from "../repo/soon"; | |
| 8 | + | ||
| 9 | + | /** The tab is the last part of the address: `-/teams` or `-/insights`. */ | |
| 10 | + | const tabOf = (pathname: string) => pathname.replace(/\/+$/, "").split("/").pop() ?? ""; | |
| 11 | + | ||
| 12 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | + | const item = roadmapItem(tabOf(args.location.pathname)); | |
| 14 | + | return page(args, { | |
| 15 | + | title: `${item?.title ?? "Soon"} · ${params.owner} · g1t`, | |
| 16 | + | description: item ? `Soon on g1t: ${item.summary}` : null, | |
| 17 | + | }); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export function loader({ params, context, request }: Route.LoaderArgs) { | |
| 21 | + | // For the workspace's members, as their tabs are. | |
| 22 | + | if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 }); | |
| 23 | + | const item = roadmapItem(tabOf(new URL(request.url).pathname)); | |
| 24 | + | if (!item || item.section !== "Workspace") throw data(null, { status: 404 }); | |
| 25 | + | return { item }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** A tab of the workspace's page that is coming: Teams, Insights. */ | |
| 29 | + | export default function WorkspaceTabSoon({ loaderData, params }: Route.ComponentProps) { | |
| 30 | + | return <SoonView item={loaderData.item} base={`/${params.owner}/-`} newProject={`/new?workspace=${params.owner}`} />; | |
| 31 | + | } |