mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-02 10:34:55 +00:00
open side panel on click
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type LauncherResource = {
|
||||
resourceType: "public" | "site";
|
||||
resourceId: number;
|
||||
siteResourceId?: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
|
||||
164
src/components/SidePanel.tsx
Normal file
164
src/components/SidePanel.tsx
Normal 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 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
68
src/components/resource-launcher/LauncherResourcePanel.tsx
Normal file
68
src/components/resource-launcher/LauncherResourcePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
17
src/lib/launcherResourceAdminHref.ts
Normal file
17
src/lib/launcherResourceAdminHref.ts
Normal 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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user