File
Blob: src/client/pages/admin/launcher-apps.tsx
| 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { Pencil, Plus, Trash2 } from "lucide-react"; |
| 3 | import { useState } from "react"; |
| 4 | |
| 5 | import { AppForm, type AppFormValues } from "@/client/components/admin/app-form"; |
| 6 | import { Button } from "@/client/components/ui/button"; |
| 7 | import { Card } from "@/client/components/ui/card"; |
| 8 | import { ConfirmButton } from "@/client/components/ui/confirm-button"; |
| 9 | import { PageError } from "@/client/components/ui/page-error"; |
| 10 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 11 | import { ListRow } from "@/client/components/ui/row"; |
| 12 | import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; |
| 13 | import { useResourceForm } from "@/client/hooks/use-resource-form"; |
| 14 | import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; |
| 15 | import { |
| 16 | isLauncherIconName, |
| 17 | isLauncherTintName, |
| 18 | LAUNCHER_ICONS, |
| 19 | LAUNCHER_TINTS, |
| 20 | type LauncherIconName, |
| 21 | type LauncherTintName, |
| 22 | } from "@/client/lib/launcher-icons"; |
| 23 | import { queryKeys } from "@/client/lib/query"; |
| 24 | import { hasAdminRole } from "@/shared/role"; |
| 25 | |
| 26 | interface LauncherApp { |
| 27 | id: string; |
| 28 | name: string; |
| 29 | url: string; |
| 30 | icon: LauncherIconName | null; |
| 31 | tint: LauncherTintName | null; |
| 32 | enabled: boolean; |
| 33 | } |
| 34 | |
| 35 | const initialFromName = (name: string): string => (name.trim()[0] ?? "?").toUpperCase(); |
| 36 | |
| 37 | const safeHost = (uri: string): string | null => { |
| 38 | try { |
| 39 | return new URL(uri).host; |
| 40 | } catch { |
| 41 | return null; |
| 42 | } |
| 43 | }; |
| 44 | |
| 45 | interface TilePreviewProps { |
| 46 | name: string; |
| 47 | icon: LauncherIconName | null; |
| 48 | tint: LauncherTintName | null; |
| 49 | } |
| 50 | |
| 51 | const 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 | |
| 69 | export 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 | }; |