Skip to content
141 linesCodeBlameRaw
1/**
2 * Apps: what the workspace added in Customize that you can use, the ones
3 * pinned to your rail first. Built-in apps are always in the
4 * rail, and their own pages are in their sidebars; Apps are only what was
5 * added. Pins are yours alone, per workspace, kept with your account so
6 * every device shows the same dock (lib/dock.server.ts), with a copy in a
7 * cookie for when the account's cannot be read (lib/apps.ts). The pin
8 * buttons post here, and the launcher reads its list from here.
9 */
10import { SlidersHorizontal } from "lucide-react";
11import { data } from "react-router";
12
13import type { Result } from "@g1t/contracts";
14import { customizePath } from "@g1t/contracts/customize";
15
16import type { Route } from "./+types/apps";
17import { AppTile, NoApps, useAppPins } from "../../components/apps";
18import { ButtonLink } from "../../components/ui";
19import { DOCK_COOKIE, type InstalledAppData, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps";
20import { connectedStates } from "../../lib/connected.server";
21import { savePins, savedPins } from "../../lib/dock.server";
22import { workspaceAgents } from "../../lib/services.server";
23import { page } from "../../lib/meta";
24import { readCookie } from "../../lib/mission";
25import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
26import { Card } from "../../components/ui/card";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Apps · ${params.owner} · g1t` });
30}
31
32export async function loader({ params, context, request }: Route.LoaderArgs) {
33 const viewer = getViewer(context);
34 const slug = params.owner.toLowerCase();
35 const membership = viewer?.workspaces?.find((m) => m.slug === slug);
36 if (!viewer || !membership) throw data(null, { status: 404 });
37 // What Customize added; a service that doesn't answer counts as nothing added.
38 const read = <T,>(call: Promise<Result<T[]>>): Promise<T[]> => call.then((result) => (result.ok ? result.value : [])).catch(() => []);
39 const [connected, installs, connectors, saved] = await Promise.all([
40 connectedStates(slug, viewer).catch(() => null),
41 read(workspaceAgents.pluginInstalls(slug, viewer)),
42 read(workspaceAgents.connectors(slug, viewer)),
43 savedPins(viewer, slug),
44 ]);
45 return {
46 slug,
47 name: membership.name?.trim() || slug,
48 /** Null when what is installed could not be read. */
49 apps: connected ? installedApps(connected, installs, connectors).map((app) => appData(app, slug)) : (null as InstalledAppData[] | null),
50 pins: pinsToShow(saved, readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
51 };
52}
53
54export async function action({ params, context, request }: Route.ActionArgs) {
55 assertSameOrigin(request);
56 const user = requireUser(context, request);
57 const slug = params.owner.toLowerCase();
58 if (!user.workspaces?.some((m) => m.slug === slug)) throw data(null, { status: 404 });
59 const change = appPinFromForm(await request.formData());
60 if (!change) return data({ error: "Nothing to do." }, { status: 400 });
61 const cookie = readCookie(request.headers.get("cookie"), DOCK_COOKIE);
62 // The change is made to the pins as the account keeps them; pins only
63 // ever kept in this device's cookie are carried over by the first one.
64 const current = pinsToShow(await savedPins(user, slug), cookie, slug);
65 const pins = await savePins(user, slug, withPin(current, change.app, change.pinned));
66 if (!pins) return data({ error: "Your pins could not be saved. Try again.", pins: current }, { status: 503 });
67 // This device's copy, drawn from when the account's cannot be read.
68 const secure = new URL(request.url).protocol === "https:";
69 return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(cookie, slug, pins), secure) } });
70}
71
72export default function Apps({ loaderData }: Route.ComponentProps) {
73 const { slug, name, apps } = loaderData;
74 const { pins, toggle } = useAppPins(slug, loaderData.pins);
75 const usable = (apps ?? []).filter((app) => app.usable);
76 const pinned = pins.map((key) => usable.find((app) => app.key === key)).filter((app) => app != null);
77 return (
78 <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10">
79 <div className="flex flex-wrap items-end justify-between gap-4">
80 <div>
81 <h1 className="text-2xl font-semibold tracking-tight">Apps</h1>
82 <p className="mt-1 max-w-xl text-sm text-muted">
83 What {name} added in Customize: plugins, connectors and integrations. Pin the ones you want in your rail: your rail is yours, and nobody else sees your pins.
84 </p>
85 </div>
86 <ButtonLink to={customizePath("", slug)} prefetch="intent" variant="outline">
87 <SlidersHorizontal size={15} />
88 Customize
89 </ButtonLink>
90 </div>
91
92 {apps == null ? (
93 <Card asChild className="mt-8 px-4 py-6 text-center text-sm text-muted">
94 <p>What is installed can't be read right now. Reload in a minute.</p>
95 </Card>
96 ) : apps.length === 0 ? (
97 <div className="mt-8">
98 <NoApps slug={slug} />
99 </div>
100 ) : (
101 <>
102 <section aria-labelledby="pinned" className="mt-8">
103 <div className="flex items-baseline justify-between gap-3">
104 <h2 id="pinned" className="text-sm font-semibold">
105 Pinned to your rail
106 </h2>
107 <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span>
108 </div>
109 {pinned.length === 0 ? (
110 <Card asChild tone="plain" className="mt-3 border-dashed px-4 py-6 text-center text-sm text-muted">
111 <p>
112 Nothing pinned yet. Pin an app below and it shows in your rail, under the built-in ones.
113 </p>
114 </Card>
115 ) : (
116 <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
117 {pinned.map((app) => (
118 <AppTile key={app.key} app={app} pinned onToggle={toggle} card />
119 ))}
120 </div>
121 )}
122 </section>
123
124 <section aria-labelledby="installed" className="mt-10">
125 <div className="flex items-baseline justify-between gap-3">
126 <h2 id="installed" className="text-sm font-semibold">
127 Installed
128 </h2>
129 <span className="text-xs text-faint">{apps.length}</span>
130 </div>
131 <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
132 {apps.map((app) => (
133 <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={toggle} card />
134 ))}
135 </div>
136 </section>
137 </>
138 )}
139 </main>
140 );
141}