Skip to content
File

Blob: src/client/pages/admin/launcher-apps.tsx

typescript277 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { Pencil, Plus, Trash2 } from "lucide-react";
3import { useState } from "react";
4 
5import { AppForm, type AppFormValues } from "@/client/components/admin/app-form";
6import { Button } from "@/client/components/ui/button";
7import { Card } from "@/client/components/ui/card";
8import { ConfirmButton } from "@/client/components/ui/confirm-button";
9import { PageError } from "@/client/components/ui/page-error";
10import { PageHeader } from "@/client/components/ui/page-header";
11import { ListRow } from "@/client/components/ui/row";
12import { useRequireAdmin } from "@/client/hooks/use-auth-guard";
13import { useResourceForm } from "@/client/hooks/use-resource-form";
14import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http";
15import {
16 isLauncherIconName,
17 isLauncherTintName,
18 LAUNCHER_ICONS,
19 LAUNCHER_TINTS,
20 type LauncherIconName,
21 type LauncherTintName,
22} from "@/client/lib/launcher-icons";
23import { queryKeys } from "@/client/lib/query";
24import { hasAdminRole } from "@/shared/role";
25 
26interface LauncherApp {
27 id: string;
28 name: string;
29 url: string;
30 icon: LauncherIconName | null;
31 tint: LauncherTintName | null;
32 enabled: boolean;
33}
34 
35const initialFromName = (name: string): string => (name.trim()[0] ?? "?").toUpperCase();
36 
37const safeHost = (uri: string): string | null => {
38 try {
39 return new URL(uri).host;
40 } catch {
41 return null;
42 }
43};
44 
45interface TilePreviewProps {
46 name: string;
47 icon: LauncherIconName | null;
48 tint: LauncherTintName | null;
49}
50 
51const TilePreview = ({ name, icon, tint }: TilePreviewProps) => {
52 const Icon = icon && isLauncherIconName(icon) ? LAUNCHER_ICONS[icon] : null;
53 const tintValue = tint && isLauncherTintName(tint) ? LAUNCHER_TINTS[tint] : "oklch(0.30 0.005 265)";
54 return (
55 <span
56 className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-zinc-50 shadow-sm"
57 style={{ backgroundColor: tintValue }}
58 aria-hidden="true"
59 >
60 {Icon ? (
61 <Icon className="h-6 w-6" />
62 ) : (
63 <span className="font-display text-xl font-semibold">{initialFromName(name)}</span>
64 )}
65 </span>
66 );
67};
68 
69export const AdminLauncherAppsPage = () => {
70 const session = useRequireAdmin("/admin/launcher-apps");
71 const queryClient = useQueryClient();
72 // `pending` is a per-action lock: `submit`, `toggle:<id>`, `delete:<id>`.
73 // Single slot prevents concurrent mutations from racing the local list
74 // state and gives the JSX a clean way to light up only the in-flight
75 // button.
76 const [pending, setPending] = useState<string | null>(null);
77 const [error, setError] = useState<string | null>(null);
78 const form = useResourceForm({ canCancel: () => pending === null });
79 const isAdmin = hasAdminRole(session.data?.user.role);
80 const userId = session.data?.user.id ?? null;
81 const isLocked = pending !== null || form.formActive;
82 const appsQueryKey = queryKeys.adminLauncherApps(userId);
83 
84 const appsQuery = useQuery({
85 queryKey: appsQueryKey,
86 queryFn: async () => {
87 const res = await apiRequest("/api/admin/launcher-apps");
88 if (!res.ok) throw new Error(`HTTP ${res.status}`);
89 return (await res.json()) as LauncherApp[];
90 },
91 enabled: isAdmin,
92 });
93 const apps = appsQuery.data ?? [];
94 
95 const handleSubmit = async (values: AppFormValues) => {
96 if (pending !== null) return;
97 if (!form.creating && form.editingId === null) return;
98 form.setFormError(null);
99 setPending("submit");
100 try {
101 if (form.creating) {
102 const res = await apiJsonRequest("/api/admin/launcher-apps", values, { method: "POST" });
103 if (!res.ok) {
104 form.setFormError(await responseMessage(res, "Could not create launcher app."));
105 return;
106 }
107 form.close();
108 await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true });
109 } else if (form.editingId !== null) {
110 const res = await apiJsonRequest(`/api/admin/launcher-apps/${encodeURIComponent(form.editingId)}`, values, {
111 method: "PATCH",
112 });
113 if (!res.ok) {
114 form.setFormError(await responseMessage(res, "Could not update launcher app."));
115 return;
116 }
117 form.close();
118 await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true });
119 }
120 } finally {
121 setPending(null);
122 }
123 };
124 
125 const handleToggleEnabled = async (app: LauncherApp) => {
126 if (isLocked) return;
127 setError(null);
128 setPending(`toggle:${app.id}`);
129 try {
130 const res = await apiJsonRequest(
131 `/api/admin/launcher-apps/${encodeURIComponent(app.id)}`,
132 { enabled: !app.enabled },
133 { method: "PATCH" },
134 );
135 if (!res.ok) {
136 setError(await responseMessage(res, "Could not update launcher app."));
137 return;
138 }
139 await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true });
140 } finally {
141 setPending(null);
142 }
143 };
144 
145 const handleDelete = async (id: string) => {
146 if (isLocked) return;
147 setError(null);
148 setPending(`delete:${id}`);
149 try {
150 const res = await apiRequest(`/api/admin/launcher-apps/${encodeURIComponent(id)}`, { method: "DELETE" });
151 if (!res.ok && res.status !== 204) {
152 setError(await responseMessage(res, "Could not delete launcher app."));
153 return;
154 }
155 await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true });
156 } finally {
157 setPending(null);
158 }
159 };
160 
161 return (
162 <div className="mx-auto max-w-3xl">
163 <PageHeader
164 title="Launcher apps"
165 actions={
166 <Button size="sm" onClick={form.startCreate} disabled={isLocked}>
167 <Plus className="h-4 w-4" aria-hidden="true" />
168 New tile
169 </Button>
170 }
171 />
172 <PageError message={error} onDismiss={() => setError(null)} />
173
174 {form.creating ? (
175 <Card className="mb-4">
176 <AppForm
177 mode="create"
178 pending={pending === "submit"}
179 error={form.formError}
180 onDismissError={() => form.setFormError(null)}
181 onSubmit={handleSubmit}
182 onCancel={form.cancel}
183 />
184 </Card>
185 ) : null}
186
187 <Card>
188 {apps.length === 0 ? (
189 <p className="text-sm text-zinc-500">No launcher tiles yet.</p>
190 ) : (
191 <ul className="flex flex-col gap-2">
192 {apps.map((app) => {
193 const host = safeHost(app.url);
194 const togglePending = pending === `toggle:${app.id}`;
195 const isEditingThis = form.editingId === app.id;
196 return (
197 <ListRow key={app.id}>
198 <div className="flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
199 <div className="flex min-w-0 items-center gap-3">
200 <TilePreview name={app.name} icon={app.icon} tint={app.tint} />
201 <div className="min-w-0">
202 <div className="flex flex-wrap items-center gap-2">
203 <p className="truncate font-medium text-zinc-100">{app.name}</p>
204 {!app.enabled ? (
205 <span className="inline-flex items-center rounded-md bg-zinc-700/40 px-1.5 py-0.5 text-[0.7rem] font-medium uppercase tracking-wider text-zinc-400">
206 disabled
207 </span>
208 ) : null}
209 </div>
210 {host ? (
211 <a
212 href={app.url}
213 target="_blank"
214 rel="noopener noreferrer"
215 className="mt-0.5 block truncate text-xs text-zinc-500 hover:text-zinc-300"
216 >
217 {host}
218 </a>
219 ) : null}
220 </div>
221 </div>
222 <div className="flex flex-shrink-0 items-center gap-1">
223 <input
224 type="checkbox"
225 checked={app.enabled}
226 onChange={() => handleToggleEnabled(app)}
227 disabled={isLocked && !togglePending}
228 aria-label={app.enabled ? "Disable tile" : "Enable tile"}
229 title={app.enabled ? "Disable tile" : "Enable tile"}
230 className="mr-1.5 h-4 w-4 rounded border-zinc-600 bg-zinc-800 accent-accent-500"
231 />
232 <Button variant="ghost" size="sm" onClick={() => form.startEdit(app.id)} disabled={isLocked}>
233 <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
234 Edit
235 </Button>
236 <ConfirmButton
237 variant="ghost"
238 size="sm"
239 aria-label="Delete tile"
240 title="Delete tile"
241 onConfirm={() => handleDelete(app.id)}
242 loading={pending === `delete:${app.id}`}
243 disabled={isLocked && pending !== `delete:${app.id}`}
244 >
245 <Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
246 </ConfirmButton>
247 </div>
248 </div>
249 {isEditingThis ? (
250 <div className="mt-4 border-t border-zinc-800/60 pt-4">
251 <AppForm
252 key={app.id}
253 mode="edit"
254 initial={{
255 name: app.name,
256 url: app.url,
257 icon: app.icon ?? "",
258 tint: app.tint ?? "",
259 }}
260 pending={pending === "submit"}
261 error={form.formError}
262 onDismissError={() => form.setFormError(null)}
263 onSubmit={handleSubmit}
264 onCancel={form.cancel}
265 />
266 </div>
267 ) : null}
268 </ListRow>
269 );
270 })}
271 </ul>
272 )}
273 </Card>
274 </div>
275 );
276};