import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, Trash2 } from "lucide-react"; import { useState } from "react"; import { AppForm, type AppFormValues } from "@/client/components/admin/app-form"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { ConfirmButton } from "@/client/components/ui/confirm-button"; import { PageError } from "@/client/components/ui/page-error"; import { PageHeader } from "@/client/components/ui/page-header"; import { ListRow } from "@/client/components/ui/row"; import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; import { useResourceForm } from "@/client/hooks/use-resource-form"; import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; import { isLauncherIconName, isLauncherTintName, LAUNCHER_ICONS, LAUNCHER_TINTS, type LauncherIconName, type LauncherTintName, } from "@/client/lib/launcher-icons"; import { queryKeys } from "@/client/lib/query"; import { hasAdminRole } from "@/shared/role"; interface LauncherApp { id: string; name: string; url: string; icon: LauncherIconName | null; tint: LauncherTintName | null; enabled: boolean; } const initialFromName = (name: string): string => (name.trim()[0] ?? "?").toUpperCase(); const safeHost = (uri: string): string | null => { try { return new URL(uri).host; } catch { return null; } }; interface TilePreviewProps { name: string; icon: LauncherIconName | null; tint: LauncherTintName | null; } const TilePreview = ({ name, icon, tint }: TilePreviewProps) => { const Icon = icon && isLauncherIconName(icon) ? LAUNCHER_ICONS[icon] : null; const tintValue = tint && isLauncherTintName(tint) ? LAUNCHER_TINTS[tint] : "oklch(0.30 0.005 265)"; return ( ); }; export const AdminLauncherAppsPage = () => { const session = useRequireAdmin("/admin/launcher-apps"); const queryClient = useQueryClient(); // `pending` is a per-action lock: `submit`, `toggle:`, `delete:`. // Single slot prevents concurrent mutations from racing the local list // state and gives the JSX a clean way to light up only the in-flight // button. const [pending, setPending] = useState(null); const [error, setError] = useState(null); const form = useResourceForm({ canCancel: () => pending === null }); const isAdmin = hasAdminRole(session.data?.user.role); const userId = session.data?.user.id ?? null; const isLocked = pending !== null || form.formActive; const appsQueryKey = queryKeys.adminLauncherApps(userId); const appsQuery = useQuery({ queryKey: appsQueryKey, queryFn: async () => { const res = await apiRequest("/api/admin/launcher-apps"); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as LauncherApp[]; }, enabled: isAdmin, }); const apps = appsQuery.data ?? []; const handleSubmit = async (values: AppFormValues) => { if (pending !== null) return; if (!form.creating && form.editingId === null) return; form.setFormError(null); setPending("submit"); try { if (form.creating) { const res = await apiJsonRequest("/api/admin/launcher-apps", values, { method: "POST" }); if (!res.ok) { form.setFormError(await responseMessage(res, "Could not create launcher app.")); return; } form.close(); await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true }); } else if (form.editingId !== null) { const res = await apiJsonRequest(`/api/admin/launcher-apps/${encodeURIComponent(form.editingId)}`, values, { method: "PATCH", }); if (!res.ok) { form.setFormError(await responseMessage(res, "Could not update launcher app.")); return; } form.close(); await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true }); } } finally { setPending(null); } }; const handleToggleEnabled = async (app: LauncherApp) => { if (isLocked) return; setError(null); setPending(`toggle:${app.id}`); try { const res = await apiJsonRequest( `/api/admin/launcher-apps/${encodeURIComponent(app.id)}`, { enabled: !app.enabled }, { method: "PATCH" }, ); if (!res.ok) { setError(await responseMessage(res, "Could not update launcher app.")); return; } await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true }); } finally { setPending(null); } }; const handleDelete = async (id: string) => { if (isLocked) return; setError(null); setPending(`delete:${id}`); try { const res = await apiRequest(`/api/admin/launcher-apps/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok && res.status !== 204) { setError(await responseMessage(res, "Could not delete launcher app.")); return; } await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true }); } finally { setPending(null); } }; return (
); };