Skip to content
File

Blob: src/client/pages/launcher.tsx

typescript147 lines
1import { useQuery } from "@tanstack/react-query";
2 
3import {
4 isLauncherIconName,
5 isLauncherTintName,
6 LAUNCHER_ICONS,
7 LAUNCHER_TINTS,
8 type LauncherIconName,
9 type LauncherTintName,
10} from "@/client/lib/launcher-icons";
11import { fetchJson, queryKeys } from "@/client/lib/query";
12import { Skeleton } from "@/client/components/ui/skeleton";
13 
14interface LauncherApp {
15 id: string;
16 name: string;
17 url: string;
18 icon: LauncherIconName | null;
19 tint: LauncherTintName | null;
20}
21 
22const initialFromName = (name: string): string => (name.trim()[0] ?? "?").toUpperCase();
23 
24const safeHost = (uri: string): string | null => {
25 try {
26 return new URL(uri).host;
27 } catch {
28 return null;
29 }
30};
31 
32const Tile = ({ app }: { app: LauncherApp }) => {
33 const Icon = app.icon && isLauncherIconName(app.icon) ? LAUNCHER_ICONS[app.icon] : null;
34 // Default for tiles without a tint: low-chroma zinc so they read as
35 // "tessera-suite default" rather than mimicking any specific brand.
36 const tint = app.tint && isLauncherTintName(app.tint) ? LAUNCHER_TINTS[app.tint] : "oklch(0.30 0.005 265)";
37 // Worker filters URLs to http(s); this guard keeps a row that slipped
38 // through pre-validation from crashing the page.
39 const host = safeHost(app.url);
40 return (
41 <a
42 href={app.url}
43 className="group flex items-center gap-4 rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-4 transition-colors hover:border-zinc-700/60 hover:bg-zinc-800/50"
44 >
45 <span
46 className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl text-zinc-50 shadow-sm transition-transform duration-200 group-hover:-rotate-2"
47 style={{ backgroundColor: tint }}
48 aria-hidden="true"
49 >
50 {Icon ? (
51 <Icon className="h-7 w-7" />
52 ) : (
53 <span className="font-display text-2xl font-semibold">{initialFromName(app.name)}</span>
54 )}
55 </span>
56 <span className="min-w-0 flex-1">
57 <span className="block text-base font-semibold text-zinc-100">{app.name}</span>
58 {host ? <span className="mt-0.5 block truncate text-xs text-zinc-500">{host}</span> : null}
59 </span>
60 </a>
61 );
62};
63 
64interface LauncherProps {
65 greetingName: string;
66}
67 
68const SKELETON_TILE_COUNT = 4;
69 
70const LauncherTileGridSkeleton = () => (
71 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2" aria-hidden="true">
72 {Array.from({ length: SKELETON_TILE_COUNT }, (_, i) => (
73 <div
74 key={i}
75 className="flex h-[5.5rem] items-center gap-4 rounded-2xl border border-zinc-800/60 bg-zinc-900/40 p-4"
76 >
77 <Skeleton className="h-14 w-14 shrink-0 rounded-2xl" />
78 <span className="min-w-0 flex-1 space-y-2">
79 <Skeleton className="h-4 w-32" />
80 <Skeleton className="h-3 w-24" />
81 </span>
82 </div>
83 ))}
84 </div>
85);
86 
87export const LauncherSkeleton = () => (
88 <div className="mx-auto max-w-3xl py-12" aria-busy="true">
89 <Skeleton className="mb-3 h-10 w-48" />
90 <Skeleton className="mb-10 h-4 w-40" />
91 <LauncherTileGridSkeleton />
92 </div>
93);
94 
95// Signed-in landing surface: a tile grid of apps in the limic suite.
96// A tile click hard-navigates to the app's `url`; the app's own auth
97// flow (tessera OIDC, Cloudflare Access, etc.) takes over from there.
98// We don't force a `/authorize` handshake from the launcher itself
99// because the user might just be opening a homepage.
100export const Launcher = ({ greetingName }: LauncherProps) => {
101 const { data: apps = null, error: fetchError } = useQuery({
102 queryKey: queryKeys.launcherApps(),
103 queryFn: () => fetchJson<LauncherApp[]>("/api/launcher"),
104 });
105 const error = fetchError instanceof Error ? `Could not load apps (${fetchError.message}).` : null;
106 
107 return (
108 <div className="mx-auto max-w-3xl py-12">
109 <h1 className="mb-2 font-display text-[clamp(1.75rem,4vw,2.5rem)] font-semibold leading-tight tracking-tight text-zinc-100">
110 {greetingName}.
111 </h1>
112 <p className="mb-10 text-sm text-zinc-400">Pick an app to get to work.</p>
113
114 {error ? (
115 <p className="rounded-xl border border-red-500/30 bg-red-500/5 p-4 text-sm text-red-300">{error}</p>
116 ) : null}
117
118 {apps && apps.length === 0 ? (
119 <div className="rounded-2xl border border-dashed border-zinc-800/80 bg-zinc-900/40 p-8 text-center">
120 <p className="text-sm text-zinc-400">
121 No launcher tiles are registered yet. An admin can add one at{" "}
122 <a href="/admin/launcher-apps" className="text-accent-400 hover:underline">
123 /admin/launcher-apps
124 </a>
125 .
126 </p>
127 </div>
128 ) : null}
129
130 {apps === null && !error ? (
131 // Reserve the same vertical rhythm so the page doesn't shift
132 // when the API call resolves. Four skeleton tiles ≈ the median
133 // suite size; the layout settles immediately if there are more.
134 <LauncherTileGridSkeleton />
135 ) : null}
136
137 {apps && apps.length > 0 ? (
138 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
139 {apps.map((app) => (
140 <Tile key={app.id} app={app} />
141 ))}
142 </div>
143 ) : null}
144 </div>
145 );
146};