open side panel on click

This commit is contained in:
miloschwartz
2026-07-01 14:30:43 -04:00
parent 5a1d5cb66e
commit 0871a211ec
14 changed files with 343 additions and 28 deletions
+1
View File
@@ -3573,6 +3573,7 @@
"resourceLauncherShowRecents": "Show Recents", "resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View", "resourceLauncherDeleteView": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site", "resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -791,6 +791,7 @@ async function mapPublicResources(
const rows = await db const rows = await db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
niceId: resources.niceId,
name: resources.name, name: resources.name,
mode: resources.mode, mode: resources.mode,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
@@ -842,6 +843,7 @@ async function mapPublicResources(
launcherResourceKey: key, launcherResourceKey: key,
resourceType: "public", resourceType: "public",
resourceId: row.resourceId, resourceId: row.resourceId,
niceId: row.niceId,
name: row.name, name: row.name,
...access, ...access,
iconUrl: null, iconUrl: null,
@@ -876,6 +878,7 @@ async function mapSiteResources(
const rows = await db const rows = await db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
mode: siteResources.mode, mode: siteResources.mode,
destination: siteResources.destination, destination: siteResources.destination,
@@ -934,6 +937,7 @@ async function mapSiteResources(
resourceType: "site", resourceType: "site",
resourceId: row.siteResourceId, resourceId: row.siteResourceId,
siteResourceId: row.siteResourceId, siteResourceId: row.siteResourceId,
niceId: row.niceId,
name: row.name, name: row.name,
...access, ...access,
iconUrl: null, iconUrl: null,
+1
View File
@@ -39,6 +39,7 @@ export type LauncherResource = {
resourceType: "public" | "site"; resourceType: "public" | "site";
resourceId: number; resourceId: number;
siteResourceId?: number; siteResourceId?: number;
niceId: string;
name: string; name: string;
accessDisplay: string; accessDisplay: string;
accessCopyValue: string; accessCopyValue: string;
+164
View File
@@ -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 <Sheet {...props}>{children}</Sheet>;
};
const SidePanelTrigger = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetTrigger className={className} {...props}>
{children}
</SheetTrigger>
);
};
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetClose className={className} {...props}>
{children}
</SheetClose>
);
};
const SidePanelContent = ({
className,
children,
...props
}: SidePanelProps) => {
const isDesktop = useMediaQuery(desktop);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
isDesktop
? "inset-y-0 right-0 h-full w-2/5 border-l"
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
className
)}
{...props}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{children}
</SheetPrimitive.Content>
</SheetPortal>
);
};
const SidePanelDescription = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetDescription className={className} {...props}>
{children}
</SheetDescription>
);
};
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetHeader
className={cn("shrink-0 -mx-6 px-6", className)}
{...props}
>
{children}
</SheetHeader>
);
};
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetTitle className={className} {...props}>
{children}
</SheetTitle>
);
};
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
return (
<div
className={cn(
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
className
)}
{...props}
>
<div className="space-y-4">{children}</div>
<div
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
aria-hidden
/>
</div>
);
};
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetFooter
className={cn(
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
className
)}
{...props}
>
{children}
</SheetFooter>
);
};
export {
SidePanel,
SidePanelBody,
SidePanelClose,
SidePanelContent,
SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle,
SidePanelTrigger
};
@@ -5,6 +5,7 @@ import type { LauncherGroupResources } from "@app/lib/launcherServerData";
import { launcherQueries } from "@app/lib/queries"; import { launcherQueries } from "@app/lib/queries";
import type { import type {
LauncherGroup, LauncherGroup,
LauncherResource,
LauncherViewConfig LauncherViewConfig
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
@@ -25,6 +26,7 @@ type LauncherGroupListProps = {
}; };
resourcesByGroupKey: Record<string, LauncherGroupResources>; resourcesByGroupKey: Record<string, LauncherGroupResources>;
onClearFilters?: () => void; onClearFilters?: () => void;
onResourceSelect: (resource: LauncherResource) => void;
}; };
function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
@@ -42,7 +44,8 @@ export function LauncherGroupList({
initialGroups, initialGroups,
groupsPagination, groupsPagination,
resourcesByGroupKey, resourcesByGroupKey,
onClearFilters onClearFilters,
onResourceSelect
}: LauncherGroupListProps) { }: LauncherGroupListProps) {
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
@@ -137,6 +140,7 @@ export function LauncherGroupList({
config={config} config={config}
initialResources={groupResources?.resources} initialResources={groupResources?.resources}
initialResourcesPagination={groupResources?.pagination} initialResourcesPagination={groupResources?.pagination}
onResourceSelect={onResourceSelect}
/> />
); );
})} })}
@@ -40,6 +40,7 @@ type LauncherGroupSectionProps = {
pageSize: number; pageSize: number;
}; };
defaultOpen?: boolean; defaultOpen?: boolean;
onResourceSelect: (resource: LauncherResource) => void;
}; };
export function LauncherGroupSection({ export function LauncherGroupSection({
@@ -49,7 +50,8 @@ export function LauncherGroupSection({
config, config,
initialResources, initialResources,
initialResourcesPagination, initialResourcesPagination,
defaultOpen = true defaultOpen = true,
onResourceSelect
}: LauncherGroupSectionProps) { }: LauncherGroupSectionProps) {
const t = useTranslations(); const t = useTranslations();
const loadMoreRef = useRef<HTMLDivElement | null>(null); const loadMoreRef = useRef<HTMLDivElement | null>(null);
@@ -175,11 +177,13 @@ export function LauncherGroupSection({
<LauncherResourceGrid <LauncherResourceGrid
resources={resources} resources={resources}
showLabels={config.showLabels} showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/> />
) : ( ) : (
<LauncherResourceList <LauncherResourceList
resources={resources} resources={resources}
showLabels={config.showLabels} showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/> />
)} )}
<div <div
@@ -5,26 +5,21 @@ import type { LauncherResource } from "@server/routers/launcher/types";
import { LauncherLabelsRow } from "./LauncherLabelsRow"; import { LauncherLabelsRow } from "./LauncherLabelsRow";
import { LauncherResourceAccess } from "./LauncherResourceAccess"; import { LauncherResourceAccess } from "./LauncherResourceAccess";
import { LauncherResourceIcon } from "./LauncherResourceIcon"; import { LauncherResourceIcon } from "./LauncherResourceIcon";
import { import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
getLauncherResourceClickProps,
useLauncherResourceAction
} from "./useLauncherResourceAction";
type LauncherResourceCardProps = { type LauncherResourceCardProps = {
resource: LauncherResource; resource: LauncherResource;
showLabels: boolean; showLabels: boolean;
onSelect: () => void;
}; };
export function LauncherResourceCard({ export function LauncherResourceCard({
resource, resource,
showLabels showLabels,
onSelect
}: LauncherResourceCardProps) { }: LauncherResourceCardProps) {
const hasIcon = Boolean(resource.iconUrl); const hasIcon = Boolean(resource.iconUrl);
const { handleAction, isClickable } = useLauncherResourceAction({ const clickProps = getLauncherResourceSelectProps(onSelect);
accessUrl: resource.accessUrl,
accessCopyValue: resource.accessCopyValue
});
const clickProps = getLauncherResourceClickProps(handleAction, isClickable);
return ( return (
<div <div
@@ -6,11 +6,13 @@ import { LauncherResourceCard } from "./LauncherResourceCard";
type LauncherResourceGridProps = { type LauncherResourceGridProps = {
resources: LauncherResource[]; resources: LauncherResource[];
showLabels: boolean; showLabels: boolean;
onResourceSelect: (resource: LauncherResource) => void;
}; };
export function LauncherResourceGrid({ export function LauncherResourceGrid({
resources, resources,
showLabels showLabels,
onResourceSelect
}: LauncherResourceGridProps) { }: LauncherResourceGridProps) {
return ( return (
<div className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"> <div className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0">
@@ -19,6 +21,7 @@ export function LauncherResourceGrid({
key={resource.launcherResourceKey} key={resource.launcherResourceKey}
resource={resource} resource={resource}
showLabels={showLabels} showLabels={showLabels}
onSelect={() => onResourceSelect(resource)}
/> />
))} ))}
</div> </div>
@@ -6,11 +6,13 @@ import { LauncherResourceRow } from "./LauncherResourceRow";
type LauncherResourceListProps = { type LauncherResourceListProps = {
resources: LauncherResource[]; resources: LauncherResource[];
showLabels: boolean; showLabels: boolean;
onResourceSelect: (resource: LauncherResource) => void;
}; };
export function LauncherResourceList({ export function LauncherResourceList({
resources, resources,
showLabels showLabels,
onResourceSelect
}: LauncherResourceListProps) { }: LauncherResourceListProps) {
return ( return (
<div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden"> <div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden">
@@ -21,6 +23,7 @@ export function LauncherResourceList({
resource={resource} resource={resource}
showLabels={showLabels} showLabels={showLabels}
isLast={index === resources.length - 1} isLast={index === resources.length - 1}
onSelect={() => onResourceSelect(resource)}
/> />
))} ))}
</div> </div>
@@ -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 (
<SidePanel open={open} onOpenChange={onOpenChange}>
<SidePanelContent>
<SidePanelHeader>
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
<SidePanelDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SidePanelDescription>
</SidePanelHeader>
<SidePanelBody />
<SidePanelFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("close")}
</Button>
{isAdmin && resource ? (
<Button variant="outline" asChild>
<Link
href={getLauncherResourceAdminHref(
orgId,
resource
)}
>
{t("resourceLauncherViewAsAdmin")}
</Link>
</Button>
) : null}
</SidePanelFooter>
</SidePanelContent>
</SidePanel>
);
}
@@ -5,28 +5,23 @@ import type { LauncherResource } from "@server/routers/launcher/types";
import { LauncherLabelsRow } from "./LauncherLabelsRow"; import { LauncherLabelsRow } from "./LauncherLabelsRow";
import { LauncherResourceAccess } from "./LauncherResourceAccess"; import { LauncherResourceAccess } from "./LauncherResourceAccess";
import { LauncherResourceIcon } from "./LauncherResourceIcon"; import { LauncherResourceIcon } from "./LauncherResourceIcon";
import { import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
getLauncherResourceClickProps,
useLauncherResourceAction
} from "./useLauncherResourceAction";
type LauncherResourceRowProps = { type LauncherResourceRowProps = {
resource: LauncherResource; resource: LauncherResource;
showLabels: boolean; showLabels: boolean;
isLast?: boolean; isLast?: boolean;
onSelect: () => void;
}; };
export function LauncherResourceRow({ export function LauncherResourceRow({
resource, resource,
showLabels, showLabels,
isLast = false isLast = false,
onSelect
}: LauncherResourceRowProps) { }: LauncherResourceRowProps) {
const hasTags = showLabels && resource.labels.length > 0; const hasTags = showLabels && resource.labels.length > 0;
const { handleAction, isClickable } = useLauncherResourceAction({ const clickProps = getLauncherResourceSelectProps(onSelect);
accessUrl: resource.accessUrl,
accessCopyValue: resource.accessCopyValue
});
const clickProps = getLauncherResourceClickProps(handleAction, isClickable);
return ( return (
<div <div
@@ -32,6 +32,7 @@ import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import type { import type {
LauncherGroup, LauncherGroup,
LauncherResource,
LauncherViewConfig, LauncherViewConfig,
LauncherViewRecord LauncherViewRecord
} from "@server/routers/launcher/types"; } from "@server/routers/launcher/types";
@@ -55,6 +56,7 @@ import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
@@ -96,6 +98,8 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<LauncherResource | null>(null);
const [newViewName, setNewViewName] = useState(""); const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false); const [saveOrgWide, setSaveOrgWide] = useState(false);
@@ -491,6 +495,19 @@ export default function ResourceLauncher({
groupsPagination={groupsPagination} groupsPagination={groupsPagination}
resourcesByGroupKey={resourcesByGroupKey} resourcesByGroupKey={resourcesByGroupKey}
onClearFilters={handleClearFilters} onClearFilters={handleClearFilters}
onResourceSelect={setSelectedResource}
/>
<LauncherResourcePanel
open={selectedResource != null}
onOpenChange={(open) => {
if (!open) {
setSelectedResource(null);
}
}}
resource={selectedResource}
orgId={orgId}
isAdmin={isAdmin}
/> />
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}> <Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
@@ -44,28 +44,67 @@ export function useLauncherResourceAction({
} }
export function isLauncherResourceInteractiveTarget( export function isLauncherResourceInteractiveTarget(
target: EventTarget | null target: EventTarget | null,
container?: EventTarget | null
): boolean { ): boolean {
if (!(target instanceof Element)) { if (!(target instanceof Element)) {
return false; return false;
} }
return Boolean( const interactive = target.closest(
target.closest("a, button, [role='button'], input, textarea, select") "a, button, [role='button'], input, textarea, select"
); );
if (!interactive) {
return false;
}
if (container instanceof Element && interactive === container) {
return false;
}
return true;
} }
function handleLauncherResourceClick( function handleLauncherResourceClick(
event: MouseEvent, event: MouseEvent,
handleAction: () => void handleAction: () => void
) { ) {
if (isLauncherResourceInteractiveTarget(event.target)) { if (
isLauncherResourceInteractiveTarget(event.target, event.currentTarget)
) {
return; return;
} }
handleAction(); 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( export function getLauncherResourceClickProps(
handleAction: () => void, handleAction: () => void,
isClickable: boolean isClickable: boolean
+17
View File
@@ -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()}`;
}