Skip to content
114 linesCodeBlameRaw
1/**
2 * Apps: every app in the workspace that you can use, the ones pinned to
3 * your dock first. Built-in apps are always in the dock; the rest you pin
4 * or unpin here, from the launcher, or on a phone from More. Pins are
5 * yours alone, per workspace, kept with your account so every device
6 * shows the same dock (lib/dock.server.ts), with a copy in a cookie for
7 * when the account's cannot be read (lib/apps.ts). The pin buttons post
8 * here.
9 */
10import { Store } from "lucide-react";
11import { data } from "react-router";
12
13import { hasCodeAccess } from "@g1t/contracts";
14
15import type { Route } from "./+types/apps";
16import { AppTile, useAppPins } from "../../components/apps";
17import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsToShow, withPin, writeDock } from "../../lib/apps";
18import { savePins, savedPins } from "../../lib/dock.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 return {
33 slug,
34 name: membership.name?.trim() || slug,
35 code: hasCodeAccess(membership),
36 pins: pinsToShow(await savedPins(viewer, slug), readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
37 };
38}
39
40export async function action({ params, context, request }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const user = requireUser(context, request);
43 const slug = params.owner.toLowerCase();
44 if (!user.workspaces?.some((m) => m.slug === slug)) throw data(null, { status: 404 });
45 const change = appPinFromForm(await request.formData());
46 if (!change) return data({ error: "Nothing to do." }, { status: 400 });
47 const cookie = readCookie(request.headers.get("cookie"), DOCK_COOKIE);
48 // The change is made to the pins as the account keeps them; pins only
49 // ever kept in this device's cookie are carried over by the first one.
50 const current = pinsToShow(await savedPins(user, slug), cookie, slug);
51 const pins = await savePins(user, slug, withPin(current, change.app, change.pinned));
52 if (!pins) return data({ error: "Your pins could not be saved. Try again.", pins: current }, { status: 503 });
53 // This device's copy, drawn from when the account's cannot be read.
54 const secure = new URL(request.url).protocol === "https:";
55 return data({ error: null, pins }, { headers: { "Set-Cookie": dockCookie(writeDock(cookie, slug, pins), secure) } });
56}
57
58export default function Apps({ loaderData }: Route.ComponentProps) {
59 const { slug, name, code } = loaderData;
60 const { pins, toggle } = useAppPins(slug, loaderData.pins);
61 const apps = appsFor(code);
62 const pinned = pins.map((key) => apps.find((app) => app.key === key)).filter((app) => app != null);
63 return (
64 <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10">
65 <div className="flex flex-wrap items-end justify-between gap-4">
66 <div>
67 <h1 className="text-2xl font-semibold tracking-tight">Apps</h1>
68 <p className="mt-1 max-w-xl text-sm text-muted">
69 Everything in {name} that you can use. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins.
70 </p>
71 </div>
72 <span className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint">
73 <Store size={15} />
74 Marketplace
75 <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium text-muted">Coming</span>
76 </span>
77 </div>
78
79 <section aria-labelledby="pinned" className="mt-8">
80 <div className="flex items-baseline justify-between gap-3">
81 <h2 id="pinned" className="text-sm font-semibold">
82 Pinned to your dock
83 </h2>
84 <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span>
85 </div>
86 {pinned.length === 0 ? (
87 <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
88 Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones.
89 </p>
90 ) : (
91 <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
92 {pinned.map((app) => (
93 <AppTile key={app.key} app={app} slug={slug} pinned onToggle={toggle} card />
94 ))}
95 </div>
96 )}
97 </section>
98
99 <section aria-labelledby="yours" className="mt-10">
100 <div className="flex items-baseline justify-between gap-3">
101 <h2 id="yours" className="text-sm font-semibold">
102 Your apps
103 </h2>
104 <span className="text-xs text-faint">Built-in apps are always in your dock</span>
105 </div>
106 <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
107 {apps.map((app) => (
108 <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={toggle} card />
109 ))}
110 </div>
111 </section>
112 </main>
113 );
114}