+ {!isAdminPage &&
+ isSettingsPage &&
+ canViewResourceLauncher &&
+ orgId && (
+
+ )}
{!isAdminPage && user.serverAdmin && (
{
- const [faviconError, setFaviconError] = useState(false);
- const [faviconLoaded, setFaviconLoaded] = useState(false);
-
- // Extract domain for favicon URL
- const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
- const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
-
- const handleFaviconLoad = () => {
- setFaviconLoaded(true);
- setFaviconError(false);
- };
-
- const handleFaviconError = () => {
- setFaviconError(true);
- setFaviconLoaded(false);
- };
-
- if (faviconError || !enabled) {
- return (
-
- );
- }
-
- return (
-
- {!faviconLoaded && (
-
- )}
-

-
- );
-};
-
-// Resource Info component
-const ResourceInfo = ({ resource }: { resource: Resource }) => {
- const t = useTranslations();
- const hasAuthMethods =
- resource.sso ||
- resource.password ||
- resource.pincode ||
- resource.whitelist;
-
- const hasAnyInfo =
- Boolean(resource.siteName) ||
- Boolean(hasAuthMethods) ||
- !resource.enabled;
-
- if (!hasAnyInfo) return null;
-
- const infoContent = (
-
- {/* Site Information */}
- {resource.siteName && (
-
-
- {t("site")}
-
-
-
- {resource.siteName}
-
-
- )}
-
- {/* Authentication Methods */}
- {hasAuthMethods && (
-
-
- {t("memberPortalAuthMethods")}
-
-
- {resource.sso && (
-
-
-
-
-
- {t("memberPortalSso")}
-
-
- )}
- {resource.password && (
-
-
-
-
-
- {t("memberPortalPasswordProtected")}
-
-
- )}
- {resource.pincode && (
-
-
-
-
-
- {t("memberPortalPinCode")}
-
-
- )}
- {resource.whitelist && (
-
-
-
- {t("memberPortalEmailWhitelist")}
-
-
- )}
-
-
- )}
-
- {/* Resource Status - if disabled */}
- {!resource.enabled && (
-
-
-
-
- {t("memberPortalResourceDisabled")}
-
-
-
- )}
-
- );
-
- return
{infoContent};
-};
-
-// Pagination component
-const PaginationControls = ({
- currentPage,
- totalPages,
- onPageChange,
- totalItems,
- itemsPerPage
-}: {
- currentPage: number;
- totalPages: number;
- onPageChange: (page: number) => void;
- totalItems: number;
- itemsPerPage: number;
-}) => {
- const t = useTranslations();
- const startItem = (currentPage - 1) * itemsPerPage + 1;
- const endItem = Math.min(currentPage * itemsPerPage, totalItems);
-
- if (totalPages <= 1) return null;
-
- return (
-
-
- {t("memberPortalShowingResources", {
- start: startItem,
- end: endItem,
- total: totalItems
- })}
-
-
-
-
-
-
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(
- (page) => {
- // Show first page, last page, current page, and 2 pages around current
- const showPage =
- page === 1 ||
- page === totalPages ||
- Math.abs(page - currentPage) <= 1;
-
- const showEllipsis =
- (page === 2 && currentPage > 4) ||
- (page === totalPages - 1 &&
- currentPage < totalPages - 3);
-
- if (!showPage && !showEllipsis) return null;
-
- if (showEllipsis) {
- return (
-
- ...
-
- );
- }
-
- return (
-
- );
- }
- )}
-
-
-
-
-
- );
-};
-
-// Loading skeleton component
-const ResourceCardSkeleton = () => (
-
-
-
-
-
-
-
-
-
-);
-
-export default function MemberResourcesPortal({
- orgId
-}: MemberResourcesPortalProps) {
- const t = useTranslations();
- const { env } = useEnvContext();
- const api = createApiClient({ env });
- const { toast } = useToast();
-
- const [resources, setResources] = useState
([]);
- const [siteResources, setSiteResources] = useState([]);
- const [filteredResources, setFilteredResources] = useState([]);
- const [filteredSiteResources, setFilteredSiteResources] = useState<
- SiteResource[]
- >([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortBy, setSortBy] = useState("name-asc");
- const [refreshing, setRefreshing] = useState(false);
-
- // Pagination state
- const [currentPage, setCurrentPage] = useState(1);
- const itemsPerPage = 12; // 3x4 grid on desktop
-
- const fetchUserResources = async (isRefresh = false) => {
- try {
- if (isRefresh) {
- setRefreshing(true);
- } else {
- setLoading(true);
- }
- setError(null);
-
- const response = await api.get(
- `/org/${orgId}/user-resources`
- );
-
- if (response.data.success) {
- setResources(response.data.data.resources);
- setSiteResources(response.data.data.siteResources || []);
- setFilteredResources(response.data.data.resources);
- setFilteredSiteResources(
- response.data.data.siteResources || []
- );
- } else {
- setError(t("memberPortalFailedToLoad"));
- }
- } catch (err) {
- console.error("Error fetching user resources:", err);
- setError(t("memberPortalFailedToLoadDescription"));
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- };
-
- useEffect(() => {
- fetchUserResources();
- }, [orgId, api]);
-
- // Filter and sort resources
- useEffect(() => {
- const filtered = resources.filter(
- (resource) =>
- resource.name
- .toLowerCase()
- .includes(searchQuery.toLowerCase()) ||
- resource.domain
- .toLowerCase()
- .includes(searchQuery.toLowerCase())
- );
-
- // Sort resources
- filtered.sort((a, b) => {
- switch (sortBy) {
- case "name-asc":
- return a.name.localeCompare(b.name);
- case "name-desc":
- return b.name.localeCompare(a.name);
- case "domain-asc":
- return a.domain.localeCompare(b.domain);
- case "domain-desc":
- return b.domain.localeCompare(a.domain);
- case "status-enabled":
- // Enabled first, then protected vs unprotected
- if (a.enabled !== b.enabled) return b.enabled ? 1 : -1;
- return b.protected ? 1 : -1;
- case "status-disabled":
- // Disabled first, then unprotected vs protected
- if (a.enabled !== b.enabled) return a.enabled ? 1 : -1;
- return a.protected ? 1 : -1;
- default:
- return a.name.localeCompare(b.name);
- }
- });
-
- setFilteredResources(filtered);
-
- // Filter and sort site resources
- const filteredSites = siteResources.filter(
- (resource) =>
- resource.name
- .toLowerCase()
- .includes(searchQuery.toLowerCase()) ||
- resource.destination
- .toLowerCase()
- .includes(searchQuery.toLowerCase())
- );
-
- // Sort site resources
- filteredSites.sort((a, b) => {
- switch (sortBy) {
- case "name-asc":
- return a.name.localeCompare(b.name);
- case "name-desc":
- return b.name.localeCompare(a.name);
- case "domain-asc":
- case "domain-desc":
- // Sort by destination for site resources
- const destCompare =
- sortBy === "domain-asc"
- ? a.destination.localeCompare(b.destination)
- : b.destination.localeCompare(a.destination);
- return destCompare;
- case "status-enabled":
- return b.enabled ? 1 : -1;
- case "status-disabled":
- return a.enabled ? 1 : -1;
- default:
- return a.name.localeCompare(b.name);
- }
- });
-
- setFilteredSiteResources(filteredSites);
-
- // Reset to first page when search/sort changes
- setCurrentPage(1);
- }, [resources, siteResources, searchQuery, sortBy]);
-
- // Calculate pagination
- const totalItems = filteredResources.length + filteredSiteResources.length;
- const totalPages = Math.ceil(totalItems / itemsPerPage);
- const startIndex = (currentPage - 1) * itemsPerPage;
- const paginatedResources = filteredResources.slice(
- startIndex,
- startIndex + itemsPerPage
- );
- const remainingSlots = itemsPerPage - paginatedResources.length;
- const paginatedSiteResources =
- remainingSlots > 0
- ? filteredSiteResources.slice(
- Math.max(0, startIndex - filteredResources.length),
- Math.max(0, startIndex - filteredResources.length) +
- remainingSlots
- )
- : [];
-
- const handleOpenResource = (resource: Resource) => {
- // Open the resource in a new tab
- window.open(resource.domain, "_blank");
- };
-
- const handleRefresh = () => {
- fetchUserResources(true);
- };
-
- const handleRetry = () => {
- fetchUserResources();
- };
-
- const handlePageChange = (page: number) => {
- setCurrentPage(page);
- // Scroll to top when page changes
- window.scrollTo({ top: 0, behavior: "smooth" });
- };
-
- if (loading) {
- return (
-
-
-
- {/* Search and Sort Controls - Skeleton */}
-
-
- {/* Loading Skeletons */}
-
- {Array.from({ length: 12 }).map((_, index) => (
-
- ))}
-
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
-
-
- {t("memberPortalUnableToLoad")}
-
-
- {error}
-
-
-
-
-
- );
- }
-
- return (
-
-
-
- {/* Search and Sort Controls with Refresh */}
-
-
- {/* Search */}
-
- setSearchQuery(e.target.value)}
- className="w-full pl-8 bg-card"
- />
-
-
-
- {/* Sort */}
-
-
-
-
-
- {/* Refresh Button */}
-
-
-
- {/* Resources Content */}
- {filteredResources.length === 0 &&
- filteredSiteResources.length === 0 ? (
- /* Enhanced Empty State */
-
-
-
- {searchQuery ? (
-
- ) : (
-
- )}
-
-
- {searchQuery
- ? t("memberPortalNoResourcesFound")
- : t("memberPortalNoResourcesAvailable")}
-
-
- {searchQuery
- ? t("memberPortalNoResourcesMatchSearch", {
- query: searchQuery
- })
- : t("memberPortalNoResourcesAccess")}
-
-
- {searchQuery ? (
-
- ) : (
-
- )}
-
-
-
- ) : (
- <>
- {/* Public Resources Section */}
- {paginatedResources.length > 0 && (
- <>
-
-
-
- {t("memberPortalPublicResources")}
-
-
- {t(
- "memberPortalPublicResourcesDescription"
- )}
-
-
-
- {paginatedResources.map((resource) => (
-
-
-
-
-
-
-
-
- {
- resource.name
- }
-
-
-
-
- {
- resource.name
- }
-
-
-
-
-
-
-
-
- {resource.mode.toUpperCase()}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))}
-
- >
- )}
-
- {/* Private Resources (Site Resources) Section */}
- {paginatedSiteResources.length > 0 && (
- <>
-
-
-
- {t("memberPortalPrivateResources")}
-
-
- {t(
- "memberPortalPrivateResourcesDescription"
- )}
-
-
-
- {paginatedSiteResources.map((siteResource) => (
-
-
-
-
-
-
-
-
- {
- siteResource.name
- }
-
-
-
-
- {
- siteResource.name
- }
-
-
-
-
-
-
-
-
- {siteResource.mode.toUpperCase()}
-
-
-
-
- {t(
- "memberPortalResourceDetails"
- )}
-
-
-
- {t(
- "memberPortalMode"
- )}
- :
-
-
- {siteResource.mode.toUpperCase()}
-
-
- {siteResource.destination && (
-
-
- {t(
- "memberPortalDestination"
- )}
- :
-
-
- {
- siteResource.destination
- }
-
-
- )}
- {siteResource.alias && (
-
-
- {t(
- "memberPortalAlias"
- )}
- :
-
-
- {
- siteResource.alias
- }
-
-
- )}
-
-
- {t(
- "status"
- )}
- :
-
-
- {siteResource.enabled
- ? t(
- "enabled"
- )
- : t(
- "disabled"
- )}
-
-
-
-
-
-
-
-
- {siteResource.mode === "http" &&
- siteResource.fullDomain ? (
- /* HTTP mode - show as clickable link */
-
- ) : siteResource.alias ? (
- /* Alias as primary */
-
-
- {siteResource.alias}
-
-
-
- ) : siteResource.destination ? (
- /* Destination as primary when no alias */
-
-
- {
- siteResource.destination
- }
-
-
-
- ) : (
- /* niceId fallback when no alias and no destination */
-
-
- {
- siteResource.niceId
- }
-
-
-
- )}
-
-
-
-
- {siteResource.mode === "http" &&
- siteResource.fullDomain ? (
-
- ) : null}
-
-
- {t(
- "memberPortalRequiresClientConnection"
- )}
-
-
-
- ))}
-
- >
- )}
-
- {/* Pagination Controls */}
-
- >
- )}
-
- );
-}
diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx
index 2a4d63e85..ff854a1a8 100644
--- a/src/components/PrivateResourcesTable.tsx
+++ b/src/components/PrivateResourcesTable.tsx
@@ -186,11 +186,11 @@ export default function PrivateResourcesTable({
});
});
} catch (e) {
- console.error(t("resourceErrorDelete"), e);
+ console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
- description: formatAxiosError(e, t("v"))
+ description: formatAxiosError(e, t("resourceErrorDelte"))
});
}
};
diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx
index e647ee7a1..02ad97773 100644
--- a/src/components/RedirectToOrg.tsx
+++ b/src/components/RedirectToOrg.tsx
@@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
type RedirectToOrgProps = {
targetOrgId: string;
+ isAdminOrOwner?: boolean;
};
-export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
+export default function RedirectToOrg({
+ targetOrgId,
+ isAdminOrOwner = false
+}: RedirectToOrgProps) {
const router = useRouter();
useEffect(() => {
try {
const target =
- getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
+ getInternalRedirectTarget(targetOrgId) ??
+ (isAdminOrOwner
+ ? `/${targetOrgId}/settings`
+ : `/${targetOrgId}`);
router.replace(target);
} catch {
- router.replace(`/${targetOrgId}`);
+ router.replace(
+ isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}`
+ );
}
- }, [targetOrgId, router]);
+ }, [targetOrgId, isAdminOrOwner, router]);
return null;
}
diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx
index f25342263..f459d2c38 100644
--- a/src/components/ResourceInfoBox.tsx
+++ b/src/components/ResourceInfoBox.tsx
@@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
- {resource.ssl ? "HTTPS" : "HTTP"}
+ {resource.mode == "http"
+ ? resource.ssl
+ ? "HTTPS"
+ : "HTTP"
+ : resource.mode?.toUpperCase()}
diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx
new file mode 100644
index 000000000..969eebcfc
--- /dev/null
+++ b/src/components/SidePanel.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import * as React from "react";
+
+import { useMediaQuery } from "@app/hooks/useMediaQuery";
+import { cn } from "@app/lib/cn";
+import {
+ Sheet,
+ SheetClose,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetOverlay,
+ SheetPortal,
+ SheetTitle,
+ SheetTrigger
+} from "./ui/sheet";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+
+type BaseProps = {
+ children: React.ReactNode;
+};
+
+type RootSidePanelProps = BaseProps & {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+};
+
+type SidePanelProps = {
+ className?: string;
+ asChild?: true;
+ children?: React.ReactNode;
+};
+
+const desktop = "(min-width: 768px)";
+
+const SidePanel = ({ children, ...props }: RootSidePanelProps) => {
+ return {children};
+};
+
+const SidePanelTrigger = ({
+ className,
+ children,
+ ...props
+}: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const SidePanelContent = ({
+ className,
+ children,
+ ...props
+}: SidePanelProps) => {
+ const isDesktop = useMediaQuery(desktop);
+
+ return (
+
+
+ e.preventDefault()}
+ >
+ {children}
+
+
+ );
+};
+
+const SidePanelDescription = ({
+ className,
+ children,
+ ...props
+}: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
+ return (
+
+ );
+};
+
+const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export {
+ SidePanel,
+ SidePanelBody,
+ SidePanelClose,
+ SidePanelContent,
+ SidePanelDescription,
+ SidePanelFooter,
+ SidePanelHeader,
+ SidePanelTitle,
+ SidePanelTrigger
+};
diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx
index a5e639c28..8089e71b2 100644
--- a/src/components/SiteInfoCard.tsx
+++ b/src/components/SiteInfoCard.tsx
@@ -54,7 +54,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
{t("publicIpEndpoint")}
{formatPublicEndpoint(site.endpoint)}
-
+
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx
index 3dc7a56da..efcf83e72 100644
--- a/src/components/SitesTable.tsx
+++ b/src/components/SitesTable.tsx
@@ -107,6 +107,7 @@ export default function SitesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteWithResources, setDeleteWithResources] = useState(false);
const [selectedSite, setSelectedSite] = useState(null);
+ const [restartingSite, setRestartingSite] = useState(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState(null);
const [isRefreshing, startTransition] = useTransition();
@@ -159,6 +160,24 @@ export default function SitesTable({
});
}
+ async function restartSite(siteId: number) {
+ try {
+ await api.post(`/site/${siteId}/restart`);
+ toast({
+ title: t("siteRestarted"),
+ description: t("siteRestartedDescription")
+ });
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: t("siteErrorRestart"),
+ description: formatAxiosError(e, t("siteErrorRestartDescription"))
+ });
+ } finally {
+ setRestartingSite(null);
+ }
+ }
+
function deleteSite(siteId: number, withResources: boolean) {
startTransition(async () => {
await api
@@ -526,6 +545,20 @@ export default function SitesTable({
+ {siteRow.type === "newt" && (
+ <>
+
+ setRestartingSite(siteRow)
+ }
+ >
+
+ {t("siteRestartButton")}
+
+
+
+ >
+ )}
{
setSelectedSite(siteRow);
@@ -654,6 +687,28 @@ export default function SitesTable({
+ {restartingSite && (
+ {
+ if (!val) setRestartingSite(null);
+ }}
+ dialog={
+
+ {t.rich("siteRestartDialogMessage", {
+ name: restartingSite.name,
+ b: (chunks) => {chunks}
+ })}
+
+ }
+ buttonText={t("siteRestartButton")}
+ onConfirm={() => restartSite(restartingSite.id)}
+ string={restartingSite.name}
+ warningText={t("siteRestartWarning")}
+ title={t("siteRestartTitle")}
+ />
+ )}
+
{selectedSite && (
void;
};
+export function formatLabelsSelectorLabel(
+ selectedLabels: SelectedLabel[],
+ t: (key: string, values?: { count: number }) => string
+): string {
+ if (selectedLabels.length === 0) {
+ return t("selectLabels");
+ }
+ if (selectedLabels.length === 1) {
+ return selectedLabels[0]!.name;
+ }
+ return t("labelsSelectorLabelsCount", {
+ count: selectedLabels.length
+ });
+}
+
export const LABEL_COLORS = {
red: "#ff6467",
green: "#05df72",
diff --git a/src/components/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx
index 15b23827f..659e16d5c 100644
--- a/src/components/multi-select/multi-select-content.tsx
+++ b/src/components/multi-select/multi-select-content.tsx
@@ -12,7 +12,12 @@ import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Checkbox } from "../ui/checkbox";
-export type TagValue = { text: string; id: string; isAdmin?: boolean };
+export type TagValue = {
+ text: string;
+ id: string;
+ isAdmin?: boolean;
+ color?: string;
+};
export type MultiSelectTagsProps = {
emptyPlaceholder?: string;
@@ -77,6 +82,14 @@ export function MultiSelectContent({
aria-hidden
tabIndex={-1}
/>
+ {option.color && (
+
+ )}
{`${option.text}`}
);
diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx
index bde1a9b05..dc75311c2 100644
--- a/src/components/multi-select/multi-select-tag-input.tsx
+++ b/src/components/multi-select/multi-select-tag-input.tsx
@@ -66,7 +66,17 @@ export function MultiSelectTagInput({
)}
onClick={(e) => e.stopPropagation()}
>
- {option.text}
+ {option.color && (
+
+ )}
+
+ {option.text}
+
{isLocked ? (
diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx
index acb8b7dd9..fe81dc697 100644
--- a/src/components/multi-site-selector.tsx
+++ b/src/components/multi-site-selector.tsx
@@ -1,4 +1,4 @@
-import { orgQueries } from "@app/lib/queries";
+import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
@@ -19,6 +19,7 @@ export type MultiSitesSelectorProps = {
selectedSites: Selectedsite[];
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
+ scope?: "org" | "launcher";
};
export function formatMultiSitesSelectorLabel(
@@ -40,19 +41,33 @@ export function MultiSitesSelector({
orgId,
selectedSites,
onSelectionChange,
- filterTypes
+ filterTypes,
+ scope = "org"
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
- const { data: sites = [] } = useQuery(
- orgQueries.sites({
+ const orgSitesQuery = useQuery({
+ ...orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 10
- })
- );
+ }),
+ enabled: scope === "org"
+ });
+ const launcherSitesQuery = useQuery({
+ ...launcherQueries.sites({
+ orgId,
+ query: debouncedQuery,
+ perPage: 500
+ }),
+ enabled: scope === "launcher"
+ });
+ const sites =
+ scope === "launcher"
+ ? (launcherSitesQuery.data ?? [])
+ : (orgSitesQuery.data ?? []);
const sitesShown = useMemo(() => {
const base = filterTypes
diff --git a/src/components/resource-launcher/LauncherCopyIcon.tsx b/src/components/resource-launcher/LauncherCopyIcon.tsx
new file mode 100644
index 000000000..bfaf21d04
--- /dev/null
+++ b/src/components/resource-launcher/LauncherCopyIcon.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import { Check, Copy } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+
+type LauncherCopyIconProps = {
+ text: string;
+ className?: string;
+};
+
+export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
+ const t = useTranslations();
+ const [copied, setCopied] = useState(false);
+
+ if (!text) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherEmptyState.tsx b/src/components/resource-launcher/LauncherEmptyState.tsx
new file mode 100644
index 000000000..193ffae78
--- /dev/null
+++ b/src/components/resource-launcher/LauncherEmptyState.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { cn } from "@app/lib/cn";
+import { LayoutGrid, SearchX } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type LauncherEmptyStateVariant = "empty" | "noResults";
+
+type LauncherEmptyStateProps = {
+ variant: LauncherEmptyStateVariant;
+ layout: "grid" | "list";
+ query?: string;
+ onClearFilters?: () => void;
+};
+
+function GhostResourceGrid() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function GhostResourceList() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function LauncherEmptyState({
+ variant,
+ layout,
+ query,
+ onClearFilters
+}: LauncherEmptyStateProps) {
+ const t = useTranslations();
+ const isNoResults = variant === "noResults";
+ const Icon = isNoResults ? SearchX : LayoutGrid;
+ const trimmedQuery = query?.trim();
+
+ return (
+
+
+ {layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {isNoResults
+ ? t("resourceLauncherEmptyStateNoResultsTitle")
+ : t("resourceLauncherEmptyStateTitle")}
+
+
+ {isNoResults
+ ? trimmedQuery
+ ? t(
+ "resourceLauncherEmptyStateNoResultsWithQuery",
+ { query: trimmedQuery }
+ )
+ : t(
+ "resourceLauncherEmptyStateNoResultsDescription"
+ )
+ : t("resourceLauncherEmptyStateDescription")}
+
+
+ {isNoResults && onClearFilters ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx
new file mode 100644
index 000000000..5a0e425f9
--- /dev/null
+++ b/src/components/resource-launcher/LauncherFilterPopover.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import {
+ formatMultiSitesSelectorLabel,
+ MultiSitesSelector
+} from "@app/components/multi-site-selector";
+import {
+ formatLabelsSelectorLabel,
+ LABEL_COLORS,
+ type SelectedLabel
+} from "@app/components/labels-selector";
+import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
+import { Button } from "@app/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import { cn } from "@app/lib/cn";
+import { launcherQueries } from "@app/lib/queries";
+import { useQuery } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { ChevronsUpDown, Funnel } from "lucide-react";
+import { useMemo, useState } from "react";
+import type { Selectedsite } from "@app/components/site-selector";
+
+type LauncherFilterPopoverProps = {
+ orgId: string;
+ selectedSites: Selectedsite[];
+ selectedLabels: SelectedLabel[];
+ onSitesChange: (sites: Selectedsite[]) => void;
+ onLabelsChange: (labels: SelectedLabel[]) => void;
+};
+
+export function LauncherFilterPopover({
+ orgId,
+ selectedSites,
+ selectedLabels,
+ onSitesChange,
+ onLabelsChange
+}: LauncherFilterPopoverProps) {
+ const t = useTranslations();
+ const [sitesOpen, setSitesOpen] = useState(false);
+ const [labelsOpen, setLabelsOpen] = useState(false);
+
+ const { data: labels = [] } = useQuery(
+ launcherQueries.labels({
+ orgId,
+ perPage: 500
+ })
+ );
+
+ const { data: sites = [] } = useQuery(
+ launcherQueries.sites({
+ orgId,
+ perPage: 500
+ })
+ );
+
+ const resolvedSelectedSites: Selectedsite[] = useMemo(
+ () =>
+ selectedSites.map((selected) => {
+ const found = sites.find(
+ (site) => site.siteId === selected.siteId
+ );
+ return found
+ ? {
+ siteId: found.siteId,
+ name: found.name,
+ type: found.type,
+ online: found.online
+ }
+ : selected;
+ }),
+ [sites, selectedSites]
+ );
+
+ const selectedLabelIds = useMemo(
+ () => new Set(selectedLabels.map((label) => label.labelId)),
+ [selectedLabels]
+ );
+
+ const resolvedSelectedLabels: SelectedLabel[] = useMemo(
+ () =>
+ selectedLabels.map((selected) => {
+ const found = labels.find(
+ (label) => label.labelId === selected.labelId
+ );
+ return (
+ found ?? {
+ ...selected,
+ color: selected.color || LABEL_COLORS.gray
+ }
+ );
+ }),
+ [labels, selectedLabels]
+ );
+
+ return (
+
+
+
+
+
+
+
+
{t("sites")}
+
+
+
+
+
+
+
+
+
+
+
{t("labels")}
+
+
+
+
+
+
+ selectedLabelIds.has(label.labelId)
+ }
+ onToggle={(label) => {
+ if (
+ selectedLabelIds.has(label.labelId)
+ ) {
+ onLabelsChange(
+ selectedLabels.filter(
+ (item) =>
+ item.labelId !==
+ label.labelId
+ )
+ );
+ } else {
+ onLabelsChange([
+ ...selectedLabels,
+ label
+ ]);
+ }
+ }}
+ showClear={selectedLabels.length > 0}
+ onClear={() => {
+ onLabelsChange([]);
+ }}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx
new file mode 100644
index 000000000..c7fe6b905
--- /dev/null
+++ b/src/components/resource-launcher/LauncherGroupList.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { launcherQueries } from "@app/lib/queries";
+import type {
+ LauncherGroup,
+ LauncherResource,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useEffect, useMemo, useRef } from "react";
+import { LauncherEmptyState } from "./LauncherEmptyState";
+import { LauncherGroupSection } from "./LauncherGroupSection";
+
+type LauncherGroupListProps = {
+ orgId: string;
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ initialGroups: LauncherGroup[];
+ groupsPagination: {
+ total: number;
+ page: number;
+ pageSize: number;
+ };
+ onClearFilters?: () => void;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
+ return (
+ config.query.trim().length > 0 ||
+ config.siteIds.length > 0 ||
+ config.labelIds.length > 0
+ );
+}
+
+export function LauncherGroupList({
+ orgId,
+ activeViewId,
+ config,
+ initialGroups,
+ groupsPagination,
+ onClearFilters,
+ onResourceSelect
+}: LauncherGroupListProps) {
+ const loadMoreRef = useRef(null);
+
+ const groupFilters = useMemo(
+ () => ({
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order,
+ pageSize: 20
+ }),
+ [
+ config.groupBy,
+ config.labelIds,
+ config.order,
+ config.query,
+ config.siteIds,
+ config.sortBy
+ ]
+ );
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
+ useInfiniteQuery({
+ ...launcherQueries.groups(orgId, groupFilters),
+ initialData: {
+ pages: [
+ {
+ groups: initialGroups,
+ pagination: groupsPagination
+ }
+ ],
+ pageParams: [1]
+ },
+ refetchOnMount: false
+ });
+
+ const groups = data?.pages.flatMap((page) => page.groups) ?? [];
+
+ useEffect(() => {
+ const node = loadMoreRef.current;
+ if (!node || !hasNextPage) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" }
+ );
+
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ if (groups.length === 0) {
+ if (isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ {groups.map((group) => (
+
+ ))}
+
+ {isFetchingNextPage ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx
new file mode 100644
index 000000000..49b648032
--- /dev/null
+++ b/src/components/resource-launcher/LauncherGroupSection.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import {
+ Collapsible,
+ CollapsibleContent
+} from "@app/components/ui/collapsible";
+import { cn } from "@app/lib/cn";
+import {
+ readLauncherGroupOpen,
+ writeLauncherGroupOpen,
+ type LauncherActiveViewId
+} from "@app/lib/launcherLocalStorage";
+import { launcherQueries } from "@app/lib/queries";
+import type {
+ LauncherGroup,
+ LauncherResource,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+import {
+ LAUNCHER_NO_SITE_GROUP_KEY,
+ LAUNCHER_UNLABELED_GROUP_KEY
+} from "@server/routers/launcher/types";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useRef, useState } from "react";
+import { LauncherGroupTrigger } from "./LauncherGroupTrigger";
+import { LauncherResourceGrid } from "./LauncherResourceGrid";
+import { LauncherResourceList } from "./LauncherResourceList";
+
+type LauncherGroupSectionProps = {
+ orgId: string;
+ activeViewId: LauncherActiveViewId;
+ group: LauncherGroup;
+ config: LauncherViewConfig;
+ initialResources?: LauncherResource[];
+ initialResourcesPagination?: {
+ total: number;
+ page: number;
+ pageSize: number;
+ };
+ defaultOpen?: boolean;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+export function LauncherGroupSection({
+ orgId,
+ activeViewId,
+ group,
+ config,
+ initialResources,
+ initialResourcesPagination,
+ defaultOpen = true,
+ onResourceSelect
+}: LauncherGroupSectionProps) {
+ const t = useTranslations();
+ const loadMoreRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(() =>
+ readLauncherGroupOpen(
+ orgId,
+ activeViewId,
+ config.groupBy,
+ group.groupKey,
+ defaultOpen
+ )
+ );
+
+ useEffect(() => {
+ setIsOpen(
+ readLauncherGroupOpen(
+ orgId,
+ activeViewId,
+ config.groupBy,
+ group.groupKey,
+ defaultOpen
+ )
+ );
+ }, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]);
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open);
+ writeLauncherGroupOpen(
+ orgId,
+ activeViewId,
+ config.groupBy,
+ group.groupKey,
+ open
+ );
+ };
+
+ const hasInitialResources = initialResources !== undefined;
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ useInfiniteQuery({
+ ...launcherQueries.resources(orgId, {
+ query: config.query,
+ groupBy: config.groupBy,
+ groupKey: group.groupKey,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order,
+ pageSize: 20
+ }),
+ enabled: isOpen,
+ refetchOnMount: false,
+ ...(hasInitialResources
+ ? {
+ initialData: {
+ pages: [
+ {
+ resources: initialResources,
+ pagination: initialResourcesPagination ?? {
+ total: initialResources.length,
+ page: 1,
+ pageSize: 20
+ }
+ }
+ ],
+ pageParams: [1]
+ }
+ }
+ : {})
+ });
+
+ const resources = data?.pages.flatMap((page) => page.resources) ?? [];
+ const showInitialLoader = isLoading && resources.length === 0;
+
+ useEffect(() => {
+ const node = loadMoreRef.current;
+ if (!node || !hasNextPage || !isOpen) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" }
+ );
+
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
+
+ const groupTitle =
+ group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY
+ ? t("resourceLauncherUnlabeled")
+ : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
+ ? t("resourceLauncherNoSite")
+ : group.name;
+
+ return (
+
+
+
+
+ {showInitialLoader ? (
+
+
+
+ ) : resources.length === 0 ? (
+
+ {t("resourceLauncherNoResourcesInGroup")}
+
+ ) : config.layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+ {isFetchingNextPage ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx
new file mode 100644
index 000000000..a43536fd5
--- /dev/null
+++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { CollapsibleTrigger } from "@app/components/ui/collapsible";
+import type { LauncherGroup } from "@server/routers/launcher/types";
+import { ChevronDown, ChevronLeft } from "lucide-react";
+
+type LauncherGroupTriggerProps = {
+ group: LauncherGroup;
+ title: string;
+ isOpen: boolean;
+};
+
+function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
+ if (group.groupType === "label") {
+ return (
+
+ );
+ }
+
+ if (group.groupType === "site") {
+ if (
+ (group.siteType === "newt" || group.siteType === "wireguard") &&
+ typeof group.siteOnline === "boolean"
+ ) {
+ return (
+
+ );
+ }
+
+ return ;
+ }
+
+ return null;
+}
+
+export function LauncherGroupTrigger({
+ group,
+ title,
+ isOpen
+}: LauncherGroupTriggerProps) {
+ return (
+
+ {group.groupType === "site" || group.groupType === "label" ? (
+
+ ) : null}
+
+
+ {title} ({group.itemCount})
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherLabelsRow.tsx b/src/components/resource-launcher/LauncherLabelsRow.tsx
new file mode 100644
index 000000000..e2c913ae9
--- /dev/null
+++ b/src/components/resource-launcher/LauncherLabelsRow.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import type { LauncherLabel } from "@server/routers/launcher/types";
+import { LabelBadge } from "@app/components/label-badge";
+import { LabelOverflowBadge } from "@app/components/label-overflow-badge";
+import { cn } from "@app/lib/cn";
+import { useLayoutEffect, useRef, useState } from "react";
+
+const MAX_LABEL_ROWS = 2;
+const SINGLE_ROW_MAX_LABELS = 5;
+
+type LauncherLabelsRowProps = {
+ labels: LauncherLabel[];
+ className?: string;
+ variant?: "wrap" | "single-row";
+};
+
+function countFlexRows(container: HTMLElement): number {
+ const rowTops = new Set();
+
+ for (const child of container.children) {
+ const element = child as HTMLElement;
+ if (element.style.display === "none") {
+ continue;
+ }
+ rowTops.add(element.offsetTop);
+ }
+
+ return rowTops.size;
+}
+
+export function LauncherLabelsRow({
+ labels,
+ className,
+ variant = "wrap"
+}: LauncherLabelsRowProps) {
+ const containerRef = useRef(null);
+ const measureRef = useRef(null);
+ const [visibleCount, setVisibleCount] = useState(labels.length);
+
+ const labelKey = labels.map((label) => label.labelId).join(",");
+
+ useLayoutEffect(() => {
+ if (variant === "single-row") {
+ return;
+ }
+
+ const container = containerRef.current;
+ const measure = measureRef.current;
+ if (!container || !measure || labels.length === 0) {
+ return;
+ }
+
+ const recompute = () => {
+ const width = container.clientWidth;
+ if (width <= 0) {
+ setVisibleCount(labels.length);
+ return;
+ }
+
+ measure.style.width = `${width}px`;
+
+ const labelNodes = measure.querySelectorAll(
+ "[data-measure-label]"
+ );
+ const overflowNode = measure.querySelector(
+ "[data-measure-overflow]"
+ );
+
+ const fits = (visible: number) => {
+ labelNodes.forEach((node, index) => {
+ node.style.display = index < visible ? "" : "none";
+ });
+
+ if (overflowNode) {
+ const overflowCount = labels.length - visible;
+ if (overflowCount > 0) {
+ overflowNode.style.display = "";
+ } else {
+ overflowNode.style.display = "none";
+ }
+ }
+
+ return countFlexRows(measure) <= MAX_LABEL_ROWS;
+ };
+
+ let best = 0;
+ for (let visible = labels.length; visible >= 0; visible--) {
+ if (fits(visible)) {
+ best = visible;
+ break;
+ }
+ }
+
+ setVisibleCount(best);
+ };
+
+ recompute();
+
+ const observer = new ResizeObserver(recompute);
+ observer.observe(container);
+
+ return () => observer.disconnect();
+ }, [labelKey, labels, variant]);
+
+ if (labels.length === 0) {
+ return null;
+ }
+
+ const resolvedVisibleCount =
+ variant === "single-row"
+ ? Math.min(labels.length, SINGLE_ROW_MAX_LABELS)
+ : visibleCount;
+ const visibleLabels = labels.slice(0, resolvedVisibleCount);
+ const overflowLabels = labels.slice(resolvedVisibleCount);
+
+ return (
+
+
+ {visibleLabels.map((label) => (
+
+ ))}
+ {overflowLabels.length > 0 ? (
+ ({
+ color: label.color,
+ name: label.name
+ }))}
+ displayOnly
+ className="shrink-0"
+ />
+ ) : null}
+
+
+ {variant === "wrap" ? (
+
+ {labels.map((label) => (
+
+
+
+ ))}
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherOrgSelector.tsx b/src/components/resource-launcher/LauncherOrgSelector.tsx
new file mode 100644
index 000000000..795e21927
--- /dev/null
+++ b/src/components/resource-launcher/LauncherOrgSelector.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList
+} from "@app/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import { cn } from "@app/lib/cn";
+import { ListUserOrgsResponse } from "@server/routers/org";
+import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
+import { usePathname, useRouter } from "next/navigation";
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@app/components/ui/button";
+
+type LauncherOrgSelectorProps = {
+ orgId?: string;
+ orgs?: ListUserOrgsResponse["orgs"];
+};
+
+export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
+ const [open, setOpen] = useState(false);
+ const router = useRouter();
+ const pathname = usePathname();
+ const t = useTranslations();
+
+ const selectedOrg = orgs?.find((org) => org.orgId === orgId);
+
+ const sortedOrgs = useMemo(() => {
+ if (!orgs?.length) {
+ return orgs ?? [];
+ }
+ return [...orgs].sort((a, b) => {
+ const aPrimary = Boolean(a.isPrimaryOrg);
+ const bPrimary = Boolean(b.isPrimaryOrg);
+ if (aPrimary && !bPrimary) {
+ return -1;
+ }
+ if (!aPrimary && bPrimary) {
+ return 1;
+ }
+ return 0;
+ });
+ }, [orgs]);
+
+ return (
+
+
+
+
+
+
+
+
+ {t("orgNotFound2")}
+
+ {sortedOrgs.map((org) => (
+ {
+ setOpen(false);
+ const newPath = pathname.includes(
+ "/settings/"
+ )
+ ? pathname.replace(
+ /^\/[^/]+/,
+ `/${org.orgId}`
+ )
+ : `/${org.orgId}`;
+ router.push(newPath);
+ }}
+ >
+
+
+ {org.name}
+
+
+ {org.orgId}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx
new file mode 100644
index 000000000..854128736
--- /dev/null
+++ b/src/components/resource-launcher/LauncherRefreshButton.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { useTranslations } from "next-intl";
+import { RefreshCw } from "lucide-react";
+
+type LauncherRefreshButtonProps = {
+ onRefresh: () => void;
+ isRefreshing: boolean;
+};
+
+export function LauncherRefreshButton({
+ onRefresh,
+ isRefreshing
+}: LauncherRefreshButtonProps) {
+ const t = useTranslations();
+
+ return (
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx
new file mode 100644
index 000000000..81a867e17
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceAccess.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
+import Link from "next/link";
+import { LauncherCopyIcon } from "./LauncherCopyIcon";
+
+type LauncherResourceAccessProps = {
+ accessDisplay: string;
+ accessCopyValue: string;
+ accessUrl?: string | null;
+ variant: "grid" | "list";
+};
+
+export function LauncherResourceAccess({
+ accessDisplay,
+ accessCopyValue,
+ accessUrl,
+ variant
+}: LauncherResourceAccessProps) {
+ if (!accessDisplay) {
+ return null;
+ }
+
+ const href = accessUrl ?? undefined;
+ const canLink = href && isSafeUrlForLink(href);
+ const copyValue = canLink ? href : accessCopyValue;
+
+ if (variant === "list") {
+ return (
+
+ {canLink ? (
+
+ {accessDisplay}
+
+ ) : (
+
+ {accessDisplay}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ {canLink ? (
+
+ {accessDisplay}
+
+ ) : (
+
+ {accessDisplay}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx
new file mode 100644
index 000000000..ac891e84b
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceCard.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import type { LauncherResource } from "@server/routers/launcher/types";
+import { LauncherLabelsRow } from "./LauncherLabelsRow";
+import { LauncherResourceAccess } from "./LauncherResourceAccess";
+import { LauncherResourceIcon } from "./LauncherResourceIcon";
+import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
+
+type LauncherResourceCardProps = {
+ resource: LauncherResource;
+ showLabels: boolean;
+ onSelect?: () => void;
+};
+
+export function LauncherResourceCard({
+ resource,
+ showLabels,
+ onSelect
+}: LauncherResourceCardProps) {
+ const hasIcon = Boolean(resource.iconUrl);
+ const clickProps = onSelect
+ ? getLauncherResourceSelectProps(onSelect)
+ : null;
+
+ return (
+
+
+ {hasIcon ? (
+
+ ) : null}
+
+
+
+ {resource.name}
+
+
+
+
+
+ {showLabels && resource.labels.length > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourceGrid.tsx b/src/components/resource-launcher/LauncherResourceGrid.tsx
new file mode 100644
index 000000000..0e69bee04
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceGrid.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import type { LauncherResource } from "@server/routers/launcher/types";
+import { LauncherResourceCard } from "./LauncherResourceCard";
+
+type LauncherResourceGridProps = {
+ resources: LauncherResource[];
+ showLabels: boolean;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+export function LauncherResourceGrid({
+ resources,
+ showLabels,
+ onResourceSelect
+}: LauncherResourceGridProps) {
+ return (
+
+ {resources.map((resource) => (
+ onResourceSelect(resource)
+ : undefined
+ }
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourceIcon.tsx b/src/components/resource-launcher/LauncherResourceIcon.tsx
new file mode 100644
index 000000000..a4abbd63f
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceIcon.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+
+type LauncherResourceIconProps = {
+ iconUrl?: string | null;
+ name: string;
+ className?: string;
+ variant?: "grid" | "list";
+};
+
+export function LauncherResourceIcon({
+ iconUrl,
+ name,
+ className,
+ variant = "grid"
+}: LauncherResourceIconProps) {
+ const dimension = variant === "list" ? "size-5" : "size-10";
+
+ if (iconUrl) {
+ return (
+
+ );
+ }
+
+ if (variant === "list") {
+ return (
+
+ -
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx
new file mode 100644
index 000000000..555d112a3
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceList.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { LauncherResource } from "@server/routers/launcher/types";
+import { LauncherResourceRow } from "./LauncherResourceRow";
+
+type LauncherResourceListProps = {
+ resources: LauncherResource[];
+ showLabels: boolean;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+export function LauncherResourceList({
+ resources,
+ showLabels,
+ onResourceSelect
+}: LauncherResourceListProps) {
+ return (
+
+
+ {resources.map((resource, index) => (
+ onResourceSelect(resource)
+ : undefined
+ }
+ />
+ ))}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx
new file mode 100644
index 000000000..1bb61bd9e
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourcePanel.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import {
+ SidePanel,
+ SidePanelBody,
+ SidePanelContent,
+ SidePanelDescription,
+ SidePanelFooter,
+ SidePanelHeader,
+ SidePanelTitle
+} from "@app/components/SidePanel";
+import { Button } from "@app/components/ui/button";
+import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
+import type { LauncherResource } from "@server/routers/launcher/types";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+
+type LauncherResourcePanelProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ resource: LauncherResource | null;
+ orgId: string;
+ isAdmin: boolean;
+};
+
+export function LauncherResourcePanel({
+ open,
+ onOpenChange,
+ resource,
+ orgId,
+ isAdmin
+}: LauncherResourcePanelProps) {
+ const t = useTranslations();
+
+ return (
+
+
+
+ {resource?.name ?? ""}
+
+ {t("resourceLauncherResourceDetailsDescription")}
+
+
+
+
+
+ {isAdmin && resource ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx
new file mode 100644
index 000000000..eca882c56
--- /dev/null
+++ b/src/components/resource-launcher/LauncherResourceRow.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import type { LauncherResource } from "@server/routers/launcher/types";
+import { LauncherLabelsRow } from "./LauncherLabelsRow";
+import { LauncherResourceAccess } from "./LauncherResourceAccess";
+import { LauncherResourceIcon } from "./LauncherResourceIcon";
+import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
+
+type LauncherResourceRowProps = {
+ resource: LauncherResource;
+ showLabels: boolean;
+ isLast?: boolean;
+ onSelect?: () => void;
+};
+
+export function LauncherResourceRow({
+ resource,
+ showLabels,
+ isLast = false,
+ onSelect
+}: LauncherResourceRowProps) {
+ const hasTags = showLabels && resource.labels.length > 0;
+ const clickProps = onSelect
+ ? getLauncherResourceSelectProps(onSelect)
+ : null;
+
+ return (
+
+
+
+
+ {resource.name}
+
+
+
+
+ {hasTags ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
new file mode 100644
index 000000000..41143cd10
--- /dev/null
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { Label } from "@app/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "@app/components/ui/select";
+import { Switch } from "@app/components/ui/switch";
+import type { LauncherViewConfig } from "@server/routers/launcher/types";
+import { useTranslations } from "next-intl";
+import { Settings } from "lucide-react";
+
+type LauncherSettingsMenuProps = {
+ config: LauncherViewConfig;
+ isDefaultView: boolean;
+ onConfigChange: (patch: Partial) => void;
+ onDeleteView: () => void;
+};
+
+export function LauncherSettingsMenu({
+ config,
+ isDefaultView,
+ onConfigChange,
+ onDeleteView
+}: LauncherSettingsMenuProps) {
+ const t = useTranslations();
+
+ return (
+
+
+
+
+
+
+
+
+ {t("resourceLauncherGroupBy")}
+
+
+
+
+
+
+ {t("resourceLauncherLayout")}
+
+
+
+
+
+
+
+
+ onConfigChange({ showLabels: checked })
+ }
+ />
+
+
+
+ {!isDefaultView ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherSortButton.tsx b/src/components/resource-launcher/LauncherSortButton.tsx
new file mode 100644
index 000000000..d79c3c6c2
--- /dev/null
+++ b/src/components/resource-launcher/LauncherSortButton.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { useTranslations } from "next-intl";
+import { ArrowDown01, ArrowUp10 } from "lucide-react";
+
+type LauncherSortButtonProps = {
+ order: "asc" | "desc";
+ onToggle: () => void;
+};
+
+export function LauncherSortButton({
+ order,
+ onToggle
+}: LauncherSortButtonProps) {
+ const t = useTranslations();
+
+ return (
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx
new file mode 100644
index 000000000..98d8f7289
--- /dev/null
+++ b/src/components/resource-launcher/LauncherViewTabs.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger
+} from "@app/components/ui/dropdown-menu";
+import { useTranslations } from "next-intl";
+import { ChevronDown } from "lucide-react";
+import { cn } from "@app/lib/cn";
+
+type LauncherViewTabsProps = {
+ activeViewId: number | "default";
+ savedViews: Array<{ viewId: number; name: string }>;
+ onSelectView: (viewId: number | "default") => void;
+};
+
+export function LauncherViewTabs({
+ activeViewId,
+ savedViews,
+ onSelectView
+}: LauncherViewTabsProps) {
+ const t = useTranslations();
+
+ const viewOptions: Array<{
+ value: number | "default";
+ label: string;
+ }> = [
+ { value: "default", label: t("resourceLauncherDefaultView") },
+ ...savedViews.map((view) => ({
+ value: view.viewId,
+ label: view.name
+ }))
+ ];
+
+ return (
+
+ {viewOptions.map((option) => {
+ const isSelected = activeViewId === option.value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+type LauncherSaveViewMenuProps = {
+ isDefaultView: boolean;
+ isAdmin: boolean;
+ isOrgWideView: boolean;
+ hasUnsavedChanges: boolean;
+ onSaveToCurrent: () => void;
+ onSaveAsNew: () => void;
+ onSaveForEveryone: () => void;
+ onMakePersonal: () => void;
+ onResetView: () => void;
+};
+
+export function LauncherSaveViewMenu({
+ isDefaultView,
+ isAdmin,
+ isOrgWideView,
+ hasUnsavedChanges,
+ onSaveToCurrent,
+ onSaveAsNew,
+ onSaveForEveryone,
+ onMakePersonal,
+ onResetView
+}: LauncherSaveViewMenuProps) {
+ const t = useTranslations();
+
+ return (
+
+
+
+
+
+ {hasUnsavedChanges ? (
+ <>
+
+ {t("resourceLauncherResetView")}
+
+
+ >
+ ) : null}
+ {!isDefaultView && (isAdmin || !isOrgWideView) ? (
+
+ {t("resourceLauncherSaveToCurrentView")}
+
+ ) : null}
+
+ {t("resourceLauncherSaveAsNewView")}
+
+ {isAdmin && !isDefaultView && !isOrgWideView ? (
+
+ {t("resourceLauncherSaveForEveryone")}
+
+ ) : null}
+ {isAdmin && !isDefaultView && isOrgWideView ? (
+
+ {t("resourceLauncherMakePersonal")}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
new file mode 100644
index 000000000..be5620d25
--- /dev/null
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -0,0 +1,577 @@
+"use client";
+
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import { Button } from "@app/components/ui/button";
+import { CheckboxWithLabel } from "@app/components/ui/checkbox";
+import { Input } from "@app/components/ui/input";
+import { Label } from "@app/components/ui/label";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { useNavigationContext } from "@app/hooks/useNavigationContext";
+import {
+ readLauncherLastView,
+ writeLauncherLastView,
+ type LauncherActiveViewId
+} from "@app/lib/launcherLocalStorage";
+import {
+ buildLauncherPath,
+ getLauncherUrlBaseConfig,
+ isLauncherConfigEqual,
+ parseLauncherUrlState,
+ serializeLauncherUrlState
+} from "@app/lib/launcherUrlState";
+import { useToast } from "@app/hooks/useToast";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import type {
+ LauncherGroup,
+ LauncherViewConfig,
+ LauncherViewRecord
+} from "@server/routers/launcher/types";
+import { useMutation } from "@tanstack/react-query";
+import { Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useRouter } from "next/navigation";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useTransition
+} from "react";
+import { useDebouncedCallback } from "use-debounce";
+import type { Selectedsite } from "@app/components/site-selector";
+import type { SelectedLabel } from "@app/components/labels-selector";
+import { useMediaQuery } from "@app/hooks/useMediaQuery";
+import { cn } from "@app/lib/cn";
+import { LauncherFilterPopover } from "./LauncherFilterPopover";
+import { LauncherGroupList } from "./LauncherGroupList";
+import { LauncherRefreshButton } from "./LauncherRefreshButton";
+import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
+import { LauncherSortButton } from "./LauncherSortButton";
+import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+
+type ResourceLauncherProps = {
+ orgId: string;
+ isAdmin: boolean;
+ views: LauncherViewRecord[];
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ savedConfig: LauncherViewConfig;
+ groups: LauncherGroup[];
+ groupsPagination: {
+ total: number;
+ page: number;
+ pageSize: number;
+ };
+};
+
+export default function ResourceLauncher({
+ orgId,
+ isAdmin,
+ views,
+ activeViewId,
+ config,
+ savedConfig,
+ groups,
+ groupsPagination
+}: ResourceLauncherProps) {
+ const t = useTranslations();
+ const { toast } = useToast();
+ const { env } = useEnvContext();
+ const api = createApiClient({ env });
+ const router = useRouter();
+ const { navigate, isNavigating, searchParams } = useNavigationContext();
+ const [isRefreshing, startRefreshTransition] = useTransition();
+ const hasRestoredLastView = useRef(false);
+
+ const [searchInputResetKey, setSearchInputResetKey] = useState(0);
+ const [saveDialogOpen, setSaveDialogOpen] = useState(false);
+ const [newViewName, setNewViewName] = useState("");
+ const [saveOrgWide, setSaveOrgWide] = useState(false);
+
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ const configRef = useRef(config);
+ configRef.current = config;
+ const searchInputRef = useRef(config.query);
+ const activeViewIdRef = useRef(activeViewId);
+ activeViewIdRef.current = activeViewId;
+
+ useEffect(() => {
+ if (hasRestoredLastView.current) {
+ return;
+ }
+ hasRestoredLastView.current = true;
+
+ const parsed = parseLauncherUrlState(searchParams);
+ if (parsed.hasAnyLauncherParams) {
+ return;
+ }
+
+ const lastView = readLauncherLastView(orgId);
+ if (lastView === null || lastView === activeViewId) {
+ return;
+ }
+
+ const isValid =
+ lastView === "default" ||
+ views.some((view) => view.viewId === lastView);
+ if (!isValid) {
+ return;
+ }
+
+ const baseConfig = getLauncherUrlBaseConfig(lastView, views);
+ const params = serializeLauncherUrlState({
+ viewId: lastView,
+ config: baseConfig
+ });
+ navigate({ searchParams: params, replace: true });
+ }, [activeViewId, navigate, orgId, searchParams, views]);
+
+ const navigateToConfig = useCallback(
+ (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
+ const params = serializeLauncherUrlState({
+ viewId,
+ config: nextConfig
+ });
+ navigate({ searchParams: params });
+ },
+ [navigate]
+ );
+
+ const debouncedNavigateSearch = useDebouncedCallback(
+ (viewId: LauncherActiveViewId, query: string) => {
+ navigateToConfig(viewId, { ...configRef.current, query });
+ },
+ 300
+ );
+
+ const selectView = useCallback(
+ (viewId: LauncherActiveViewId) => {
+ writeLauncherLastView(orgId, viewId);
+ const baseConfig = getLauncherUrlBaseConfig(viewId, views);
+ navigateToConfig(viewId, baseConfig);
+ },
+ [navigateToConfig, orgId, views]
+ );
+
+ const activeSavedView = useMemo(
+ () =>
+ activeViewId === "default"
+ ? null
+ : views.find((view) => view.viewId === activeViewId),
+ [activeViewId, views]
+ );
+
+ const isDefaultView = activeViewId === "default";
+ const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
+ const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
+
+ const selectedSites: Selectedsite[] = useMemo(
+ () =>
+ config.siteIds.map((siteId) => ({
+ siteId,
+ name: String(siteId),
+ type: "newt"
+ })),
+ [config.siteIds]
+ );
+
+ const selectedLabels: SelectedLabel[] = useMemo(
+ () =>
+ config.labelIds.map((labelId) => ({
+ labelId,
+ name: String(labelId),
+ color: "#a1a1aa"
+ })),
+ [config.labelIds]
+ );
+
+ const createViewMutation = useMutation({
+ mutationFn: async (payload: {
+ name: string;
+ config: LauncherViewConfig;
+ orgWide: boolean;
+ }) => {
+ const res = await api.post(`/org/${orgId}/launcher/views`, payload);
+ return res.data.data as LauncherViewRecord;
+ },
+ onSuccess: (view) => {
+ writeLauncherLastView(orgId, view.viewId);
+ const params = serializeLauncherUrlState({
+ viewId: view.viewId,
+ config: view.config
+ });
+ navigate({ searchParams: params, replace: true });
+ router.refresh();
+ setSaveDialogOpen(false);
+ setNewViewName("");
+ toast({
+ title: t("resourceLauncherViewSaved"),
+ description: t("resourceLauncherViewSavedDescription")
+ });
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ title: t("resourceLauncherViewSaveFailed"),
+ description: formatAxiosError(
+ error,
+ t("resourceLauncherViewSaveFailedDescription")
+ )
+ });
+ }
+ });
+
+ const updateViewMutation = useMutation({
+ mutationFn: async (payload: {
+ viewId: number;
+ name?: string;
+ config?: LauncherViewConfig;
+ orgWide?: boolean;
+ }) => {
+ const { viewId, ...body } = payload;
+ const res = await api.put(
+ `/org/${orgId}/launcher/views/${viewId}`,
+ body
+ );
+ return res.data.data as LauncherViewRecord;
+ },
+ onSuccess: (view) => {
+ const params = serializeLauncherUrlState({
+ viewId: view.viewId,
+ config: view.config
+ });
+ navigate({ searchParams: params, replace: true });
+ router.refresh();
+ toast({
+ title: t("resourceLauncherViewSaved"),
+ description: t("resourceLauncherViewSavedDescription")
+ });
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ title: t("resourceLauncherViewSaveFailed"),
+ description: formatAxiosError(
+ error,
+ t("resourceLauncherViewSaveFailedDescription")
+ )
+ });
+ }
+ });
+
+ const deleteViewMutation = useMutation({
+ mutationFn: async (viewId: number) => {
+ await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
+ },
+ onSuccess: () => {
+ writeLauncherLastView(orgId, "default");
+ const params = serializeLauncherUrlState({
+ viewId: "default",
+ config: getLauncherUrlBaseConfig("default", views)
+ });
+ navigate({ searchParams: params, replace: true });
+ router.refresh();
+ toast({
+ title: t("resourceLauncherViewDeleted"),
+ description: t("resourceLauncherViewDeletedDescription")
+ });
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ title: t("resourceLauncherViewDeleteFailed"),
+ description: formatAxiosError(
+ error,
+ t("resourceLauncherViewDeleteFailedDescription")
+ )
+ });
+ }
+ });
+
+ const applyConfigPatch = useCallback(
+ (patch: Partial) => {
+ const nextConfig = {
+ ...configRef.current,
+ ...patch,
+ query: searchInputRef.current
+ };
+ navigateToConfig(activeViewIdRef.current, nextConfig);
+ },
+ [navigateToConfig]
+ );
+
+ const handleClearFilters = useCallback(() => {
+ searchInputRef.current = "";
+ setSearchInputResetKey((key) => key + 1);
+ navigateToConfig(activeViewIdRef.current, {
+ ...configRef.current,
+ query: "",
+ siteIds: [],
+ labelIds: []
+ });
+ }, [navigateToConfig]);
+
+ const handleResetView = useCallback(() => {
+ searchInputRef.current = savedConfig.query;
+ setSearchInputResetKey((key) => key + 1);
+ navigateToConfig(activeViewIdRef.current, savedConfig);
+ }, [navigateToConfig, savedConfig]);
+
+ const refreshData = () => {
+ startRefreshTransition(async () => {
+ try {
+ router.refresh();
+ } catch {
+ toast({
+ title: t("error"),
+ description: t("refreshError"),
+ variant: "destructive"
+ });
+ }
+ });
+ };
+
+ const handleSaveToCurrent = () => {
+ if (isDefaultView || (isOrgWideView && !isAdmin)) {
+ return;
+ }
+ updateViewMutation.mutate({
+ viewId: activeViewId,
+ config
+ });
+ };
+
+ const handleSaveAsNew = () => {
+ setSaveOrgWide(false);
+ setNewViewName("");
+ setSaveDialogOpen(true);
+ };
+
+ const handleSaveForEveryone = () => {
+ if (isDefaultView) {
+ return;
+ }
+ updateViewMutation.mutate({
+ viewId: activeViewId,
+ orgWide: true
+ });
+ };
+
+ const handleMakePersonal = () => {
+ if (isDefaultView) {
+ return;
+ }
+ updateViewMutation.mutate({
+ viewId: activeViewId,
+ orgWide: false
+ });
+ };
+
+ const handleCreateView = () => {
+ if (!newViewName.trim()) {
+ return;
+ }
+ createViewMutation.mutate({
+ name: newViewName.trim(),
+ config,
+ orgWide: saveOrgWide && isAdmin
+ });
+ };
+
+ const savedViewTabs = views.map((view) => ({
+ viewId: view.viewId,
+ name: view.name
+ }));
+
+ const renderToolbarSearch = (searchClassName: string) => (
+
+
+ {
+ const value = event.currentTarget.value;
+ searchInputRef.current = value;
+ debouncedNavigateSearch(activeViewIdRef.current, value);
+ }}
+ placeholder={t("resourceLauncherSearchPlaceholder")}
+ className="pl-8"
+ type="search"
+ />
+
+ );
+
+ const renderToolbarActions = () => (
+ <>
+
+
+ applyConfigPatch({
+ siteIds: sites.map((site) => site.siteId)
+ })
+ }
+ onLabelsChange={(labels) =>
+ applyConfigPatch({
+ labelIds: labels.map((label) => label.labelId)
+ })
+ }
+ />
+
+ applyConfigPatch({
+ order: config.order === "asc" ? "desc" : "asc"
+ })
+ }
+ />
+ {
+ if (!isDefaultView) {
+ deleteViewMutation.mutate(activeViewId);
+ }
+ }}
+ />
+
+ >
+ );
+
+ const renderToolbarViews = () => (
+
+ );
+
+ return (
+
+
+
+ {isDesktop ? (
+
+ {renderToolbarSearch("w-64")}
+
+ {renderToolbarViews()}
+
+
+ {renderToolbarActions()}
+
+
+ ) : (
+
+
+ {renderToolbarActions()}
+
+ {renderToolbarSearch("w-full")}
+
+ {renderToolbarViews()}
+
+
+ )}
+
+
+
+
+
+
+
+ {t("resourceLauncherSaveAsNewView")}
+
+
+ {t("resourceLauncherSaveAsNewViewDescription")}
+
+
+
+
+
+
+ setNewViewName(event.target.value)
+ }
+ />
+
+ {isAdmin ? (
+
+
+ setSaveOrgWide(checked === true)
+ }
+ />
+
+ {t(
+ "resourceLauncherSaveForEveryoneDescription"
+ )}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts
new file mode 100644
index 000000000..4c7d081dd
--- /dev/null
+++ b/src/components/resource-launcher/useLauncherResourceAction.ts
@@ -0,0 +1,127 @@
+"use client";
+
+import { useToast } from "@app/hooks/useToast";
+import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
+import { useTranslations } from "next-intl";
+import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
+
+type LauncherResourceActionInput = {
+ accessUrl?: string | null;
+ accessCopyValue: string;
+};
+
+export function useLauncherResourceAction({
+ accessUrl,
+ accessCopyValue
+}: LauncherResourceActionInput) {
+ const { toast } = useToast();
+ const t = useTranslations();
+
+ const href = accessUrl ?? undefined;
+ const canLink = Boolean(href && isSafeUrlForLink(href));
+ const isClickable = canLink || Boolean(accessCopyValue);
+
+ const handleAction = useCallback(() => {
+ if (canLink && href) {
+ window.open(href, "_blank", "noopener,noreferrer");
+ return;
+ }
+
+ if (!accessCopyValue) {
+ return;
+ }
+
+ void navigator.clipboard.writeText(accessCopyValue).then(() => {
+ toast({
+ title: t("resourceLauncherCopiedToClipboard"),
+ description: t("resourceLauncherCopiedAccessDescription"),
+ duration: 2000
+ });
+ });
+ }, [accessCopyValue, canLink, href, t, toast]);
+
+ return { handleAction, isClickable };
+}
+
+export function isLauncherResourceInteractiveTarget(
+ target: EventTarget | null,
+ container?: EventTarget | null
+): boolean {
+ if (!(target instanceof Element)) {
+ return false;
+ }
+
+ const interactive = target.closest(
+ "a, button, [role='button'], input, textarea, select"
+ );
+
+ if (!interactive) {
+ return false;
+ }
+
+ if (container instanceof Element && interactive === container) {
+ return false;
+ }
+
+ return true;
+}
+
+function handleLauncherResourceClick(
+ event: MouseEvent,
+ handleAction: () => void
+) {
+ if (
+ isLauncherResourceInteractiveTarget(event.target, event.currentTarget)
+ ) {
+ return;
+ }
+
+ handleAction();
+}
+
+export function getLauncherResourceSelectProps(onSelect: () => void) {
+ return {
+ onClick: (event: MouseEvent) => {
+ if (
+ isLauncherResourceInteractiveTarget(
+ event.target,
+ event.currentTarget
+ )
+ ) {
+ return;
+ }
+
+ onSelect();
+ },
+ className: "cursor-pointer",
+ role: "button" as const,
+ tabIndex: 0,
+ onKeyDown: (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onSelect();
+ }
+ }
+ };
+}
+
+export function getLauncherResourceClickProps(
+ handleAction: () => void,
+ isClickable: boolean
+) {
+ return {
+ onClick: (event: MouseEvent) =>
+ handleLauncherResourceClick(event, handleAction),
+ className: isClickable ? "cursor-pointer" : undefined,
+ role: isClickable ? ("button" as const) : undefined,
+ tabIndex: isClickable ? 0 : undefined,
+ onKeyDown: isClickable
+ ? (event: KeyboardEvent) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ handleAction();
+ }
+ }
+ : undefined
+ };
+}
diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx
index 7a88cab0b..bd735f9b5 100644
--- a/src/components/resource-policy/PolicyAccessRulesSection.tsx
+++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx
@@ -340,7 +340,8 @@ function PolicyAccessRulesSectionEdit({
? rules.filter((rule) => !rule.fromPolicy)
: rules;
const rulesPayload = rulesToValidate.map(
- ({ action, match, value, priority, enabled }) => ({
+ ({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({
+ ...(isNew ? {} : { ruleId }),
action,
match,
value,
diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts
new file mode 100644
index 000000000..6e1fb60a5
--- /dev/null
+++ b/src/lib/launcherLocalStorage.ts
@@ -0,0 +1,98 @@
+export type LauncherActiveViewId = number | "default";
+
+const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:";
+const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:";
+
+function lastViewKey(orgId: string) {
+ return `${LAST_VIEW_PREFIX}${orgId}`;
+}
+
+function groupOpenKey(
+ orgId: string,
+ viewId: LauncherActiveViewId,
+ groupBy: "site" | "label"
+) {
+ return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
+}
+
+function readJson(key: string, fallback: T): T {
+ if (typeof window === "undefined") {
+ return fallback;
+ }
+
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw ? (JSON.parse(raw) as T) : fallback;
+ } catch (error) {
+ console.warn(`Error reading localStorage key "${key}":`, error);
+ return fallback;
+ }
+}
+
+function writeJson(key: string, value: unknown) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.warn(`Error writing localStorage key "${key}":`, error);
+ }
+}
+
+export function readLauncherLastView(
+ orgId: string
+): LauncherActiveViewId | null {
+ const value = readJson(
+ lastViewKey(orgId),
+ null
+ );
+ if (value === "default" || typeof value === "number") {
+ return value;
+ }
+ return null;
+}
+
+export function writeLauncherLastView(
+ orgId: string,
+ viewId: LauncherActiveViewId
+) {
+ writeJson(lastViewKey(orgId), viewId);
+}
+
+export function readLauncherGroupOpenState(
+ orgId: string,
+ viewId: LauncherActiveViewId,
+ groupBy: "site" | "label"
+): Record {
+ return readJson>(
+ groupOpenKey(orgId, viewId, groupBy),
+ {}
+ );
+}
+
+export function readLauncherGroupOpen(
+ orgId: string,
+ viewId: LauncherActiveViewId,
+ groupBy: "site" | "label",
+ groupKey: string,
+ defaultOpen: boolean
+): boolean {
+ const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
+ return groupKey in state ? state[groupKey] : defaultOpen;
+}
+
+export function writeLauncherGroupOpen(
+ orgId: string,
+ viewId: LauncherActiveViewId,
+ groupBy: "site" | "label",
+ groupKey: string,
+ isOpen: boolean
+) {
+ const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
+ writeJson(groupOpenKey(orgId, viewId, groupBy), {
+ ...state,
+ [groupKey]: isOpen
+ });
+}
diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts
new file mode 100644
index 000000000..d7dd888ac
--- /dev/null
+++ b/src/lib/launcherResourceAccess.ts
@@ -0,0 +1,123 @@
+import {
+ formatSiteResourceDestinationDisplay,
+ type SiteResourceDestinationInput
+} from "./formatSiteResourceAccess";
+
+export {
+ formatSiteResourceDestinationDisplay,
+ resolveHttpHttpsDisplayPort,
+ type SiteResourceDestinationInput
+} from "./formatSiteResourceAccess";
+
+export type PublicResourceAccessInput = {
+ mode: string;
+ fullDomain: string | null;
+ ssl: boolean;
+ proxyPort: number | null;
+ wildcard: boolean;
+};
+
+export type SiteResourceAccessInput = {
+ mode: string;
+ destination: string | null;
+ destinationPort: number | null;
+ scheme: "http" | "https" | null;
+ ssl: boolean;
+ fullDomain: string | null;
+ alias: string | null;
+ aliasAddress: string | null;
+};
+
+export type LauncherAccessFields = {
+ accessDisplay: string;
+ accessCopyValue: string;
+ accessUrl: string | null;
+};
+
+export function formatPublicResourceAccess(
+ resource: PublicResourceAccessInput
+): LauncherAccessFields {
+ const browserModes = ["http", "ssh", "rdp", "vnc"];
+ if (!browserModes.includes(resource.mode)) {
+ const port = resource.proxyPort?.toString() ?? "";
+ return {
+ accessDisplay: port,
+ accessCopyValue: port,
+ accessUrl: null
+ };
+ }
+
+ if (!resource.fullDomain) {
+ return {
+ accessDisplay: "",
+ accessCopyValue: "",
+ accessUrl: null
+ };
+ }
+
+ const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
+ return {
+ accessDisplay: url,
+ accessCopyValue: url,
+ accessUrl: resource.wildcard ? null : url
+ };
+}
+
+export function formatSiteResourceAccess(
+ resource: SiteResourceAccessInput
+): LauncherAccessFields {
+ if (resource.alias) {
+ return {
+ accessDisplay: resource.alias,
+ accessCopyValue: resource.alias,
+ accessUrl: null
+ };
+ }
+
+ if (resource.mode === "http" && resource.fullDomain) {
+ const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
+ return {
+ accessDisplay: url,
+ accessCopyValue: url,
+ accessUrl: url
+ };
+ }
+
+ const destination = formatSiteResourceDestinationDisplay({
+ mode: resource.mode as SiteResourceDestinationInput["mode"],
+ destination: resource.destination,
+ destinationPort: resource.destinationPort,
+ scheme: resource.scheme
+ });
+
+ if (destination) {
+ return {
+ accessDisplay: destination,
+ accessCopyValue: destination,
+ accessUrl: resource.mode === "http" ? destination : null
+ };
+ }
+
+ if (resource.aliasAddress) {
+ return {
+ accessDisplay: resource.aliasAddress,
+ accessCopyValue: resource.aliasAddress,
+ accessUrl: null
+ };
+ }
+
+ return {
+ accessDisplay: "",
+ accessCopyValue: "",
+ accessUrl: null
+ };
+}
+
+export function isSafeUrlForLink(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/launcherResourceAdminHref.ts b/src/lib/launcherResourceAdminHref.ts
new file mode 100644
index 000000000..db7da151e
--- /dev/null
+++ b/src/lib/launcherResourceAdminHref.ts
@@ -0,0 +1,17 @@
+import type { LauncherResource } from "@server/routers/launcher/types";
+
+export function getLauncherResourceAdminHref(
+ orgId: string,
+ resource: LauncherResource
+): string {
+ if (resource.resourceType === "public") {
+ return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
+ }
+
+ const qs = new URLSearchParams({ query: resource.niceId });
+ if (resource.site?.siteId != null) {
+ qs.set("siteId", String(resource.site.siteId));
+ }
+
+ return `/${orgId}/settings/resources/private?${qs.toString()}`;
+}
diff --git a/src/lib/launcherSearchParams.ts b/src/lib/launcherSearchParams.ts
new file mode 100644
index 000000000..acaccd06f
--- /dev/null
+++ b/src/lib/launcherSearchParams.ts
@@ -0,0 +1,43 @@
+import type { LauncherListQuery } from "@server/routers/launcher/types";
+
+export type LauncherQueryFilters = {
+ query?: string;
+ groupBy?: LauncherListQuery["groupBy"];
+ groupKey?: string;
+ siteIds?: number[];
+ labelIds?: number[];
+ sort_by?: LauncherListQuery["sort_by"];
+ order?: LauncherListQuery["order"];
+ pageSize?: number;
+};
+
+export function buildLauncherSearchParams(
+ filters: LauncherQueryFilters,
+ page: number
+) {
+ const sp = new URLSearchParams();
+ sp.set("page", String(page));
+ sp.set("pageSize", String(filters.pageSize ?? 20));
+ if (filters.query) {
+ sp.set("query", filters.query);
+ }
+ if (filters.groupBy) {
+ sp.set("groupBy", filters.groupBy);
+ }
+ if (filters.groupKey) {
+ sp.set("groupKey", filters.groupKey);
+ }
+ if (filters.siteIds?.length) {
+ sp.set("siteIds", filters.siteIds.join(","));
+ }
+ if (filters.labelIds?.length) {
+ sp.set("labelIds", filters.labelIds.join(","));
+ }
+ if (filters.sort_by) {
+ sp.set("sort_by", filters.sort_by);
+ }
+ if (filters.order) {
+ sp.set("order", filters.order);
+ }
+ return sp;
+}
diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts
new file mode 100644
index 000000000..2c542c5fc
--- /dev/null
+++ b/src/lib/launcherServerData.ts
@@ -0,0 +1,82 @@
+import { internal } from "@app/lib/api";
+import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
+import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
+import type {
+ LauncherGroup,
+ LauncherViewConfig,
+ LauncherViewRecord,
+ ListLauncherGroupsResponse,
+ ListLauncherViewsResponse
+} from "@server/routers/launcher/types";
+import { AxiosResponse } from "axios";
+
+export type LauncherPageData = {
+ views: LauncherViewRecord[];
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ savedConfig: LauncherViewConfig;
+ groups: LauncherGroup[];
+ groupsPagination: {
+ total: number;
+ page: number;
+ pageSize: number;
+ };
+};
+
+export async function fetchLauncherPageData(
+ orgId: string,
+ searchParams: URLSearchParams,
+ cookieHeader: Awaited<
+ ReturnType
+ >
+): Promise {
+ let views: LauncherViewRecord[] = [];
+ try {
+ const viewsRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/views`, cookieHeader);
+ views = viewsRes.data.data.views;
+ } catch (e) {}
+
+ const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
+ searchParams,
+ views,
+ null
+ );
+
+ const groupFilters = {
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order,
+ pageSize: 20
+ };
+
+ let groups: LauncherGroup[] = [];
+ let groupsPagination: LauncherPageData["groupsPagination"] = {
+ total: 0,
+ page: 1,
+ pageSize: 20
+ };
+
+ try {
+ const sp = buildLauncherSearchParams(groupFilters, 1);
+ const groupsRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
+ groups = groupsRes.data.data.groups;
+ groupsPagination = groupsRes.data.data.pagination;
+ } catch (e) {}
+
+ return {
+ views,
+ activeViewId,
+ config,
+ savedConfig,
+ groups,
+ groupsPagination
+ };
+}
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
new file mode 100644
index 000000000..cfe8e26c4
--- /dev/null
+++ b/src/lib/launcherUrlState.ts
@@ -0,0 +1,278 @@
+import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import {
+ defaultLauncherViewConfig,
+ parseIdListParam,
+ type LauncherViewConfig,
+ type LauncherViewRecord
+} from "@server/routers/launcher/types";
+import { z } from "zod";
+
+const launcherUrlBooleanSchema = z
+ .enum(["0", "1"])
+ .transform((value) => value === "1");
+
+export type LauncherUrlConfigOverrides = Partial<
+ Pick<
+ LauncherViewConfig,
+ | "groupBy"
+ | "layout"
+ | "order"
+ | "showLabels"
+ | "siteIds"
+ | "labelIds"
+ | "query"
+ >
+>;
+
+export type ParsedLauncherUrlState = {
+ viewId: LauncherActiveViewId | null;
+ configOverrides: LauncherUrlConfigOverrides;
+ hasAnyLauncherParams: boolean;
+};
+
+export type ResolvedLauncherState = {
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ savedConfig: LauncherViewConfig;
+};
+
+const LAUNCHER_CONFIG_PARAM_KEYS = [
+ "query",
+ "groupBy",
+ "layout",
+ "order",
+ "showLabels",
+ "siteIds",
+ "labelIds"
+] as const;
+
+const LAUNCHER_URL_PARAM_KEYS = [
+ "view",
+ ...LAUNCHER_CONFIG_PARAM_KEYS
+] as const;
+
+export function hasLauncherConfigParams(searchParams: URLSearchParams) {
+ return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key));
+}
+
+export function isLauncherConfigEqual(
+ a: LauncherViewConfig,
+ b: LauncherViewConfig
+) {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+export function getLauncherUrlBaseConfig(
+ viewId: LauncherActiveViewId,
+ views: LauncherViewRecord[]
+): LauncherViewConfig {
+ if (viewId === "default") {
+ return defaultLauncherViewConfig;
+ }
+
+ const savedView = views.find((view) => view.viewId === viewId);
+ return savedView?.config ?? defaultLauncherViewConfig;
+}
+
+export function resolveLauncherConfig(
+ baseConfig: LauncherViewConfig,
+ overrides: LauncherUrlConfigOverrides
+): LauncherViewConfig {
+ return {
+ ...baseConfig,
+ ...overrides,
+ sortBy: "name"
+ };
+}
+
+function parseViewParam(value: string | null): LauncherActiveViewId | null {
+ if (value === null) {
+ return null;
+ }
+
+ if (value === "default") {
+ return "default";
+ }
+
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) {
+ return "default";
+ }
+
+ return parsed;
+}
+
+function parseConfigOverrides(
+ searchParams: URLSearchParams
+): LauncherUrlConfigOverrides {
+ const overrides: LauncherUrlConfigOverrides = {};
+
+ const query = searchParams.get("query");
+ if (query !== null) {
+ overrides.query = query;
+ }
+
+ const groupBy = searchParams.get("groupBy");
+ if (groupBy === "site" || groupBy === "label") {
+ overrides.groupBy = groupBy;
+ }
+
+ const layout = searchParams.get("layout");
+ if (layout === "grid" || layout === "list") {
+ overrides.layout = layout;
+ }
+
+ const order = searchParams.get("order");
+ if (order === "asc" || order === "desc") {
+ overrides.order = order;
+ }
+
+ const showLabels = searchParams.get("showLabels");
+ if (showLabels !== null) {
+ const parsed = launcherUrlBooleanSchema.safeParse(showLabels);
+ if (parsed.success) {
+ overrides.showLabels = parsed.data;
+ }
+ }
+
+ const siteIds = searchParams.get("siteIds");
+ if (siteIds !== null) {
+ overrides.siteIds = parseIdListParam(siteIds);
+ }
+
+ const labelIds = searchParams.get("labelIds");
+ if (labelIds !== null) {
+ overrides.labelIds = parseIdListParam(labelIds);
+ }
+
+ return overrides;
+}
+
+export function parseLauncherUrlState(
+ searchParams: URLSearchParams
+): ParsedLauncherUrlState {
+ const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) =>
+ searchParams.has(key)
+ );
+
+ return {
+ viewId: parseViewParam(searchParams.get("view")),
+ configOverrides: parseConfigOverrides(searchParams),
+ hasAnyLauncherParams
+ };
+}
+
+function isValidActiveViewId(
+ viewId: LauncherActiveViewId,
+ views: LauncherViewRecord[]
+) {
+ return viewId === "default" || views.some((view) => view.viewId === viewId);
+}
+
+export function resolveLauncherStateFromUrl(
+ searchParams: URLSearchParams,
+ views: LauncherViewRecord[],
+ fallbackViewId: LauncherActiveViewId | null
+): ResolvedLauncherState {
+ const parsed = parseLauncherUrlState(searchParams);
+
+ let activeViewId: LauncherActiveViewId = "default";
+
+ if (parsed.viewId !== null) {
+ activeViewId = isValidActiveViewId(parsed.viewId, views)
+ ? parsed.viewId
+ : "default";
+ } else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) {
+ activeViewId = isValidActiveViewId(fallbackViewId, views)
+ ? fallbackViewId
+ : "default";
+ }
+
+ const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
+
+ let config: LauncherViewConfig;
+ if (hasLauncherConfigParams(searchParams)) {
+ config = resolveLauncherConfig(
+ defaultLauncherViewConfig,
+ parsed.configOverrides
+ );
+ } else if (activeViewId !== "default") {
+ config = savedConfig;
+ } else {
+ config = defaultLauncherViewConfig;
+ }
+
+ return {
+ activeViewId,
+ config,
+ savedConfig
+ };
+}
+
+function idListsEqual(a: number[], b: number[]) {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ return a.every((value, index) => value === b[index]);
+}
+
+export function serializeLauncherUrlState({
+ viewId,
+ config
+}: {
+ viewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+}): URLSearchParams {
+ const baseConfig = defaultLauncherViewConfig;
+ const params = new URLSearchParams();
+
+ if (viewId !== "default") {
+ params.set("view", String(viewId));
+ }
+
+ if (config.query !== baseConfig.query && config.query) {
+ params.set("query", config.query);
+ } else if (config.query !== baseConfig.query && !config.query) {
+ params.set("query", "");
+ }
+
+ if (config.groupBy !== baseConfig.groupBy) {
+ params.set("groupBy", config.groupBy);
+ }
+
+ if (config.layout !== baseConfig.layout) {
+ params.set("layout", config.layout);
+ }
+
+ if (config.order !== baseConfig.order) {
+ params.set("order", config.order);
+ }
+
+ if (config.showLabels !== baseConfig.showLabels) {
+ params.set("showLabels", config.showLabels ? "1" : "0");
+ }
+
+ if (!idListsEqual(config.siteIds, baseConfig.siteIds)) {
+ if (config.siteIds.length > 0) {
+ params.set("siteIds", config.siteIds.join(","));
+ } else {
+ params.set("siteIds", "");
+ }
+ }
+
+ if (!idListsEqual(config.labelIds, baseConfig.labelIds)) {
+ if (config.labelIds.length > 0) {
+ params.set("labelIds", config.labelIds.join(","));
+ } else {
+ params.set("labelIds", "");
+ }
+ }
+
+ return params;
+}
+
+export function buildLauncherPath(orgId: string, params: URLSearchParams) {
+ const query = params.toString();
+ return query ? `/${orgId}?${query}` : `/${orgId}`;
+}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index b8a50a908..14fa1d3da 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -46,6 +46,20 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
+import type {
+ ListLauncherGroupsResponse,
+ ListLauncherLabelsResponse,
+ ListLauncherResourcesResponse,
+ ListLauncherSitesResponse,
+ ListLauncherViewsResponse,
+ LauncherListQuery,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
+import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
+
+export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
+export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
export type ProductUpdate = {
link: string | null;
@@ -1166,3 +1180,123 @@ export const domainQueries = {
refetchInterval: durationToMs(10, "seconds")
})
};
+
+export const launcherQueries = {
+ views: (orgId: string) =>
+ queryOptions({
+ queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const,
+ queryFn: async ({ signal, meta }) => {
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/views`, { signal });
+ return res.data.data.views;
+ }
+ }),
+ sites: ({
+ orgId,
+ query,
+ perPage = 500
+ }: {
+ orgId: string;
+ query?: string;
+ perPage?: number;
+ }) =>
+ queryOptions({
+ queryKey: [
+ "ORG",
+ orgId,
+ "LAUNCHER",
+ "SITES",
+ { query, perPage }
+ ] as const,
+ queryFn: async ({ signal, meta }) => {
+ const sp = new URLSearchParams({
+ pageSize: perPage.toString()
+ });
+
+ if (query?.trim()) {
+ sp.set("query", query);
+ }
+
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal });
+ return res.data.data.sites;
+ }
+ }),
+ labels: ({
+ orgId,
+ query,
+ perPage = 500
+ }: {
+ orgId: string;
+ query?: string;
+ perPage?: number;
+ }) =>
+ queryOptions({
+ queryKey: [
+ "ORG",
+ orgId,
+ "LAUNCHER",
+ "LABELS",
+ { query, perPage }
+ ] as const,
+ queryFn: async ({ signal, meta }) => {
+ const sp = new URLSearchParams({
+ pageSize: perPage.toString()
+ });
+
+ if (query?.trim()) {
+ sp.set("query", query);
+ }
+
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/labels?${sp.toString()}`, {
+ signal
+ });
+ return res.data.data.labels;
+ }
+ }),
+ groups: (orgId: string, filters: LauncherQueryFilters) =>
+ infiniteQueryOptions({
+ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
+ queryFn: async ({ pageParam = 1, signal, meta }) => {
+ const sp = buildLauncherSearchParams(filters, pageParam);
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
+ return res.data.data;
+ },
+ initialPageParam: 1,
+ placeholderData: keepPreviousData,
+ getNextPageParam: (lastPage) => {
+ const { page, pageSize, total } = lastPage.pagination;
+ const nextPage = page + 1;
+ return page * pageSize < total ? nextPage : undefined;
+ }
+ }),
+ resources: (
+ orgId: string,
+ filters: LauncherQueryFilters & { groupKey: string }
+ ) =>
+ infiniteQueryOptions({
+ queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
+ queryFn: async ({ pageParam = 1, signal, meta }) => {
+ const sp = buildLauncherSearchParams(filters, pageParam);
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
+ signal
+ });
+ return res.data.data;
+ },
+ initialPageParam: 1,
+ placeholderData: keepPreviousData,
+ getNextPageParam: (lastPage) => {
+ const { page, pageSize, total } = lastPage.pagination;
+ const nextPage = page + 1;
+ return page * pageSize < total ? nextPage : undefined;
+ }
+ })
+};