feat(dashboard): added new Pages
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-portal": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
|
||||
@@ -17,7 +17,7 @@ const ListDashboardsServer = async ({ projectId }: Props) => {
|
||||
return (
|
||||
<Padding>
|
||||
<HeaderDashboards />
|
||||
<ListDashboards dashboards={dashboards} />;
|
||||
<ListDashboards dashboards={dashboards} />
|
||||
</Padding>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useUser } from '@clerk/nextjs';
|
||||
import {
|
||||
GanttChartIcon,
|
||||
Globe2Icon,
|
||||
LayersIcon,
|
||||
LayoutPanelTopIcon,
|
||||
PlusIcon,
|
||||
ScanEyeIcon,
|
||||
@@ -89,6 +90,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||
label="Dashboards"
|
||||
href={`/${params.organizationSlug}/${projectId}/dashboards`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={LayersIcon}
|
||||
label="Pages"
|
||||
href={`/${params.organizationSlug}/${projectId}/pages`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={Globe2Icon}
|
||||
label="Realtime"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
|
||||
import { Padding } from '@/components/ui/padding';
|
||||
import { parseAsStringEnum } from 'nuqs';
|
||||
|
||||
import { Pages } from './pages';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
projectId: string;
|
||||
};
|
||||
searchParams: {
|
||||
tab: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params: { projectId },
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const tab = parseAsStringEnum(['pages', 'trends'])
|
||||
.withDefault('pages')
|
||||
.parseServerSide(searchParams.tab);
|
||||
|
||||
return (
|
||||
<Padding>
|
||||
<PageTabs className="mb-4">
|
||||
<PageTabsLink href="?tab=pages" isActive={tab === 'pages'}>
|
||||
Pages
|
||||
</PageTabsLink>
|
||||
</PageTabs>
|
||||
{tab === 'pages' && <Pages projectId={projectId} />}
|
||||
</Padding>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { LazyChart } from '@/components/report/chart/LazyChart';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
|
||||
import type { IServicePage } from '@openpanel/db';
|
||||
|
||||
export function PagesTable({ data }: { data: IServicePage[] }) {
|
||||
const number = useNumber();
|
||||
const cell =
|
||||
'flex min-h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border';
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border bg-background">
|
||||
<div className={cn('min-w-[800px]')}>
|
||||
<div className="grid grid-cols-[0.2fr_auto_1fr] overflow-hidden rounded-t-none border-b">
|
||||
<div className="center-center h-10 rounded-tl-md bg-def-100 p-4 font-semibold text-muted-foreground">
|
||||
Views
|
||||
</div>
|
||||
<div className="flex h-10 w-80 items-center bg-def-100 p-4 font-semibold text-muted-foreground">
|
||||
Path
|
||||
</div>
|
||||
<div className="flex h-10 items-center rounded-tr-md bg-def-100 p-4 font-semibold text-muted-foreground">
|
||||
Chart
|
||||
</div>
|
||||
</div>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={item.path + item.origin + item.title}
|
||||
className="grid grid-cols-[0.2fr_auto_1fr] border-b transition-colors last:border-b-0 hover:bg-muted/50 data-[state=selected]:bg-muted"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
cell,
|
||||
'center-center font-mono text-lg font-semibold',
|
||||
index === data.length - 1 && 'rounded-bl-md'
|
||||
)}
|
||||
>
|
||||
{number.short(item.count)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
cell,
|
||||
'flex w-80 flex-col justify-center gap-2 text-left'
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium">{item.title}</span>
|
||||
{item.origin ? (
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
className="truncate font-mono text-sm text-muted-foreground underline"
|
||||
>
|
||||
<ExternalLinkIcon className="mr-2 inline-block size-3" />
|
||||
{item.path}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate font-mono text-sm text-muted-foreground">
|
||||
{item.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
cell,
|
||||
'p-1',
|
||||
index === data.length - 1 && 'rounded-br-md'
|
||||
)}
|
||||
>
|
||||
<LazyChart
|
||||
hideYAxis
|
||||
hideXAxis
|
||||
className="w-full"
|
||||
lineType="linear"
|
||||
breakdowns={[]}
|
||||
name="screen_view"
|
||||
metric="sum"
|
||||
range="30d"
|
||||
interval="day"
|
||||
previous
|
||||
aspectRatio={0.15}
|
||||
chartType="linear"
|
||||
projectId={item.project_id}
|
||||
events={[
|
||||
{
|
||||
id: 'A',
|
||||
name: 'screen_view',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
id: 'path',
|
||||
name: 'path',
|
||||
value: [item.path],
|
||||
operator: 'is',
|
||||
},
|
||||
{
|
||||
id: 'origin',
|
||||
name: 'origin',
|
||||
value: [item.origin],
|
||||
operator: 'is',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { TableButtons } from '@/components/data-table';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TableSkeleton } from '@/components/ui/table';
|
||||
import { useDebounceValue } from '@/hooks/useDebounceValue';
|
||||
import { api } from '@/trpc/client';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { PagesTable } from './pages-table';
|
||||
|
||||
export function Pages({ projectId }: { projectId: string }) {
|
||||
const take = 20;
|
||||
const [cursor, setCursor] = useQueryState(
|
||||
'cursor',
|
||||
parseAsInteger.withDefault(0)
|
||||
);
|
||||
const [search, setSearch] = useQueryState('search', {
|
||||
defaultValue: '',
|
||||
shallow: true,
|
||||
});
|
||||
const debouncedSearch = useDebounceValue(search, 500);
|
||||
const query = api.event.pages.useQuery(
|
||||
{
|
||||
projectId,
|
||||
cursor,
|
||||
take,
|
||||
search: debouncedSearch,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
const data = query.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableButtons>
|
||||
<Input
|
||||
placeholder="Serch path"
|
||||
value={search ?? ''}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setCursor(0);
|
||||
}}
|
||||
/>
|
||||
</TableButtons>
|
||||
{query.isLoading ? (
|
||||
<TableSkeleton cols={3} />
|
||||
) : (
|
||||
<PagesTable data={data} />
|
||||
)}
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
setCursor={setCursor}
|
||||
cursor={cursor}
|
||||
count={Infinity}
|
||||
take={take}
|
||||
loading={query.isFetching}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableSkeleton } from '@/components/ui/table';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
import { column } from 'mathjs';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
@@ -25,15 +27,7 @@ export const EventsTable = ({ query, ...props }: Props) => {
|
||||
const { data, isFetching, isLoading } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
return <TableSkeleton cols={columns.length} />;
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
|
||||
@@ -60,7 +60,7 @@ export const GridCell: React.FC<
|
||||
}) => (
|
||||
<Component
|
||||
className={cn(
|
||||
'flex h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
|
||||
'flex min-h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
|
||||
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
|
||||
colSpan && `col-span-${colSpan}`,
|
||||
className
|
||||
|
||||
@@ -9,7 +9,7 @@ export function PageTabs({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('overflow-x-auto', className)}>
|
||||
<div className={cn('h-7 overflow-x-auto', className)}>
|
||||
<div className="flex gap-4 whitespace-nowrap text-3xl font-semibold">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableSkeleton } from '@/components/ui/table';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
@@ -26,15 +27,7 @@ export const ProfilesTable = ({ type, query, ...props }: Props) => {
|
||||
const { data, isFetching, isLoading } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
return <TableSkeleton cols={columns.length} />;
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCardEmpty } from './MetricCard';
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
export function ChartEmpty() {
|
||||
const { editMode, chartType } = useChartContext();
|
||||
@@ -20,12 +20,10 @@ export function ChartEmpty() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex aspect-video max-h-[300px] min-h-[200px] w-full items-center justify-center'
|
||||
}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
<ResponsiveContainer>
|
||||
<div className={'flex h-full w-full items-center justify-center'}>
|
||||
No data
|
||||
</div>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ChartLoadingProps {
|
||||
className?: string;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
export function ChartLoading({ className }: ChartLoadingProps) {
|
||||
export function ChartLoading({ className, aspectRatio }: ChartLoadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-def-200 aspect-video max-h-[300px] min-h-[200px] w-full animate-pulse rounded',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full w-full animate-pulse rounded bg-def-200',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import type { IChartProps, IChartSerie } from '@openpanel/validation';
|
||||
|
||||
export interface IChartContextType extends IChartProps {
|
||||
hideXAxis?: boolean;
|
||||
hideYAxis?: boolean;
|
||||
aspectRatio?: number;
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
onClick?: (item: IChartSerie) => void;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import type { IChartRoot } from '.';
|
||||
import { ChartRoot } from '.';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
|
||||
export function LazyChart(props: IChartRoot) {
|
||||
export function LazyChart({
|
||||
className,
|
||||
...props
|
||||
}: IChartRoot & { className?: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(ref, undefined, {
|
||||
@@ -21,11 +25,11 @@ export function LazyChart(props: IChartRoot) {
|
||||
}, [inViewport]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} className={cn('w-full', className)}>
|
||||
{once.current || inViewport ? (
|
||||
<ChartRoot {...props} editMode={false} />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
@@ -24,11 +26,35 @@ export function ReportChartTooltip({
|
||||
const { unit, interval } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const number = useNumber();
|
||||
if (!active || !payload) {
|
||||
return null;
|
||||
}
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
if (!payload.length) {
|
||||
const inactive = !active || !payload?.length;
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
type: 'mousemove',
|
||||
listener(event) {
|
||||
if (!inactive) {
|
||||
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubDragEnter = bind(window, {
|
||||
type: 'pointerdown',
|
||||
listener() {
|
||||
setPosition(null);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMouseMove();
|
||||
unsubDragEnter();
|
||||
};
|
||||
}, [inactive]);
|
||||
|
||||
if (inactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,55 +67,81 @@ export function ReportChartTooltip({
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
const correctXPosition = (x: number | undefined) => {
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tooltipWidth = 300;
|
||||
const screenWidth = window.innerWidth;
|
||||
const newX = x;
|
||||
|
||||
if (newX + tooltipWidth > screenWidth) {
|
||||
return screenWidth - tooltipWidth;
|
||||
}
|
||||
return newX;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = (
|
||||
item.dataKey.includes(':')
|
||||
? // @ts-expect-error
|
||||
payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload
|
||||
) as IRechartPayloadItem;
|
||||
<Portal.Portal
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
left: correctXPosition(position?.x),
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = (
|
||||
item.dataKey.includes(':')
|
||||
? // @ts-expect-error
|
||||
payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload
|
||||
) as IRechartPayloadItem;
|
||||
|
||||
return (
|
||||
<React.Fragment key={data.id}>
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={data.names} />
|
||||
<SerieName name={data.names} />
|
||||
return (
|
||||
<React.Fragment key={data.id}>
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
</div>
|
||||
<div className="font-mono flex justify-between gap-8 font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count, unit)}
|
||||
{!!data.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={data.names} />
|
||||
<SerieName name={data.names} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count, unit)}
|
||||
{!!data.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicator {...data.previous} />
|
||||
<PreviousDiffIndicator {...data.previous} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground">and {hidden.length} more...</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
and {hidden.length} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Portal.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor, theme } from '@/utils/theme';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
@@ -21,7 +20,11 @@ interface ReportHistogramChartProps {
|
||||
}
|
||||
|
||||
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
const bg = theme?.colors?.slate?.['200'] as string;
|
||||
const themeMode = useTheme();
|
||||
const bg =
|
||||
themeMode?.theme === 'dark'
|
||||
? theme.colors['def-100']
|
||||
: theme.colors['def-300'];
|
||||
return (
|
||||
<rect
|
||||
{...{ x, y, width, height, top, left, right, bottom }}
|
||||
@@ -33,7 +36,7 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
|
||||
const { editMode, previous, interval } = useChartContext();
|
||||
const { editMode, previous, interval, aspectRatio } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
@@ -41,10 +44,15 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(editMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<div className={cn('w-full', editMode && 'card p-4')}>
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
{({ width, height }) => (
|
||||
<BarChart width={width} height={height} data={rechartData}>
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={rechartData}
|
||||
barCategoryGap={10}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
@@ -70,22 +78,45 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<React.Fragment key={serie.id}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="colorGradient"
|
||||
x1="0"
|
||||
y1="1"
|
||||
x2="0"
|
||||
y2="0"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={getChartColor(serie.index)}
|
||||
stopOpacity={0.7}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={getChartColor(serie.index)}
|
||||
stopOpacity={1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{previous && (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.2}
|
||||
fillOpacity={0.1}
|
||||
radius={3}
|
||||
barSize={20} // Adjust the bar width here
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fill="url(#colorGradient)"
|
||||
radius={3}
|
||||
fillOpacity={1}
|
||||
barSize={20} // Adjust the bar width here
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,11 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
aspectRatio,
|
||||
hideXAxis,
|
||||
hideYAxis,
|
||||
} = useChartContext();
|
||||
const dataLength = data.series[0]?.data?.length || 0;
|
||||
const references = api.reference.getChartReferences.useQuery(
|
||||
{
|
||||
projectId,
|
||||
@@ -120,11 +124,10 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
}, [series]);
|
||||
|
||||
const isAreaStyle = series.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(editMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<div className={cn('w-full', editMode && 'card p-4')}>
|
||||
<ResponsiveContainer aspectRatio={aspectRatio}>
|
||||
{({ width, height }) => (
|
||||
<ComposedChart width={width} height={height} data={rechartData}>
|
||||
<CartesianGrid
|
||||
@@ -149,7 +152,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
/>
|
||||
))}
|
||||
<YAxis
|
||||
width={getYAxisWidth(data.metrics.max)}
|
||||
width={hideYAxis ? 0 : getYAxisWidth(data.metrics.max)}
|
||||
fontSize={12}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
@@ -164,6 +167,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
)}
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<XAxis
|
||||
height={hideXAxis ? 0 : undefined}
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="timestamp"
|
||||
@@ -212,7 +216,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
|
||||
)}
|
||||
</defs>
|
||||
<Line
|
||||
dot={isAreaStyle}
|
||||
dot={isAreaStyle && dataLength <= 8}
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
|
||||
|
||||
interface ResponsiveContainerProps {
|
||||
children: (props: { width: number; height: number }) => React.ReactNode;
|
||||
aspectRatio?: number;
|
||||
children:
|
||||
| ((props: { width: number; height: number }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
}
|
||||
|
||||
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
|
||||
export function ResponsiveContainer({
|
||||
children,
|
||||
aspectRatio = 0.5625,
|
||||
}: ResponsiveContainerProps) {
|
||||
const maxHeight = 300;
|
||||
const minHeight = 200;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
aspectRatio: 1 / (aspectRatio || DEFAULT_ASPECT_RATIO),
|
||||
maxHeight,
|
||||
minHeight,
|
||||
}}
|
||||
className={'aspect-video w-full max-sm:-mx-3'}
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) =>
|
||||
children({
|
||||
width,
|
||||
height: Math.min(maxHeight, width * 0.5625),
|
||||
})
|
||||
}
|
||||
</AutoSizer>
|
||||
{typeof children === 'function' ? (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) =>
|
||||
children({
|
||||
width,
|
||||
height: Math.min(
|
||||
maxHeight,
|
||||
width * aspectRatio || DEFAULT_ASPECT_RATIO
|
||||
),
|
||||
})
|
||||
}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
@@ -24,14 +25,18 @@ export function ChartRoot(props: IChartContextType) {
|
||||
return props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
props.chartType === 'metric' ? <MetricCardLoading /> : <ChartLoading />
|
||||
props.chartType === 'metric' ? (
|
||||
<MetricCardLoading />
|
||||
) : (
|
||||
<ChartLoading aspectRatio={props.aspectRatio} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChartProvider {...props}>
|
||||
@@ -49,9 +54,13 @@ interface ChartRootShortcutProps {
|
||||
interval?: IChartProps['interval'];
|
||||
events: IChartProps['events'];
|
||||
breakdowns?: IChartProps['breakdowns'];
|
||||
lineType?: IChartProps['lineType'];
|
||||
hideXAxis?: boolean;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export const ChartRootShortcut = ({
|
||||
hideXAxis,
|
||||
projectId,
|
||||
range = '7d',
|
||||
previous = false,
|
||||
@@ -59,19 +68,25 @@ export const ChartRootShortcut = ({
|
||||
interval = 'day',
|
||||
events,
|
||||
breakdowns,
|
||||
aspectRatio,
|
||||
lineType = 'monotone',
|
||||
}: ChartRootShortcutProps) => {
|
||||
return (
|
||||
<ChartRoot
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
breakdowns={breakdowns ?? []}
|
||||
previous={previous}
|
||||
chartType={chartType}
|
||||
interval={interval}
|
||||
name="Random"
|
||||
lineType="bump"
|
||||
metric="sum"
|
||||
events={events}
|
||||
/>
|
||||
<Portal.Root>
|
||||
<ChartRoot
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
breakdowns={breakdowns ?? []}
|
||||
previous={previous}
|
||||
chartType={chartType}
|
||||
interval={interval}
|
||||
name="Random"
|
||||
lineType={lineType}
|
||||
metric="sum"
|
||||
events={events}
|
||||
aspectRatio={aspectRatio}
|
||||
hideXAxis={hideXAxis}
|
||||
/>
|
||||
</Portal.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -110,6 +110,39 @@ const TableCaption = React.forwardRef<
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export function TableSkeleton({
|
||||
rows = 10,
|
||||
cols = 2,
|
||||
}: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<TableHead key={j}>
|
||||
<div className="h-4 w-1/4 animate-pulse rounded-full bg-def-300" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<div className="h-4 w-1/4 min-w-20 animate-pulse rounded-full bg-def-300" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
|
||||
@@ -37,7 +37,7 @@ export function WidgetTitle({
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="bg-def-200 absolute left-0 rounded-lg p-2">
|
||||
<div className="absolute left-0 rounded-lg bg-def-200 p-2">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isSameDay, isSameMonth } from 'date-fns';
|
||||
|
||||
export const DEFAULT_ASPECT_RATIO = 0.5625;
|
||||
export const NOT_SET_VALUE = '(not set)';
|
||||
|
||||
export const timeWindows = {
|
||||
|
||||
@@ -28,6 +28,15 @@ export type IImportedEvent = Omit<
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type IServicePage = {
|
||||
path: string;
|
||||
count: number;
|
||||
project_id: string;
|
||||
first_seen: string;
|
||||
title: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
export interface IClickhouseEvent {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -493,3 +502,30 @@ export async function getLastScreenViewFromProfileId({
|
||||
|
||||
return eventInDb || null;
|
||||
}
|
||||
|
||||
export async function getTopPages({
|
||||
projectId,
|
||||
cursor,
|
||||
take,
|
||||
search,
|
||||
}: {
|
||||
projectId: string;
|
||||
cursor?: number;
|
||||
take: number;
|
||||
search?: string;
|
||||
}) {
|
||||
const res = await chQuery<IServicePage>(`
|
||||
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, max(properties['__title']) as title, origin
|
||||
FROM events_v2
|
||||
WHERE name = 'screen_view'
|
||||
AND project_id = ${escape(projectId)}
|
||||
AND created_at > now() - INTERVAL 30 DAY
|
||||
${search ? `AND path LIKE '%${search}%'` : ''}
|
||||
GROUP BY path, project_id, origin
|
||||
ORDER BY count desc
|
||||
LIMIT ${take}
|
||||
OFFSET ${Math.max(0, (cursor ?? 0) * take)}
|
||||
`);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
db,
|
||||
getEventList,
|
||||
getEvents,
|
||||
getTopPages,
|
||||
TABLE_NAMES,
|
||||
} from '@openpanel/db';
|
||||
import { zChartEventFilter } from '@openpanel/validation';
|
||||
@@ -69,7 +70,6 @@ export const eventRouter = createTRPCRouter({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().default(8),
|
||||
profileId: z.string().optional(),
|
||||
take: z.number().default(50),
|
||||
events: z.array(z.string()).optional(),
|
||||
@@ -165,4 +165,17 @@ export const eventRouter = createTRPCRouter({
|
||||
count: counts[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
pages: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
cursor: z.number().optional(),
|
||||
take: z.number().default(20),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return getTopPages(input);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod';
|
||||
import { db } from '@openpanel/db';
|
||||
import { zInviteUser } from '@openpanel/validation';
|
||||
|
||||
import { getOrganizationAccess, getOrganizationAccessCached } from '../access';
|
||||
import { getOrganizationAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
|
||||
|
||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@@ -233,6 +233,9 @@ importers:
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-portal':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -5574,6 +5577,19 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.56)(react@18.2.0):
|
||||
resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.56
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
||||
peerDependencies:
|
||||
@@ -5982,6 +5998,27 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-portal@1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.56)(react@18.2.0)
|
||||
'@types/react': 18.2.56
|
||||
'@types/react-dom': 18.2.19
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
|
||||
peerDependencies:
|
||||
@@ -6050,6 +6087,26 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.56)(react@18.2.0)
|
||||
'@types/react': 18.2.56
|
||||
'@types/react-dom': 18.2.19
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==}
|
||||
peerDependencies:
|
||||
@@ -6185,6 +6242,20 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.1.0(@types/react@18.2.56)(react@18.2.0):
|
||||
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.56)(react@18.2.0)
|
||||
'@types/react': 18.2.56
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
|
||||
peerDependencies:
|
||||
@@ -6450,6 +6521,19 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.2.56)(react@18.2.0):
|
||||
resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.56
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-previous@1.0.1(@types/react@18.2.56)(react@18.2.0):
|
||||
resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
|
||||
peerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user