From 0871a211ecaf7ef2509256e942ccb8105fbf96cb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 14:30:43 -0400 Subject: [PATCH] open side panel on click --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 4 + server/routers/launcher/types.ts | 1 + src/components/SidePanel.tsx | 164 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 6 +- .../LauncherGroupSection.tsx | 6 +- .../LauncherResourceCard.tsx | 15 +- .../LauncherResourceGrid.tsx | 5 +- .../LauncherResourceList.tsx | 5 +- .../LauncherResourcePanel.tsx | 68 ++++++++ .../resource-launcher/LauncherResourceRow.tsx | 15 +- .../resource-launcher/ResourceLauncher.tsx | 17 ++ .../useLauncherResourceAction.ts | 47 ++++- src/lib/launcherResourceAdminHref.ts | 17 ++ 14 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 src/components/SidePanel.tsx create mode 100644 src/components/resource-launcher/LauncherResourcePanel.tsx create mode 100644 src/lib/launcherResourceAdminHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2c938639a..1621bb8d4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3573,6 +3573,7 @@ "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 85ca24a23..60365d496 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -791,6 +791,7 @@ async function mapPublicResources( const rows = await db .select({ resourceId: resources.resourceId, + niceId: resources.niceId, name: resources.name, mode: resources.mode, fullDomain: resources.fullDomain, @@ -842,6 +843,7 @@ async function mapPublicResources( launcherResourceKey: key, resourceType: "public", resourceId: row.resourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, @@ -876,6 +878,7 @@ async function mapSiteResources( const rows = await db .select({ siteResourceId: siteResources.siteResourceId, + niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, destination: siteResources.destination, @@ -934,6 +937,7 @@ async function mapSiteResources( resourceType: "site", resourceId: row.siteResourceId, siteResourceId: row.siteResourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 9d08271ae..0a252d74d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -39,6 +39,7 @@ export type LauncherResource = { resourceType: "public" | "site"; resourceId: number; siteResourceId?: number; + niceId: string; name: string; accessDisplay: string; accessCopyValue: string; 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 ( +
+
{children}
+
+
+ ); +}; + +const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +export { + SidePanel, + SidePanelBody, + SidePanelClose, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle, + SidePanelTrigger +}; diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index 7ca7e745d..a631aff6c 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -5,6 +5,7 @@ import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -25,6 +26,7 @@ type LauncherGroupListProps = { }; resourcesByGroupKey: Record; onClearFilters?: () => void; + onResourceSelect: (resource: LauncherResource) => void; }; function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { @@ -42,7 +44,8 @@ export function LauncherGroupList({ initialGroups, groupsPagination, resourcesByGroupKey, - onClearFilters + onClearFilters, + onResourceSelect }: LauncherGroupListProps) { const loadMoreRef = useRef(null); @@ -137,6 +140,7 @@ export function LauncherGroupList({ config={config} initialResources={groupResources?.resources} initialResourcesPagination={groupResources?.pagination} + onResourceSelect={onResourceSelect} /> ); })} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 2d1b483dd..1d4881835 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -40,6 +40,7 @@ type LauncherGroupSectionProps = { pageSize: number; }; defaultOpen?: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherGroupSection({ @@ -49,7 +50,8 @@ export function LauncherGroupSection({ config, initialResources, initialResourcesPagination, - defaultOpen = true + defaultOpen = true, + onResourceSelect }: LauncherGroupSectionProps) { const t = useTranslations(); const loadMoreRef = useRef(null); @@ -175,11 +177,13 @@ export function LauncherGroupSection({ ) : ( )}
void; }; export function LauncherResourceCard({ resource, - showLabels + showLabels, + onSelect }: LauncherResourceCardProps) { const hasIcon = Boolean(resource.iconUrl); - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
void; }; export function LauncherResourceGrid({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceGridProps) { return (
@@ -19,6 +21,7 @@ export function LauncherResourceGrid({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} + onSelect={() => onResourceSelect(resource)} /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index d95b58adf..e64e2b7b0 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,11 +6,13 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherResourceList({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceListProps) { return (
@@ -21,6 +23,7 @@ export function LauncherResourceList({ resource={resource} showLabels={showLabels} isLast={index === resources.length - 1} + onSelect={() => onResourceSelect(resource)} /> ))}
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 index d94823b27..34558de51 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -5,28 +5,23 @@ import type { LauncherResource } from "@server/routers/launcher/types"; import { LauncherLabelsRow } from "./LauncherLabelsRow"; import { LauncherResourceAccess } from "./LauncherResourceAccess"; import { LauncherResourceIcon } from "./LauncherResourceIcon"; -import { - getLauncherResourceClickProps, - useLauncherResourceAction -} from "./useLauncherResourceAction"; +import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; isLast?: boolean; + onSelect: () => void; }; export function LauncherResourceRow({ resource, showLabels, - isLast = false + isLast = false, + onSelect }: LauncherResourceRowProps) { const hasTags = showLabels && resource.labels.length > 0; - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
(null); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -491,6 +495,19 @@ export default function ResourceLauncher({ groupsPagination={groupsPagination} resourcesByGroupKey={resourcesByGroupKey} onClearFilters={handleClearFilters} + onResourceSelect={setSelectedResource} + /> + + { + if (!open) { + setSelectedResource(null); + } + }} + resource={selectedResource} + orgId={orgId} + isAdmin={isAdmin} /> diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts index 427ee64bf..4c7d081dd 100644 --- a/src/components/resource-launcher/useLauncherResourceAction.ts +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -44,28 +44,67 @@ export function useLauncherResourceAction({ } export function isLauncherResourceInteractiveTarget( - target: EventTarget | null + target: EventTarget | null, + container?: EventTarget | null ): boolean { if (!(target instanceof Element)) { return false; } - return Boolean( - target.closest("a, button, [role='button'], input, textarea, select") + 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)) { + 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 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()}`; +}