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

View File

@@ -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",

View File

@@ -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,

View File

@@ -39,6 +39,7 @@ export type LauncherResource = {
resourceType: "public" | "site";
resourceId: number;
siteResourceId?: number;
niceId: string;
name: string;
accessDisplay: string;
accessCopyValue: string;

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
};

View File

@@ -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<string, LauncherGroupResources>;
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<HTMLDivElement | null>(null);
@@ -137,6 +140,7 @@ export function LauncherGroupList({
config={config}
initialResources={groupResources?.resources}
initialResourcesPagination={groupResources?.pagination}
onResourceSelect={onResourceSelect}
/>
);
})}

View File

@@ -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<HTMLDivElement | null>(null);
@@ -175,11 +177,13 @@ export function LauncherGroupSection({
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div

View File

@@ -5,26 +5,21 @@ 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 LauncherResourceCardProps = {
resource: LauncherResource;
showLabels: boolean;
onSelect: () => 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 (
<div

View File

@@ -6,11 +6,13 @@ import { LauncherResourceCard } from "./LauncherResourceCard";
type LauncherResourceGridProps = {
resources: LauncherResource[];
showLabels: boolean;
onResourceSelect: (resource: LauncherResource) => void;
};
export function LauncherResourceGrid({
resources,
showLabels
showLabels,
onResourceSelect
}: LauncherResourceGridProps) {
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">
@@ -19,6 +21,7 @@ export function LauncherResourceGrid({
key={resource.launcherResourceKey}
resource={resource}
showLabels={showLabels}
onSelect={() => onResourceSelect(resource)}
/>
))}
</div>

View File

@@ -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 (
<div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden">
@@ -21,6 +23,7 @@ export function LauncherResourceList({
resource={resource}
showLabels={showLabels}
isLast={index === resources.length - 1}
onSelect={() => onResourceSelect(resource)}
/>
))}
</div>

View File

@@ -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>
);
}

View File

@@ -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 (
<div

View File

@@ -32,6 +32,7 @@ import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type {
LauncherGroup,
LauncherResource,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
@@ -55,6 +56,7 @@ import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
@@ -96,6 +98,8 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<LauncherResource | null>(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}
/>
<LauncherResourcePanel
open={selectedResource != null}
onOpenChange={(open) => {
if (!open) {
setSelectedResource(null);
}
}}
resource={selectedResource}
orgId={orgId}
isAdmin={isAdmin}
/>
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>

View File

@@ -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

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()}`;
}