mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 14:44:52 +02:00
add compact mode for large orgs
This commit is contained in:
@@ -139,6 +139,10 @@ export function LauncherFilterPopover({
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { LAUNCHER_FLAT_GROUP_KEY } 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 { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherFlatResourceListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
onClearFilters?: () => void;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherFlatResourceList({
|
||||
orgId,
|
||||
config,
|
||||
onClearFilters,
|
||||
onResourceSelect
|
||||
}: LauncherFlatResourceListProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const resourceFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: LAUNCHER_FLAT_GROUP_KEY,
|
||||
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.resources(orgId, resourceFilters)
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
|
||||
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 (resources.length === 0) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,16 +69,20 @@ export function LauncherGroupList({
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.groups(orgId, groupFilters),
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false
|
||||
...(initialGroups.length > 0
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false as const
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherSearchFirstGateProps = {
|
||||
layout: "grid" | "list";
|
||||
};
|
||||
|
||||
function GhostResourceGrid() {
|
||||
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"
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostResourceList() {
|
||||
return (
|
||||
<div className="flex w-full flex-col" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3",
|
||||
index < 2 && "border-b border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherSearchFirstGate({
|
||||
layout
|
||||
}: LauncherSearchFirstGateProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||
{layout === "grid" ? (
|
||||
<GhostResourceGrid />
|
||||
) : (
|
||||
<GhostResourceList />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("resourceLauncherSearchFirstTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherSearchFirstDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,13 +15,19 @@ import {
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||
import type {
|
||||
LauncherScaleCapabilities,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
type LauncherSettingsMenuProps = {
|
||||
config: LauncherViewConfig;
|
||||
isDefaultView: boolean;
|
||||
capabilities: LauncherScaleCapabilities;
|
||||
isCompactMode: boolean;
|
||||
selectedGroupBy: LauncherViewConfig["groupBy"];
|
||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||
onDeleteView: () => void;
|
||||
};
|
||||
@@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = {
|
||||
export function LauncherSettingsMenu({
|
||||
config,
|
||||
isDefaultView,
|
||||
capabilities,
|
||||
isCompactMode,
|
||||
selectedGroupBy,
|
||||
onConfigChange,
|
||||
onDeleteView
|
||||
}: LauncherSettingsMenuProps) {
|
||||
@@ -51,7 +60,7 @@ export function LauncherSettingsMenu({
|
||||
{t("resourceLauncherGroupBy")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.groupBy}
|
||||
value={selectedGroupBy}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
groupBy:
|
||||
@@ -63,14 +72,44 @@ export function LauncherSettingsMenu({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
<SelectItem value="none">
|
||||
{t("resourceLauncherGroupByNone")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="site"
|
||||
disabled={
|
||||
isCompactMode ||
|
||||
!capabilities.allowSiteGrouping
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupBySite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="label">
|
||||
<SelectItem
|
||||
value="label"
|
||||
disabled={
|
||||
isCompactMode ||
|
||||
!capabilities.allowLabelGrouping
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupByLabel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCompactMode ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherCompactGroupingLocked")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowSiteGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherSiteGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowLabelGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherLabelGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -29,12 +29,20 @@ import {
|
||||
} from "@app/lib/launcherUrlState";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
getEffectiveLauncherConfig,
|
||||
shouldShowFlatResourceList,
|
||||
shouldShowLauncherGroupList,
|
||||
shouldShowSearchFirstGate
|
||||
} from "@app/lib/launcherScale";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherScaleInfo,
|
||||
LauncherViewConfig,
|
||||
LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -52,7 +60,9 @@ 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 { LauncherFlatResourceList } from "./LauncherFlatResourceList";
|
||||
import { LauncherGroupList } from "./LauncherGroupList";
|
||||
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||
import { LauncherSortButton } from "./LauncherSortButton";
|
||||
@@ -66,6 +76,7 @@ type ResourceLauncherProps = {
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
scale: LauncherScaleInfo;
|
||||
groups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
@@ -81,6 +92,7 @@ export default function ResourceLauncher({
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig,
|
||||
scale: initialScale,
|
||||
groups,
|
||||
groupsPagination
|
||||
}: ResourceLauncherProps) {
|
||||
@@ -100,6 +112,38 @@ export default function ResourceLauncher({
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const scaleFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data: scale = initialScale } = useQuery({
|
||||
...launcherQueries.scale(orgId, scaleFilters),
|
||||
initialData: initialScale
|
||||
});
|
||||
|
||||
const showGroupList = shouldShowLauncherGroupList(scale, config);
|
||||
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
|
||||
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
|
||||
const effectiveConfig = useMemo(
|
||||
() => getEffectiveLauncherConfig(scale, config),
|
||||
[config, scale]
|
||||
);
|
||||
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
const searchInputRef = useRef(config.query);
|
||||
@@ -451,6 +495,9 @@ export default function ResourceLauncher({
|
||||
<LauncherSettingsMenu
|
||||
config={config}
|
||||
isDefaultView={isDefaultView}
|
||||
capabilities={scale.capabilities}
|
||||
isCompactMode={scale.mode === "compact"}
|
||||
selectedGroupBy={effectiveConfig.groupBy}
|
||||
onConfigChange={applyConfigPatch}
|
||||
onDeleteView={() => {
|
||||
if (!isDefaultView) {
|
||||
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={config}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
{scale.mode === "compact" && !showSearchFirstGate ? (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{t("resourceLauncherCompactModeHint")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{showSearchFirstGate ? (
|
||||
<LauncherSearchFirstGate layout={config.layout} />
|
||||
) : showGroupList ? (
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
) : showFlatResourceList ? (
|
||||
<LauncherFlatResourceList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<CredenzaContent>
|
||||
|
||||
Reference in New Issue
Block a user