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