| 1 | import { Bell, Building2, Code2, House, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, SlidersHorizontal, Sparkles, Users } from "lucide-react"; |
| 2 | import { type ReactNode, useEffect, useMemo, useState } from "react"; |
| 3 | import { Link, useFetcher, useFetchers } from "react-router"; |
| 4 | |
| 5 | import { customizePath } from "@g1t/contracts/customize"; |
| 6 | |
| 7 | import { ConnectorMark } from "./connectors"; |
| 8 | import { Card } from "./ui/card"; |
| 9 | import { Skeleton } from "./ui/skeleton"; |
| 10 | import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps"; |
| 11 | import { cn } from "../lib/cn"; |
| 12 | import { Hint } from "./ui/hint"; |
| 13 | |
| 14 | /** Each built-in app's icon, at the size asked for. */ |
| 15 | export function appIcon(key: BuiltinApp, size = 18): ReactNode { |
| 16 | const icons: Record<BuiltinApp, ReactNode> = { |
| 17 | home: <House size={size} />, |
| 18 | chat: <MessagesSquare size={size} />, |
| 19 | notifications: <Bell size={size} />, |
| 20 | agents: <Sparkles size={size} />, |
| 21 | code: <Code2 size={size} />, |
| 22 | artifacts: <Shapes size={size} />, |
| 23 | people: <Users size={size} />, |
| 24 | workspace: <Building2 size={size} />, |
| 25 | }; |
| 26 | return icons[key]; |
| 27 | } |
| 28 | |
| 29 | /** An installed app's mark: its connector's, or its letters on a steady colour. */ |
| 30 | export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) { |
| 31 | return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />; |
| 32 | } |
| 33 | |
| 34 | /** A pinned app as the rail draws it, from its key alone; null when the key names nothing installable. */ |
| 35 | export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null { |
| 36 | const app = installedAppOf(key); |
| 37 | if (!app) return null; |
| 38 | return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> }; |
| 39 | } |
| 40 | |
| 41 | /** Every pin form shares this key, so the rail, the launcher and the Apps page see one change. */ |
| 42 | const PIN_FETCHER = "app-pin"; |
| 43 | |
| 44 | /** |
| 45 | * Someone's pins in the workspace `slug`, as saved (`saved`, from the |
| 46 | * root's data), with any pin or unpin on its way already applied, so the |
| 47 | * rail changes the moment a pin is pressed. `toggle` pins or unpins. |
| 48 | */ |
| 49 | export function useAppPins(slug: string, saved: PinnableApp[]) { |
| 50 | const fetcher = useFetcher({ key: PIN_FETCHER }); |
| 51 | const fetchers = useFetchers(); |
| 52 | const pins = useMemo(() => { |
| 53 | let pins = saved; |
| 54 | for (const pending of fetchers) { |
| 55 | if (pending.key !== PIN_FETCHER || !pending.formData) continue; |
| 56 | const change = appPinFromForm(pending.formData); |
| 57 | if (change) pins = withPin(pins, change.app, change.pinned); |
| 58 | } |
| 59 | return pins; |
| 60 | }, [saved, fetchers]); |
| 61 | const toggle = (app: PinnableApp) => { |
| 62 | const form = new FormData(); |
| 63 | form.set("intent", pins.includes(app) ? "unpin" : "pin"); |
| 64 | form.set("app", app); |
| 65 | fetcher.submit(form, { method: "post", action: `/${slug}/-/apps` }); |
| 66 | }; |
| 67 | return { pins, toggle }; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * One installed app: its mark and name, opening it; and a pin that shows |
| 72 | * on hover or focus and stays lit while it is pinned. An app the person |
| 73 | * can't use says Request access instead, and has no pin. |
| 74 | */ |
| 75 | export function AppTile({ |
| 76 | app, |
| 77 | pinned, |
| 78 | onToggle, |
| 79 | onOpen, |
| 80 | card = false, |
| 81 | }: { |
| 82 | app: InstalledAppData; |
| 83 | pinned: boolean; |
| 84 | onToggle: (app: PinnableApp) => void; |
| 85 | onOpen?: () => void; |
| 86 | /** On the Apps page: a card with what the app is. */ |
| 87 | card?: boolean; |
| 88 | }) { |
| 89 | const body = ( |
| 90 | <> |
| 91 | <span className="relative flex shrink-0"> |
| 92 | <InstalledMark app={app} size={40} /> |
| 93 | {!app.usable && ( |
| 94 | <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-surface text-faint ring-1 ring-line"> |
| 95 | <Lock size={9} /> |
| 96 | </span> |
| 97 | )} |
| 98 | </span> |
| 99 | {card ? ( |
| 100 | <span className="min-w-0"> |
| 101 | <span className="flex min-w-0 items-baseline gap-2"> |
| 102 | <span className="truncate text-sm font-medium text-fg">{app.name}</span> |
| 103 | <span className="shrink-0 text-[0.6875rem] text-faint">{app.from}</span> |
| 104 | </span> |
| 105 | <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span> |
| 106 | </span> |
| 107 | ) : ( |
| 108 | <span className="flex w-full min-w-0 flex-col items-center gap-1"> |
| 109 | <span className="block w-full truncate text-xs font-medium text-fg-soft">{app.name}</span> |
| 110 | <span className={cn("block truncate text-[0.625rem]", app.usable ? "text-faint" : "text-accent")}>{app.usable ? app.from : "Request access"}</span> |
| 111 | </span> |
| 112 | )} |
| 113 | </> |
| 114 | ); |
| 115 | const linkClass = cn( |
| 116 | "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent", |
| 117 | card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5", |
| 118 | ); |
| 119 | return ( |
| 120 | <div |
| 121 | className={cn( |
| 122 | "group/tile relative rounded-xl transition-colors", |
| 123 | card ? "border border-line bg-surface hover:border-line-strong" : "hover:bg-raised focus-within:bg-raised", |
| 124 | )} |
| 125 | > |
| 126 | {/* An app the person can't use leads (`href`) to where they ask for it. */} |
| 127 | <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}> |
| 128 | {body} |
| 129 | </Link> |
| 130 | {app.usable && app.pinnable && ( |
| 131 | <Hint label={pinned ? "Unpin from your rail" : "Pin to your rail"}> |
| 132 | <button |
| 133 | type="button" |
| 134 | aria-label={pinned ? `Unpin ${app.name} from your rail` : `Pin ${app.name} to your rail`} |
| 135 | aria-pressed={pinned} |
| 136 | onClick={() => onToggle(app.key)} |
| 137 | className={cn( |
| 138 | "absolute flex size-6 items-center justify-center rounded-md outline-none transition-opacity hover:bg-line focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-accent [@media(hover:none)]:opacity-100", |
| 139 | card ? "top-1/2 right-2.5 -translate-y-1/2" : "top-1 right-1", |
| 140 | pinned ? "text-accent opacity-100" : "text-faint opacity-0 group-hover/tile:opacity-100 hover:text-fg", |
| 141 | )} |
| 142 | > |
| 143 | <Pin size={13} className={pinned ? "fill-current" : undefined} /> |
| 144 | </button> |
| 145 | </Hint> |
| 146 | )} |
| 147 | </div> |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | /** When nothing is installed: what Apps are, and the way to Customize. */ |
| 152 | export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) { |
| 153 | return ( |
| 154 | <div className={cn("flex flex-col items-center text-center", compact ? "px-4 py-6" : "rounded-xl border border-dashed border-line px-6 py-12")}> |
| 155 | <span className="flex size-10 items-center justify-center rounded-xl bg-raised text-muted ring-1 ring-line"> |
| 156 | <SlidersHorizontal size={18} /> |
| 157 | </span> |
| 158 | <p className="mt-3 text-sm font-medium">No apps installed yet</p> |
| 159 | <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from Customize: the plugins your workspace installs, and the connectors and integrations it connects. Each one you can use shows here.</p> |
| 160 | <Link to={customizePath("", slug)} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover"> |
| 161 | <SlidersHorizontal size={14} /> |
| 162 | Customize |
| 163 | </Link> |
| 164 | </div> |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * The Apps launcher's body: a search, every app the workspace installed |
| 170 | * that the person can use (read from the Apps page as it opens), and the |
| 171 | * way to all of them and to Customize. |
| 172 | */ |
| 173 | export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) { |
| 174 | const [query, setQuery] = useState(""); |
| 175 | const loader = useFetcher<{ apps: InstalledAppData[] | null }>(); |
| 176 | useEffect(() => { |
| 177 | if (loader.state === "idle" && !loader.data) loader.load(`/${slug}/-/apps`); |
| 178 | // Once per opening: the launcher's body mounts when it opens. |
| 179 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 180 | }, [slug]); |
| 181 | const installed = loader.data?.apps; |
| 182 | const wanted = query.trim().toLowerCase(); |
| 183 | const apps = (installed ?? []).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted)); |
| 184 | const loading = installed == null && !loader.data; |
| 185 | const failed = installed == null && !!loader.data; |
| 186 | return ( |
| 187 | <div className="flex flex-col gap-2"> |
| 188 | {/* The search is there from the first frame, so the launcher keeps its shape while the apps are read. */} |
| 189 | {(loading || (installed && installed.length > 0)) && ( |
| 190 | <Card asChild tone="bg" radius="lg" className="flex h-9 items-center gap-2 px-2.5 text-sm focus-within:border-line-strong"> |
| 191 | <label> |
| 192 | <Search size={15} className="shrink-0 text-faint" /> |
| 193 | <input |
| 194 | value={query} |
| 195 | onChange={(event) => setQuery(event.target.value)} |
| 196 | placeholder="Find an app" |
| 197 | aria-label="Find an app" |
| 198 | autoComplete="off" |
| 199 | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" |
| 200 | /> |
| 201 | </label> |
| 202 | </Card> |
| 203 | )} |
| 204 | {failed ? ( |
| 205 | <p className="px-2 py-6 text-center text-sm text-muted">Apps can't be listed right now. Try again in a moment.</p> |
| 206 | ) : loading ? ( |
| 207 | <div className="grid grid-cols-4 gap-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps"> |
| 208 | {Array.from({ length: 4 }, (_, n) => ( |
| 209 | <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5"> |
| 210 | {/* On the launcher's raised surface the usual raised block would vanish: a line-coloured one shows. */} |
| 211 | <Skeleton className="size-10 rounded-xl bg-line" /> |
| 212 | <Skeleton className="h-2.5 w-12 bg-line" /> |
| 213 | <Skeleton className="h-2 w-8 bg-line" /> |
| 214 | </div> |
| 215 | ))} |
| 216 | </div> |
| 217 | ) : installed!.length === 0 ? ( |
| 218 | <NoApps slug={slug} onOpen={onClose} compact /> |
| 219 | ) : apps.length === 0 ? ( |
| 220 | <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p> |
| 221 | ) : ( |
| 222 | <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3"> |
| 223 | {apps.map((app) => ( |
| 224 | <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} /> |
| 225 | ))} |
| 226 | </div> |
| 227 | )} |
| 228 | <div className="flex items-center justify-between border-t border-line pt-2"> |
| 229 | <Link |
| 230 | to={`/${slug}/-/apps`} |
| 231 | onClick={onClose} |
| 232 | className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg" |
| 233 | > |
| 234 | <LayoutGrid size={14} /> |
| 235 | All apps |
| 236 | </Link> |
| 237 | <Link |
| 238 | to={customizePath("", slug)} |
| 239 | onClick={onClose} |
| 240 | className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg" |
| 241 | > |
| 242 | <SlidersHorizontal size={14} /> |
| 243 | Customize |
| 244 | </Link> |
| 245 | </div> |
| 246 | </div> |
| 247 | ); |
| 248 | } |