Feature/move list to client (#50)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-09-01 15:02:12 +02:00
committed by GitHub
parent c2abdaadf2
commit 668434d246
181 changed files with 2922 additions and 1959 deletions

View File

@@ -9,6 +9,7 @@ import {
getEvents,
getLiveVisitors,
getProfileById,
getProfileByIdCached,
TABLE_NAMES,
transformMinimalEvent,
} from '@openpanel/db';
@@ -144,7 +145,10 @@ export async function wsProjectEvents(
const message = async (channel: string, message: string) => {
const event = getSuperJson<IServiceEvent>(message);
if (event?.projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId);
const profile = await getProfileByIdCached(
event.profileId,
event.projectId
);
connection.socket.send(
superjson.stringify(
access

View File

@@ -64,6 +64,7 @@
"embla-carousel-react": "8.0.0-rc22",
"flag-icons": "^7.1.0",
"framer-motion": "^11.0.28",
"geist": "^1.3.1",
"hamburger-react": "^2.5.0",
"input-otp": "^1.2.4",
"javascript-time-ago": "^2.5.9",

View File

@@ -31,15 +31,16 @@ import {
getDefaultIntervalByRange,
timeWindows,
} from '@openpanel/constants';
import type { getReportsByDashboardId } from '@openpanel/db';
import type { getReportsByDashboardId, IServiceDashboard } from '@openpanel/db';
import { OverviewReportRange } from '../../overview-sticky-header';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
dashboard: IServiceDashboard;
}
export function ListReports({ reports }: ListReportsProps) {
export function ListReports({ reports, dashboard }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate } = useOverviewOptions();
@@ -52,25 +53,28 @@ export function ListReports({ reports }: ListReportsProps) {
});
return (
<>
<StickyBelowHeader className="flex items-center justify-between p-4">
<OverviewReportRange />
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationSlug}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</StickyBelowHeader>
<div className="mx-auto flex max-w-3xl flex-col gap-8 p-4 md:p-8">
<div className="row mb-4 items-center justify-between">
<h1 className="text-3xl font-semibold">{dashboard.name}</h1>
<div className="flex items-center justify-end gap-2">
<OverviewReportRange />
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationSlug}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</div>
</div>
<div className="flex max-w-6xl flex-col gap-8">
{reports.map((report) => {
const chartRange = report.range;
return (
@@ -83,7 +87,7 @@ export function ListReports({ reports }: ListReportsProps) {
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 text-sm">
<div className="mt-2 flex gap-2 ">
<span
className={
range !== null || (startDate && endDate)

View File

@@ -1,4 +1,4 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { Padding } from '@/components/ui/padding';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
@@ -14,7 +14,7 @@ interface PageProps {
}
export default async function Page({
params: { organizationSlug, projectId, dashboardId },
params: { projectId, dashboardId },
}: PageProps) {
const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId),
@@ -26,9 +26,8 @@ export default async function Page({
}
return (
<>
<PageLayout title={dashboard.name} organizationSlug={organizationSlug} />
<ListReports reports={reports} />
</>
<Padding>
<ListReports reports={reports} dashboard={dashboard} />
</Padding>
);
}

View File

@@ -4,23 +4,19 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
export function HeaderDashboards() {
return (
<StickyBelowHeader>
<div className="flex items-center justify-between p-4">
<div />
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
</StickyBelowHeader>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-3xl font-semibold">Dashboards</h1>
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard');
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
);
}

View File

@@ -1,8 +1,6 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Padding } from '@/components/ui/padding';
import withSuspense from '@/hocs/with-suspense';
import type { LucideIcon } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import { getDashboardsByProjectId } from '@openpanel/db';
@@ -16,7 +14,12 @@ interface Props {
const ListDashboardsServer = async ({ projectId }: Props) => {
const dashboards = await getDashboardsByProjectId(projectId);
return <ListDashboards dashboards={dashboards} />;
return (
<Padding>
<HeaderDashboards />
<ListDashboards dashboards={dashboards} />;
</Padding>
);
};
export default withSuspense(ListDashboardsServer, FullPageLoadingState);

View File

@@ -75,7 +75,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
return (
<>
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{dashboards.map((item) => {
const visibleReports = item.reports.slice(
0,
@@ -88,16 +88,16 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
href={`/${organizationSlug}/${projectId}/dashboards/${item.id}`}
className="flex flex-col p-4 @container"
>
<div>
<div className="col gap-2">
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-sm text-muted-foreground">
{format(item.updatedAt, 'HH:mm · MMM d')}
</div>
</div>
<div
className={cn(
'mt-4 grid gap-4',
'grid-cols-2 @xs:grid-cols-3 @lg:grid-cols-4'
'mt-4 grid gap-2',
'grid-cols-1 @sm:grid-cols-2'
)}
>
{visibleReports.map((report) => {
@@ -114,26 +114,26 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
return (
<div
className="bg-def-200 flex flex-col rounded-xl p-4"
className="row items-center gap-2 rounded-md bg-def-200 p-4 py-2"
key={report.id}
>
<Icon size={24} className="text-highlight" />
<div className="mt-2 w-full overflow-hidden text-ellipsis whitespace-nowrap text-xs">
<Icon size={24} />
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{report.name}
</div>
</div>
);
})}
{item.reports.length > 6 && (
<div className="bg-def-200 flex flex-col rounded-xl p-4">
<PlusIcon size={24} className="text-highlight" />
<div className="mt-2 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs">
<div className="row items-center gap-2 rounded-md bg-def-100 p-4 py-2">
<PlusIcon size={24} />
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{item.reports.length - 5} more
</div>
</div>
)}
</div>
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
<span className="mr-2 font-medium">
{item.reports.length} reports
</span>

View File

@@ -1,23 +1,11 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import ListDashboardsServer from './list-dashboards';
import { HeaderDashboards } from './list-dashboards/header';
interface PageProps {
params: {
projectId: string;
organizationSlug: string;
};
}
export default function Page({
params: { projectId, organizationSlug },
}: PageProps) {
return (
<>
<PageLayout title="Dashboards" organizationSlug={organizationSlug} />
<HeaderDashboards />
<ListDashboardsServer projectId={projectId} />
</>
);
export default function Page({ params: { projectId } }: PageProps) {
return <ListDashboardsServer projectId={projectId} />;
}

View File

@@ -0,0 +1,181 @@
'use client';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ChartRootShortcut } from '@/components/report/chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import type { IChartEvent } from '@openpanel/validation';
interface Props {
projectId: string;
}
function Charts({ projectId }: Props) {
const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter();
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<div>
<div className="mb-2 flex items-center gap-2">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<Widget className="w-full">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="pie"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="bar"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
<Widget className="w-full">
<WidgetHead>
<span className="title">Event distribution</span>
</WidgetHead>
<WidgetBody>
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="linear"
breakdowns={[
{
id: 'A',
name: 'name',
},
]}
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
]
}
/>
</WidgetBody>
</Widget>
</div>
</div>
);
}
export default Charts;

View File

@@ -1,48 +0,0 @@
import { ChartRootShortcut } from '@/components/report/chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IChartEvent } from '@openpanel/validation';
interface Props {
projectId: string;
events?: string[];
filters?: any[];
}
export function EventsPerDayChart({ projectId, filters, events }: Props) {
const fallback: IChartEvent[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
},
];
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="histogram"
events={
events && events.length > 0
? events.map((name) => ({
id: name,
name,
displayName: name,
segment: 'event',
filters: filters ?? [],
}))
: fallback
}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import { EventsTable } from '@/components/events/table';
import { api } from '@/trpc/client';
type Props = {
projectId: string;
profileId?: string;
};
const Conversions = ({ projectId }: Props) => {
const query = api.event.conversions.useQuery(
{
projectId,
},
{
keepPreviousData: true,
}
);
return <EventsTable query={query} />;
};
export default Conversions;

View File

@@ -1,43 +0,0 @@
'use client';
import { Fragment } from 'react';
import { Widget, WidgetHead } from '@/components/widget';
import { isSameDay } from 'date-fns';
import type { IServiceEvent } from '@openpanel/db';
import { EventListItem } from '../event-list/event-list-item';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
return !isSameDay(a, b);
}
interface EventListProps {
data: IServiceEvent[];
}
export function EventConversionsList({ data }: EventListProps) {
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Conversions</div>
</WidgetHead>
<div className="flex max-h-80 flex-col gap-2 overflow-y-auto p-4">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
<div className="flex gap-2">
<div className="flex h-8 items-center gap-2 rounded border border-def-200 bg-def-200 px-3 text-sm font-medium leading-none">
{item.createdAt.toLocaleDateString()}
</div>
</div>
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
</Widget>
);
}

View File

@@ -1,35 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { escape } from 'sqlstring';
import { db, getEvents, TABLE_NAMES } from '@openpanel/db';
import { EventConversionsList } from './event-conversions-list';
interface Props {
projectId: string;
}
async function EventConversionsListServer({ projectId }: Props) {
const conversions = await db.eventMeta.findMany({
where: {
projectId,
conversion: true,
},
});
if (conversions.length === 0) {
return null;
}
const events = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND name IN (${conversions.map((c) => escape(c.name)).join(', ')}) ORDER BY created_at DESC LIMIT 20;`,
{
profile: true,
meta: true,
}
);
return <EventConversionsList data={events} />;
}
export default withLoadingWidget(EventConversionsListServer);

View File

@@ -1,232 +0,0 @@
import { useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { ChartRootShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { KeyValue } from '@/components/ui/key-value';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { round } from 'mathjs';
import type { IServiceEvent } from '@openpanel/db';
import { EventEdit } from './event-edit';
interface Props {
event: IServiceEvent;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}
export function EventDetails({ event, open, setOpen }: Props) {
const { name } = event;
const [isEditOpen, setIsEditOpen] = useState(false);
const [, setFilter] = useEventQueryFilters({ shallow: false });
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const common = [
{
name: 'Origin',
value: event.origin,
},
{
name: 'Duration',
value: event.duration ? round(event.duration / 1000, 1) : undefined,
},
{
name: 'Referrer',
value: event.referrer,
onClick() {
setFilter('referrer', event.referrer ?? '');
},
},
{
name: 'Referrer name',
value: event.referrerName,
onClick() {
setFilter('referrer_name', event.referrerName ?? '');
},
},
{
name: 'Referrer type',
value: event.referrerType,
onClick() {
setFilter('referrer_type', event.referrerType ?? '');
},
},
{
name: 'Brand',
value: event.brand,
onClick() {
setFilter('brand', event.brand ?? '');
},
},
{
name: 'Model',
value: event.model,
onClick() {
setFilter('model', event.model ?? '');
},
},
{
name: 'Browser',
value: event.browser,
onClick() {
setFilter('browser', event.browser ?? '');
},
},
{
name: 'Browser version',
value: event.browserVersion,
onClick() {
setFilter('browser_version', event.browserVersion ?? '');
},
},
{
name: 'OS',
value: event.os,
onClick() {
setFilter('os', event.os ?? '');
},
},
{
name: 'OS version',
value: event.osVersion,
onClick() {
setFilter('os_version', event.osVersion ?? '');
},
},
{
name: 'City',
value: event.city,
onClick() {
setFilter('city', event.city ?? '');
},
},
{
name: 'Region',
value: event.region,
onClick() {
setFilter('region', event.region ?? '');
},
},
{
name: 'Country',
value: event.country,
onClick() {
setFilter('country', event.country ?? '');
},
},
{
name: 'Device',
value: event.device,
onClick() {
setFilter('device', event.device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
const properties = Object.entries(event.properties)
.map(([name, value]) => ({
name,
value: value as string | number | undefined,
}))
.filter((item) => typeof item.value === 'string' && item.value);
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<div>
<div className="flex flex-col gap-8">
<SheetHeader>
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
</SheetHeader>
{properties.length > 0 && (
<div>
<div className="mb-2 text-sm font-medium">Params</div>
<div className="flex flex-wrap gap-2">
{properties.map((item) => (
<KeyValue
key={item.name}
name={item.name.replace(/^__/, '')}
value={item.value}
onClick={() => {
setFilter(
`properties.${item.name}`,
item.value ? String(item.value) : '',
'is'
);
}}
/>
))}
</div>
</div>
)}
<div>
<div className="mb-2 text-sm font-medium">Common</div>
<div className="flex flex-wrap gap-2">
{common.map((item) => (
<KeyValue
key={item.name}
name={item.name}
value={item.value}
onClick={() => item.onClick?.()}
/>
))}
</div>
</div>
<div>
<div className="mb-2 flex justify-between text-sm font-medium">
<div>Similar events</div>
<button
className="text-muted-foreground hover:underline"
onClick={() => {
setEvents([event.name]);
setOpen(false);
}}
>
Show all
</button>
</div>
<ChartRootShortcut
projectId={event.projectId}
chartType="histogram"
events={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
},
]}
/>
</div>
</div>
</div>
<SheetFooter>
<Button
variant={'secondary'}
className="w-full"
onClick={() => setIsEditOpen(true)}
>
Customize &quot;{name}&quot;
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<EventEdit event={event} open={isEditOpen} setOpen={setIsEditOpen} />
</>
);
}

View File

@@ -21,7 +21,6 @@ const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
projectId: string;
className?: string;
};

View File

@@ -1,17 +1,16 @@
'use client';
import { useState } from 'react';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { EventDetails } from './event-details';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
@@ -20,7 +19,6 @@ export function EventListItem(props: EventListItemProps) {
const { organizationSlug, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null;
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const number = useNumber();
@@ -52,17 +50,12 @@ export function EventListItem(props: EventListItemProps) {
return (
<>
{!isMinimal && (
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
)}
<button
onClick={() => {
if (!isMinimal) {
setIsDetailsOpen(true);
pushModal('EventDetails', {
id: props.id,
});
}
}}
className={cn(
@@ -72,13 +65,8 @@ export function EventListItem(props: EventListItemProps) {
)}
>
<div>
<div className="flex items-center gap-4 text-left text-sm">
<EventIcon
size="sm"
name={name}
meta={meta}
projectId={projectId}
/>
<div className="flex items-center gap-4 text-left ">
<EventIcon size="sm" name={name} meta={meta} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
@@ -100,14 +88,14 @@ export function EventListItem(props: EventListItemProps) {
e.stopPropagation();
}}
href={`/${organizationSlug}/${projectId}/profiles/${profile?.id}`}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground hover:underline"
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
>
{getProfileName(profile)}
</Link>
</Tooltiper>
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className="text-sm text-muted-foreground">
<div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>

View File

@@ -1,101 +0,0 @@
'use client';
import { Fragment } from 'react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { isSameDay } from 'date-fns';
import { GanttChartIcon } from 'lucide-react';
import type { IServiceEvent } from '@openpanel/db';
import { EventListItem } from './event-list-item';
import EventListener from './event-listener';
function showDateHeader(a: Date, b?: Date) {
if (!b) return true;
return !isSameDay(a, b);
}
interface EventListProps {
data: IServiceEvent[];
count: number;
}
function EventList({ data, count }: EventListProps) {
const { cursor, setCursor, loading } = useCursor();
const [filters] = useEventQueryFilters();
return (
<>
{data.length === 0 ? (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
>
Go back
</Button>
</>
) : (
<>
{filters.length ? (
<p>Could not find any events with your filter</p>
) : (
<p>We have not received any events yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-2">
{data.map((item, index, list) => (
<Fragment key={item.id}>
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
<div className="flex flex-row justify-between gap-2 [&:not(:first-child)]:mt-12">
{index === 0 ? <EventListener /> : <div />}
<div className="flex gap-2">
<div className="flex h-8 items-center gap-2 rounded border border-def-200 bg-def-200 px-3 text-sm font-medium leading-none">
{item.createdAt.toLocaleDateString()}
</div>
{index === 0 && (
<Pagination
size="sm"
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
loading={loading}
/>
)}
</div>
</div>
)}
<EventListItem {...item} />
</Fragment>
))}
</div>
<Pagination
size="sm"
className="mt-2"
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
loading={loading}
/>
</>
)}
</>
);
}
export default EventList;

View File

@@ -6,11 +6,10 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceVal } from '@/hooks/useDebounceVal';
import { useDebounceState } from '@/hooks/useDebounceState';
import useWS from '@/hooks/useWS';
import { cn } from '@/utils/cn';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import type { IServiceEventMinimal } from '@openpanel/db';
@@ -19,10 +18,13 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
loading: () => <div>0</div>,
});
export default function EventListener() {
const router = useRouter();
export default function EventListener({
onRefresh,
}: {
onRefresh: () => void;
}) {
const { projectId } = useAppParams();
const counter = useDebounceVal(0, 1000, {
const counter = useDebounceState(0, 1000, {
maxWait: 5000,
});
@@ -38,9 +40,9 @@ export default function EventListener() {
<button
onClick={() => {
counter.set(0);
router.refresh();
onRefresh();
}}
className="flex h-8 items-center gap-2 rounded border border-border bg-card px-3 text-sm font-medium leading-none"
className="flex h-8 items-center gap-2 rounded border border-border bg-card px-3 font-medium leading-none"
>
<div className="relative">
<div

View File

@@ -1,46 +0,0 @@
import withSuspense from '@/hocs/with-suspense';
import { getEventList, getEventsCount } from '@openpanel/db';
import type { IChartEventFilter } from '@openpanel/validation';
import EventList from './event-list';
type Props = {
cursor?: number;
projectId: string;
filters?: IChartEventFilter[];
eventNames?: string[];
profileId?: string;
};
const EventListServer = async ({
cursor,
projectId,
eventNames,
filters,
profileId,
}: Props) => {
const count = Infinity;
const [events] = await Promise.all([
getEventList({
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
}),
]);
return <EventList data={events} count={count} />;
};
export default withSuspense(EventListServer, () => (
<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>
));

View File

@@ -0,0 +1,67 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import EventListener from './event-list/event-listener';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0)
);
const query = api.event.events.useQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
},
{
keepPreviousData: true,
}
);
return (
<div>
<TableButtons>
<EventListener onRefresh={() => query.refetch()} />
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,77 +1,50 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { parseAsInteger } from 'nuqs';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { EventsPerDayChart } from './charts/events-per-day-chart';
import EventConversionsListServer from './event-conversions-list';
import EventListServer from './event-list';
import Charts from './charts';
import Conversions from './conversions';
import Events from './events';
interface PageProps {
params: {
projectId: string;
organizationSlug: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
};
searchParams: Record<string, string>;
}
const nuqsOptions = {
shallow: false,
};
export default function Page({
params: { projectId, organizationSlug },
params: { projectId },
searchParams,
}: PageProps) {
const cursor =
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined;
const filters =
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
const eventNames =
eventQueryNamesFilter.parseServerSide(searchParams.events) ?? undefined;
const tab = parseAsStringEnum(['events', 'conversions', 'charts'])
.withDefault('events')
.parseServerSide(searchParams.tab);
return (
<>
<PageLayout title="Events" organizationSlug={organizationSlug} />
<StickyBelowHeader className="flex justify-between p-4">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter
/>
<OverviewFiltersButtons
className="justify-end p-0"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<div className="grid gap-4 p-4 md:grid-cols-2">
<div>
<EventListServer
projectId={projectId}
cursor={cursor}
filters={filters}
eventNames={eventNames}
/>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={`?tab=events`} isActive={tab === 'events'}>
Events
</PageTabsLink>
<PageTabsLink
href={`?tab=conversions`}
isActive={tab === 'conversions'}
>
Conversions
</PageTabsLink>
<PageTabsLink href={`?tab=charts`} isActive={tab === 'charts'}>
Charts
</PageTabsLink>
</PageTabs>
</div>
<div className="flex flex-col gap-4">
<EventsPerDayChart
projectId={projectId}
events={eventNames}
filters={filters}
/>
<EventConversionsListServer projectId={projectId} />
</div>
</div>
{tab === 'events' && <Events projectId={projectId} />}
{tab === 'conversions' && <Conversions projectId={projectId} />}
{tab === 'charts' && <Charts projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useSelectedLayoutSegments } from 'next/navigation';
const NOT_MIGRATED_PAGES = ['reports'];
export default function LayoutContent({
children,
}: {
children: React.ReactNode;
}) {
const segments = useSelectedLayoutSegments();
if (segments[0] && NOT_MIGRATED_PAGES.includes(segments[0])) {
return <div className="transition-all lg:pl-72">{children}</div>;
}
return <div className="transition-all max-lg:mt-12 lg:pl-72">{children}</div>;
}

View File

@@ -1,22 +1,19 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import {
BookmarkIcon,
BuildingIcon,
CogIcon,
GanttChartIcon,
Globe2Icon,
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UserSearchIcon,
PlusIcon,
ScanEyeIcon,
UsersIcon,
WallpaperIcon,
WarehouseIcon,
} from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import Link from 'next/link';
@@ -42,7 +39,7 @@ function LinkWithIcon({
return (
<Link
className={cn(
'text-text hover:bg-def-200 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-all transition-colors',
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
active && 'bg-def-200',
className
)}
@@ -60,7 +57,6 @@ interface LayoutMenuProps {
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
const { user } = useUser();
const pathname = usePathname();
const params = useAppParams();
const hasProjectId =
params.projectId &&
@@ -103,60 +99,40 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
label="Events"
href={`/${params.organizationSlug}/${projectId}/events`}
/>
<LinkWithIcon
icon={UserSearchIcon}
label="Retention"
href={`/${params.organizationSlug}/${projectId}/retention`}
/>
<LinkWithIcon
icon={UsersIcon}
label="Profiles"
href={`/${params.organizationSlug}/${projectId}/profiles`}
/>
<LinkWithIcon
icon={CogIcon}
label="Settings"
href={`/${params.organizationSlug}/${projectId}/settings/organization`}
icon={ScanEyeIcon}
label="Retention"
href={`/${params.organizationSlug}/${projectId}/retention`}
/>
{pathname?.includes('/settings/') && (
<div className="flex flex-col gap-1 pl-7">
<LinkWithIcon
icon={BuildingIcon}
label="Organization"
href={`/${params.organizationSlug}/${projectId}/settings/organization`}
/>
<LinkWithIcon
icon={WarehouseIcon}
label="Projects"
href={`/${params.organizationSlug}/${projectId}/settings/projects`}
/>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationSlug}/${projectId}/settings/profile`}
/>
<LinkWithIcon
icon={BookmarkIcon}
label="References"
href={`/${params.organizationSlug}/${projectId}/settings/references`}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-muted-foreground">Your dashboards</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground"
onClick={() => pushModal('AddDashboard')}
>
<PlusIcon size={16} />
</Button>
</div>
)}
{dashboards.length > 0 && (
<div className="mt-8">
<div className="mb-2 text-sm font-medium">Your dashboards</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={item.name}
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
/>
))}
</div>
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={item.name}
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
/>
))}
</div>
)}
</div>
</>
);
}

View File

@@ -1,45 +1,144 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import {
Building2Icon,
CheckIcon,
ChevronsUpDown,
ChevronsUpDownIcon,
PlusIcon,
} from 'lucide-react';
import { usePathname, useRouter } from 'next/navigation';
import type { getProjectsByOrganizationSlug } from '@openpanel/db';
import type {
getCurrentOrganizations,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
organizations?: Awaited<ReturnType<typeof getCurrentOrganizations>>;
align?: 'start' | 'end';
}
export default function LayoutProjectSelector({
projects,
organizations,
align = 'start',
}: LayoutProjectSelectorProps) {
const router = useRouter();
const { organizationSlug, projectId } = useAppParams();
const pathname = usePathname() || '';
const [open, setOpen] = useState(false);
const changeProject = (newProjectId: string) => {
if (organizationSlug && projectId) {
const split = pathname
.replace(
`/${organizationSlug}/${projectId}`,
`/${organizationSlug}/${newProjectId}`
)
.split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.push(`/${organizationSlug}/${newProjectId}`);
}
};
const changeOrganization = (newOrganizationId: string) => {
router.push(`/${newOrganizationId}`);
};
return (
<div>
<Combobox
portal
align="end"
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {
if (organizationSlug && projectId) {
const split = pathname.replace(projectId, value).split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.push(`/${organizationSlug}/${value}`);
}
}}
value={projectId}
items={
projects.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
/>
</div>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
size={'sm'}
variant="outline"
role="combobox"
aria-expanded={open}
className="flex min-w-0 flex-1 items-center justify-start"
>
<Building2Icon size={16} className="shrink-0" />
<span className="mx-2 truncate">
{projectId
? projects.find((p) => p.id === projectId)?.name
: 'Select project'}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-[200px]">
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuGroup>
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => changeProject(project.id)}
>
{project.name}
{project.id === projectId && (
<DropdownMenuShortcut>
<CheckIcon size={16} />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-emerald-600"
onClick={() => pushModal('AddProject')}
>
Create new project
<DropdownMenuShortcut>
<PlusIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
{!!organizations && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
<DropdownMenuGroup>
{organizations.map((organization) => (
<DropdownMenuItem
key={organization.id}
onClick={() => changeOrganization(organization.id)}
>
{organization.name}
{organization.id === organizationSlug && (
<DropdownMenuShortcut>
<CheckIcon size={16} />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
New organization
<DropdownMenuShortcut>
<PlusIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,30 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
import { Logo } from '@/components/logo';
import { buttonVariants } from '@/components/ui/button';
import { LogoSquare } from '@/components/logo';
import SettingsToggle from '@/components/settings-toggle';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import { PlusIcon } from 'lucide-react';
import Link from 'next/link';
import { MenuIcon, XIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import type {
getProjectsByOrganizationSlug,
IServiceDashboards,
IServiceOrganization,
} from '@openpanel/db';
import LayoutMenu from './layout-menu';
import LayoutOrganizationSelector from './layout-organization-selector';
import LayoutProjectSelector from './layout-project-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
organizationSlug: string;
projectId: string;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export function LayoutSidebar({
organizations,
dashboards,
organizationSlug,
projectId,
projects,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
@@ -52,30 +56,28 @@ export function LayoutSidebar({
)}
>
<div className="absolute -right-12 flex h-16 items-center lg:hidden">
<Hamburger toggled={active} onToggle={setActive} size={20} />
<Button
size="icon"
onClick={() => setActive((p) => !p)}
variant={'outline'}
>
{active ? <XIcon size={16} /> : <MenuIcon size={16} />}
</Button>
</div>
<div className="flex h-16 shrink-0 items-center border-b border-border px-4">
<Link href="/">
<Logo />
</Link>
<div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<LogoSquare className="max-h-8" />
<LayoutProjectSelector
align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div>
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="block h-32 shrink-0"></div>
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="h-8 w-full bg-gradient-to-t from-card to-card/0"></div>
<div className="flex flex-col gap-2 bg-card p-4 pt-0">
<Link
className={cn('flex gap-2', buttonVariants())}
href={`/${organizationSlug}/${projectId}/reports`}
>
<PlusIcon size={16} />
Create a report
</Link>
<LayoutOrganizationSelector organizations={organizations} />
</div>
</div>
</div>
</>

View File

@@ -12,7 +12,7 @@ export function StickyBelowHeader({
return (
<div
className={cn(
'top-0 z-20 border-b border-border bg-card md:sticky [[id=dashboard]_&]:top-16 [[id=dashboard]_&]:rounded-none',
'top-0 z-20 border-b border-border bg-card md:sticky',
className
)}
>

View File

@@ -6,6 +6,7 @@ import {
getDashboardsByProjectId,
} from '@openpanel/db';
import LayoutContent from './layout-content';
import { LayoutSidebar } from './layout-sidebar';
import SideEffects from './side-effects';
@@ -46,9 +47,15 @@ export default async function AppLayout({
return (
<div id="dashboard">
<LayoutSidebar
{...{ organizationSlug, projectId, organizations, dashboards }}
{...{
organizationSlug,
projectId,
organizations,
projects,
dashboards,
}}
/>
<div className="transition-all lg:pl-72">{children}</div>
<LayoutContent>{children}</LayoutContent>
<SideEffects />
</div>
);

View File

@@ -1,39 +1,15 @@
import DarkModeToggle from '@/components/dark-mode-toggle';
import withSuspense from '@/hocs/with-suspense';
import { getCurrentProjects } from '@openpanel/db';
import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
title: React.ReactNode;
organizationSlug: string;
}
async function PageLayout({ title, organizationSlug }: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
function PageLayout({ title }: PageLayoutProps) {
return (
<>
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-12 lg:pl-4">
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-14 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
<div className="flex gap-2">
<div>
<DarkModeToggle className="hidden sm:flex" />
</div>
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</div>
</div>
</>
);
}
const Loading = ({ title }: PageLayoutProps) => (
<>
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-card px-4 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
</div>
</>
);
export default withSuspense(PageLayout, Loading);
export default PageLayout;

View File

@@ -1,4 +1,3 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
@@ -10,7 +9,6 @@ import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import OverviewMetrics from '../../../../components/overview/overview-metrics';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { OverviewReportRange } from './overview-sticky-header';
interface PageProps {
@@ -20,14 +18,11 @@ interface PageProps {
};
}
export default function Page({
params: { organizationSlug, projectId },
}: PageProps) {
export default function Page({ params: { projectId } }: PageProps) {
return (
<>
<PageLayout title="Overview" organizationSlug={organizationSlug} />
<StickyBelowHeader>
<div className="flex justify-between gap-2 p-4">
<div className="col gap-2 p-4">
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
@@ -38,8 +33,8 @@ export default function Page({
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="grid grid-cols-6 gap-4 p-4">
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />

View File

@@ -18,12 +18,12 @@ const MostEvents = ({ data }: Props) => {
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="bg-def-200 absolute bottom-0 left-0 top-0 rounded"
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
></div>
<div className="relative flex justify-between text-sm">
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>

View File

@@ -1,25 +1,17 @@
import { start } from 'repl';
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import ClickToCopy from '@/components/click-to-copy';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { Padding } from '@/components/ui/padding';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger } from 'nuqs';
import type { GetEventListOptions } from '@openpanel/db';
import { getProfileById } from '@openpanel/db';
import { getProfileByIdCached } from '@openpanel/db';
import EventListServer from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import MostEventsServer from './most-events';
import PopularRoutesServer from './popular-routes';
import ProfileActivityServer from './profile-activity';
import ProfileCharts from './profile-charts';
import Events from './profile-events';
import ProfileMetrics from './profile-metrics';
interface PageProps {
@@ -38,66 +30,50 @@ interface PageProps {
}
export default async function Page({
params: { projectId, profileId, organizationSlug },
searchParams,
params: { projectId, profileId },
}: PageProps) {
const eventListOptions: GetEventListOptions = {
projectId,
profileId,
take: 50,
cursor:
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parseServerSide(searchParams.events ?? ''),
filters:
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ??
undefined,
};
const profile = await getProfileById(profileId, projectId);
const profile = await getProfileByIdCached(profileId, projectId);
if (!profile) {
return notFound();
}
return (
<>
<PageLayout organizationSlug={organizationSlug} title={<div />} />
<StickyBelowHeader className="!relative !top-auto !z-0 flex flex-col gap-8 p-4 md:flex-row md:items-center md:p-8">
<div className="flex flex-1 gap-4">
<ProfileAvatar {...profile} size={'lg'} />
<div className="min-w-0">
<ClickToCopy value={profile.id}>
<h1 className="max-w-full overflow-hidden text-ellipsis break-words text-lg font-semibold md:max-w-sm md:whitespace-nowrap md:text-2xl">
{getProfileName(profile)}
</h1>
</ClickToCopy>
<div className="mt-1 flex items-center gap-4">
<ListPropertiesIcon {...profile.properties} />
</div>
</div>
<Padding>
<div className="row mb-4 items-center gap-4">
<ProfileAvatar {...profile} />
<div className="min-w-0">
<ClickToCopy value={profile.id}>
<h1 className="max-w-full truncate text-3xl font-semibold">
{getProfileName(profile)}
</h1>
</ClickToCopy>
</div>
<ProfileMetrics profileId={profileId} projectId={projectId} />
</StickyBelowHeader>
<div className="p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
<div className="col-span-2">
</div>
<div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-6">
<ProfileMetrics projectId={projectId} profile={profile} />
</div>
<div className="col-span-6">
<ProfileActivityServer
profileId={profileId}
projectId={projectId}
/>
</div>
<div className="col-span-2">
<div className="col-span-6 md:col-span-3">
<MostEventsServer profileId={profileId} projectId={projectId} />
</div>
<div className="col-span-2">
<div className="col-span-6 md:col-span-3">
<PopularRoutesServer profileId={profileId} projectId={projectId} />
</div>
<ProfileCharts profileId={profileId} projectId={projectId} />
</div>
<div className="mt-8">
<EventListServer {...eventListOptions} />
<Events profileId={profileId} projectId={projectId} />
</div>
</div>
</>
</Padding>
);
}

View File

@@ -18,12 +18,12 @@ const PopularRoutes = ({ data }: Props) => {
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="bg-def-200 absolute bottom-0 left-0 top-0 rounded"
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
></div>
<div className="relative flex justify-between text-sm">
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
endOfMonth,
format,
formatISO,
isSameMonth,
startOfMonth,
subMonths,
} from 'date-fns';
@@ -43,19 +44,72 @@ const ProfileActivity = ({ data }: Props) => {
<Button
variant="outline"
size="icon"
disabled={isSameMonth(startDate, new Date())}
onClick={() => setStartDate(addMonths(startDate, 1))}
>
<ChevronRightIcon size={14} />
</Button>
</div>
</WidgetHead>
<WidgetBody className="p-0">
<div className="grid grid-cols-2">
<WidgetBody>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div>
<div className="p-1 text-xs">
<div className="mb-2 text-sm">
{format(subMonths(startDate, 3), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 3)),
end: endOfMonth(subMonths(startDate, 3)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' })
)
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200'
)}
></div>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 2), 'MMMM yyyy')}
</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 2)),
end: endOfMonth(subMonths(startDate, 2)),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(
formatISO(date, { representation: 'date' })
)
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200'
)}
></div>
);
})}
</div>
</div>
<div>
<div className="mb-2 text-sm">
{format(subMonths(startDate, 1), 'MMMM yyyy')}
</div>
<div className="grid grid-cols-7 gap-1 p-1">
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 1)),
end: endOfMonth(subMonths(startDate, 1)),
@@ -78,8 +132,8 @@ const ProfileActivity = ({ data }: Props) => {
</div>
</div>
<div>
<div className="p-1 text-xs">{format(startDate, 'MMMM yyyy')}</div>
<div className="grid grid-cols-7 gap-1 p-1">
<div className="mb-2 text-sm">{format(startDate, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startDate,
end: endDate,

View File

@@ -80,19 +80,19 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
return (
<>
<Widget className="col-span-3 w-full">
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Page views</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<WidgetBody>
<ChartRoot {...pageViewsChart} />
</WidgetBody>
</Widget>
<Widget className="col-span-3 w-full">
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<WidgetBody>
<ChartRoot {...eventsChart} />
</WidgetBody>
</Widget>

View File

@@ -0,0 +1,66 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { EventsTable } from '@/components/events/table';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { GetEventListOptions } from '@openpanel/db';
type Props = {
projectId: string;
profileId: string;
};
const Events = ({ projectId, profileId }: Props) => {
const [filters] = useEventQueryFilters();
const [eventNames] = useEventQueryNamesFilter();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0)
);
const query = api.event.events.useQuery(
{
cursor,
projectId,
take: 50,
events: eventNames,
filters,
profileId,
},
{
keepPreviousData: true,
}
);
return (
<div>
<TableButtons>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
{query.isRefetching && (
<div className="center-center size-8 rounded border bg-background">
<Loader2Icon
size={12}
className="size-4 shrink-0 animate-spin text-black text-highlight"
/>
</div>
)}
</TableButtons>
<EventsTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -1,17 +1,18 @@
import withSuspense from '@/hocs/with-suspense';
import type { IServiceProfile } from '@openpanel/db';
import { getProfileMetrics } from '@openpanel/db';
import ProfileMetrics from './profile-metrics';
type Props = {
projectId: string;
profileId: string;
profile: IServiceProfile;
};
const ProfileMetricsServer = async ({ projectId, profileId }: Props) => {
const data = await getProfileMetrics(profileId, projectId);
return <ProfileMetrics data={data} />;
const ProfileMetricsServer = async ({ projectId, profile }: Props) => {
const data = await getProfileMetrics(profile.id, projectId);
return <ProfileMetrics data={data} profile={profile} />;
};
export default withSuspense(ProfileMetricsServer, () => null);

View File

@@ -1,66 +1,115 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { useNumber } from '@/hooks/useNumerFormatter';
import { utc } from '@/utils/date';
import { cn } from '@/utils/cn';
import { formatDateTime, utc } from '@/utils/date';
import { formatDistanceToNow } from 'date-fns';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import type { IProfileMetrics } from '@openpanel/db';
import type { IProfileMetrics, IServiceProfile } from '@openpanel/db';
type Props = {
data: IProfileMetrics;
profile: IServiceProfile;
};
const ProfileMetrics = ({ data }: Props) => {
function Card({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
<div className="text-muted-foreground">{title}</div>
<div className="font-mono truncate text-2xl font-bold">{value}</div>
</div>
);
}
function Info({ title, value }: { title: string; value: string }) {
return (
<div className="col gap-2">
<div className="capitalize text-muted-foreground">{title}</div>
<div className="font-mono truncate">{value || '-'}</div>
</div>
);
}
const ProfileMetrics = ({ data, profile }: Props) => {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(['profile', 'properties']).withDefault('profile')
);
const number = useNumber();
return (
<div className="flex flex-wrap gap-6 whitespace-nowrap md:justify-end md:text-right">
<div>
<div className="text-xs font-medium text-muted-foreground">
First seen
</div>
<div className="text-lg font-semibold">
{formatDistanceToNow(utc(data.firstSeen))}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Last seen
</div>
<div className="text-lg font-semibold">
{formatDistanceToNow(utc(data.lastSeen))}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Sessions
</div>
<div className="text-lg font-semibold">
{number.format(data.sessions)}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Avg. Session
</div>
<div className="text-lg font-semibold">
{number.formatWithUnit(data.durationAvg / 1000, 'min')}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
P90. Session
</div>
<div className="text-lg font-semibold">
{number.formatWithUnit(data.durationP90 / 1000, 'min')}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Page views
</div>
<div className="text-lg font-semibold">
{number.format(data.screenViews)}
<div className="@container">
<div className="grid grid-cols-2 overflow-hidden whitespace-nowrap rounded-md border bg-background @xl:grid-cols-3 @4xl:grid-cols-6">
<div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
<div className="row border-b">
<button
onClick={() => setTab('profile')}
className={cn(
'p-4',
'opacity-50',
tab === 'profile' &&
'border-b border-foreground text-foreground opacity-100'
)}
>
Profile
</button>
<div className="h-full w-px bg-border" />
<button
onClick={() => setTab('properties')}
className={cn(
'p-4',
'opacity-50',
tab === 'properties' &&
'border-b border-foreground text-foreground opacity-100'
)}
>
Properties
</button>
</div>
<div className="grid grid-cols-2 gap-4 p-4">
{tab === 'profile' && (
<>
<Info title="ID" value={profile.id} />
<Info title="First name" value={profile.firstName} />
<Info title="Last name" value={profile.lastName} />
<Info title="Email" value={profile.email} />
<Info
title="Updated"
value={formatDateTime(new Date(profile.createdAt))}
/>
<ListPropertiesIcon {...profile.properties} />
</>
)}
{tab === 'properties' && (
<>
{Object.entries(profile.properties)
.filter(([key, value]) => value !== undefined)
.map(([key, value]) => (
<Info key={key} title={key} value={value as string} />
))}
</>
)}
</div>
</div>
<Card
title="First seen"
value={formatDistanceToNow(utc(data.firstSeen))}
/>
<Card
title="Last seen"
value={formatDistanceToNow(utc(data.lastSeen))}
/>
<Card title="Sessions" value={number.format(data.sessions)} />
<Card
title="Avg. Session"
value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
/>
<Card
title="P90. Session"
value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
/>
<Card title="Page views" value={number.format(data.screenViews)} />
</div>
</div>
);

View File

@@ -1,50 +1,45 @@
import { Suspense } from 'react';
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
import { parseAsInteger } from 'nuqs';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs';
import LastActiveUsersServer from '../retention/last-active-users';
import ProfileListServer from './profile-list';
import ProfileTopServer from './profile-top';
import PowerUsers from './power-users';
import Profiles from './profiles';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
organizationSlug: string;
};
searchParams: {
f?: string;
cursor?: string;
};
searchParams: Record<string, string>;
}
const nuqsOptions = {
shallow: false,
};
export default function Page({
params: { organizationSlug, projectId },
searchParams: { cursor, f },
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['profiles', 'power-users'])
.withDefault('profiles')
.parseServerSide(searchParams.tab);
return (
<>
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
<ProfileListServer
projectId={projectId}
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined}
filters={
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined
}
/>
<div className="flex flex-col gap-4">
<LastActiveUsersServer projectId={projectId} />
<ProfileTopServer
projectId={projectId}
organizationSlug={organizationSlug}
/>
<Padding>
<div className="mb-4">
<PageTabs>
<PageTabsLink href={`?tab=profiles`} isActive={tab === 'profiles'}>
Profiles
</PageTabsLink>
<PageTabsLink
href={`?tab=power-users`}
isActive={tab === 'power-users'}
>
Power users
</PageTabsLink>
</PageTabs>
</div>
</div>
{tab === 'profiles' && <Profiles projectId={projectId} />}
{tab === 'power-users' && <PowerUsers projectId={projectId} />}
</Padding>
</>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { ProfilesTable } from '@/components/profiles/table';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0)
);
const query = api.profile.powerUsers.useQuery(
{
cursor,
projectId,
take: 50,
// filters,
},
{
keepPreviousData: true,
}
);
return (
<div>
<ProfilesTable
query={query}
cursor={cursor}
setCursor={setCursor}
type="power-users"
/>
</div>
);
};
export default Events;

View File

@@ -61,7 +61,7 @@ export default async function ProfileLastSeenServer({ projectId }: Props) {
<div className="flex w-full flex-wrap items-start justify-start">
{res.map(renderItem)}
</div>
<div className="text-center text-xs text-muted-foreground">DAYS</div>
<div className="text-center text-sm text-muted-foreground">DAYS</div>
</WidgetBody>
</Widget>
);

View File

@@ -1,32 +0,0 @@
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getProfileList, getProfileListCount } from '@openpanel/db';
import type { IChartEventFilter } from '@openpanel/validation';
import { ProfileList } from './profile-list';
interface Props {
projectId: string;
cursor?: number;
filters?: IChartEventFilter[];
}
const limit = 40;
async function ProfileListServer({ projectId, cursor, filters }: Props) {
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: limit,
cursor,
filters,
}),
getProfileListCount({
projectId,
filters,
}),
]);
return <ProfileList data={profiles} count={count} limit={limit} />;
}
export default withLoadingWidget(ProfileListServer);

View File

@@ -1,114 +0,0 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { getProfileName } from '@/utils/getters';
import { UsersIcon } from 'lucide-react';
import Link from 'next/link';
import type { IServiceProfile } from '@openpanel/db';
interface ProfileListProps {
data: IServiceProfile[];
count: number;
limit?: number;
}
export function ProfileList({ data, count, limit = 50 }: ProfileListProps) {
const { organizationSlug, projectId } = useAppParams();
const { cursor, setCursor, loading } = useCursor();
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<div className="title">Profiles</div>
<Pagination
size="sm"
cursor={cursor}
setCursor={setCursor}
loading={loading}
count={count}
take={limit}
/>
</WidgetHead>
{data.length ? (
<>
<WidgetTable
data={data}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium"
title={getProfileName(profile, false)}
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Last seen',
render(profile) {
return (
<Tooltiper
asChild
content={profile.createdAt.toLocaleString()}
>
<div className="text-sm text-muted-foreground">
{profile.createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
);
},
},
]}
/>
<div className="border-t border-border p-4">
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={limit}
/>
</div>
</>
) : (
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor(Math.max(0, count / limit - 1))}
>
Go back
</Button>
</>
) : (
<p>Looks like there are no profiles here</p>
)}
</FullPageEmptyState>
)}
</Widget>
);
}

View File

@@ -1,72 +0,0 @@
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
import { escape } from 'sqlstring';
import { chQuery, getProfiles, TABLE_NAMES } from '@openpanel/db';
interface Props {
projectId: string;
organizationSlug: string;
}
async function ProfileTopServer({ organizationSlug, projectId }: Props) {
// Days since last event from users
// group by days
const res = await chQuery<{ profile_id: string; count: number }>(
`SELECT profile_id, count(*) as count from ${TABLE_NAMES.events} where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT 50`
);
const profiles = await getProfiles(res.map((r) => r.profile_id));
const list = res.map((item) => {
return {
count: item.count,
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
};
});
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Power users</div>
</WidgetHead>
<WidgetTable
data={list.filter((item) => !!item.id)}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Events',
render(profile) {
return profile.count;
},
},
]}
/>
</Widget>
);
}
export default withLoadingWidget(ProfileTopServer);

View File

@@ -0,0 +1,36 @@
'use client';
import { ProfilesTable } from '@/components/profiles/table';
import { api } from '@/trpc/client';
import { parseAsInteger, useQueryState } from 'nuqs';
type Props = {
projectId: string;
profileId?: string;
};
const Events = ({ projectId }: Props) => {
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0)
);
const query = api.profile.list.useQuery(
{
cursor,
projectId,
take: 50,
// filters,
},
{
keepPreviousData: true,
}
);
return (
<div>
<ProfilesTable query={query} cursor={cursor} setCursor={setCursor} />
</div>
);
};
export default Events;

View File

@@ -99,7 +99,7 @@ const Map = ({ markers }: Props) => {
<div
className={cn(
'fixed bottom-0 left-0 right-0 top-0',
!isFullscreen && 'top-16 lg:left-72'
!isFullscreen && 'lg:left-72'
)}
ref={ref}
>
@@ -123,8 +123,8 @@ const Map = ({ markers }: Props) => {
<Geography
key={geo.rsmKey}
geography={geo}
fill={theme.theme === 'dark' ? '#0f0f0f' : '#F0F4F9'}
stroke={theme.theme === 'dark' ? '#262626' : '#DDE3E9'}
fill={theme.theme === 'dark' ? '#000' : '#e5eef6'}
stroke={theme.theme === 'dark' ? '#333' : '#bcccda'}
pointerEvents={'none'}
/>
))

View File

@@ -18,23 +18,19 @@ type Props = {
projectId: string;
};
};
export default function Page({
params: { projectId, organizationSlug },
}: Props) {
export default function Page({ params: { projectId } }: Props) {
return (
<>
<PageLayout
title={<FullscreenOpen />}
{...{ projectId, organizationSlug }}
/>
<Fullscreen>
<FullscreenClose />
<RealtimeReloader projectId={projectId} />
<Suspense>
<RealtimeMap projectId={projectId} />
</Suspense>
<div className="relative z-10 grid min-h-[calc(100vh-theme(spacing.16))] items-start gap-4 overflow-hidden p-8 md:grid-cols-3">
<div className="card bg-card/80 p-4">
<div className="row relative z-10 min-h-screen items-start gap-4 overflow-hidden p-8">
<FullscreenOpen />
<div className="card min-w-52 bg-card/80 p-4 md:min-w-80">
<RealtimeLiveHistogram projectId={projectId} />
</div>
<div className="col-span-2">

View File

@@ -83,7 +83,7 @@ export function RealtimeLiveHistogram({
{staticArray.map((percent, i) => (
<div
key={i}
className="flex-1 animate-pulse rounded-md bg-def-200"
className="flex-1 animate-pulse rounded bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
@@ -103,7 +103,7 @@ export function RealtimeLiveHistogram({
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-md transition-all ease-in-out hover:scale-110',
'flex-1 rounded transition-all ease-in-out hover:scale-110',
minute.count === 0 ? 'bg-def-200' : 'bg-highlight'
)}
style={{
@@ -138,11 +138,11 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
function Wrapper({ children, count }: WrapperProps) {
return (
<div className="flex flex-col">
<div className="p-4">
<div className="col gap-2 p-4">
<div className="font-medium text-muted-foreground">
Unique vistors last 30 minutes
</div>
<div className="text-6xl font-bold">
<div className="font-mono text-6xl font-bold">
<AnimatedNumbers
includeComma
transitions={(index) => ({

View File

@@ -1,6 +1,5 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getReportById } from '@openpanel/db';
@@ -9,15 +8,12 @@ import ReportEditor from '../report-editor';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
reportId: string;
};
}
export default async function Page({
params: { reportId, organizationSlug },
}: PageProps) {
export default async function Page({ params: { reportId } }: PageProps) {
const report = await getReportById(reportId);
if (!report) {
@@ -26,10 +22,7 @@ export default async function Page({
return (
<>
<PageLayout
organizationSlug={organizationSlug}
title={<EditReportName name={report.name} />}
/>
<PageLayout title={<EditReportName name={report.name} />} />
<ReportEditor report={report} />
</>
);

View File

@@ -1,23 +1,12 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import EditReportName from '@/components/report/edit-report-name';
import { Pencil } from 'lucide-react';
import ReportEditor from './report-editor';
interface PageProps {
params: {
organizationSlug: string;
projectId: string;
};
}
export default function Page({ params: { organizationSlug } }: PageProps) {
export default function Page() {
return (
<>
<PageLayout
organizationSlug={organizationSlug}
title={<EditReportName name={undefined} />}
/>
<PageLayout title={<EditReportName name={undefined} />} />
<ReportEditor report={null} />
</>
);

View File

@@ -23,15 +23,15 @@ function Tooltip(props: any) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div>
<div className="text-xs text-muted-foreground">
<div className="text-sm text-muted-foreground">
Days since last seen
</div>
<div className="text-lg font-semibold">{payload.days}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Active users</div>
<div className="text-sm text-muted-foreground">Active users</div>
<div className="text-lg font-semibold">{payload.users}</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Padding } from '@/components/ui/padding';
import { AlertCircleIcon } from 'lucide-react';
import PageLayout from '../page-layout';
import LastActiveUsersServer from './last-active-users';
import RollingActiveUsers from './rolling-active-users';
import UsersRetentionSeries from './users-retention-series';
@@ -9,16 +9,15 @@ import WeeklyCohortsServer from './weekly-cohorts';
type Props = {
params: {
organizationSlug: string;
projectId: string;
};
};
const Retention = ({ params: { projectId, organizationSlug } }: Props) => {
const Retention = ({ params: { projectId } }: Props) => {
return (
<>
<PageLayout title="Retention" organizationSlug={organizationSlug} />
<div className="flex flex-col gap-8 p-8">
<Padding>
<h1 className="mb-4 text-3xl font-semibold">Retention</h1>
<div className="flex max-w-6xl flex-col gap-8">
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Experimental feature</AlertTitle>
@@ -59,7 +58,7 @@ const Retention = ({ params: { projectId, organizationSlug } }: Props) => {
<UsersRetentionSeries projectId={projectId} />
<WeeklyCohortsServer projectId={projectId} />
</div>
</>
</Padding>
);
};

View File

@@ -29,20 +29,20 @@ function Tooltip(props: any) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
<div className="text-xs text-muted-foreground">{payload.date}</div>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="text-sm text-muted-foreground">{payload.date}</div>
<div>
<div className="text-xs text-muted-foreground">
<div className="text-sm text-muted-foreground">
Monthly active users
</div>
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Weekly active users</div>
<div className="text-sm text-muted-foreground">Weekly active users</div>
<div className="text-lg font-semibold text-chart-1">{payload.wau}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Daily active users</div>
<div className="text-sm text-muted-foreground">Daily active users</div>
<div className="text-lg font-semibold text-chart-0">{payload.dau}</div>
</div>
</div>

View File

@@ -33,20 +33,20 @@ function Tooltip({ payload }: any) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(date))}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Active Users</div>
<div className="text-sm text-muted-foreground">Active Users</div>
<div className="text-lg font-semibold">{active_users}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Retained Users</div>
<div className="text-sm text-muted-foreground">Retained Users</div>
<div className="text-lg font-semibold">{retained_users}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Retention</div>
<div className="text-sm text-muted-foreground">Retention</div>
<div className="text-lg font-semibold">{round(retention, 2)}%</div>
</div>
</div>

View File

@@ -15,7 +15,7 @@ const Cell = ({ value, ratio }: { value: number; ratio: number }) => {
className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
>
<div
className="bg-highlight absolute inset-0 z-0"
className="absolute inset-0 z-0 bg-highlight"
style={{ opacity: ratio }}
></div>
<div className="relative z-10">{value}</div>
@@ -76,7 +76,7 @@ const WeeklyCohortsServer = async ({ projectId }: Props) => {
<tbody>
{res.map((row) => (
<tr key={row.first_seen}>
<td className="bg-def-100 text-def-1000 text-sm font-medium">
<td className="text-def-1000 bg-def-100 font-medium">
{row.first_seen}
</td>
<Cell

View File

@@ -1,3 +1,5 @@
'use client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
@@ -126,7 +128,7 @@ export default function CreateInvite({ projects }: Props) {
value: item.id,
}))}
/>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-sm text-muted-foreground">
Leave empty to give access to all projects
</p>
</div>

View File

@@ -1,6 +1,9 @@
import { TableButtons } from '@/components/data-table';
import { InvitesTable } from '@/components/settings/invites';
import { getInvites, getProjectsByOrganizationSlug } from '@openpanel/db';
import Invites from './invites';
import CreateInvite from './create-invite';
interface Props {
organizationSlug: string;
@@ -12,7 +15,14 @@ const InvitesServer = async ({ organizationSlug }: Props) => {
getProjectsByOrganizationSlug(organizationSlug),
]);
return <Invites invites={invites} projects={projects} />;
return (
<div>
<TableButtons>
<CreateInvite projects={projects} />
</TableButtons>
<InvitesTable data={invites} projects={projects} />
</div>
);
};
export default InvitesServer;

View File

@@ -1,129 +0,0 @@
'use client';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import { api } from '@/trpc/client';
import { MoreHorizontalIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { pathOr } from 'ramda';
import { toast } from 'sonner';
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
import CreateInvite from './create-invite';
interface Props {
invites: IServiceInvite[];
projects: IServiceProject[];
}
const Invites = ({ invites, projects }: Props) => {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
<CreateInvite projects={projects} />
</WidgetHead>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Mail</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Access</TableHead>
<TableHead>More</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return <Item {...item} projects={projects} key={item.id} />;
})}
</TableBody>
</Table>
</Widget>
);
};
interface ItemProps extends IServiceInvite {
projects: IServiceProject[];
}
function Item({ id, email, role, createdAt, projects, meta }: ItemProps) {
const router = useRouter();
const access = pathOr<string[]>([], ['access'], meta);
const revoke = api.organization.revokeInvite.useMutation({
onSuccess() {
toast.success(`Invite for ${email} revoked`);
router.refresh();
},
onError() {
toast.error(`Failed to revoke invite for ${email}`);
},
});
return (
<TableRow key={id}>
<TableCell className="font-medium">{email}</TableCell>
<TableCell>{role}</TableCell>
<TableCell>
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
{new Date(createdAt).toLocaleDateString()}
</TooltipComplete>
</TableCell>
<TableCell>
{access.map((id) => {
const project = projects.find((p) => p.id === id);
if (!project) {
return (
<Badge key={id} className="mr-1">
Unknown
</Badge>
);
}
return (
<Badge key={id} color="blue" className="mr-1">
{project.name}
</Badge>
);
})}
{access.length === 0 && (
<Badge variant={'secondary'}>All projects</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ memberId: id });
}}
>
Revoke invite
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
}
export default Invites;

View File

@@ -1,6 +1,6 @@
import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db';
import { MembersTable } from '@/components/settings/members';
import Members from './members';
import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db';
interface Props {
organizationSlug: string;
@@ -12,7 +12,7 @@ const MembersServer = async ({ organizationSlug }: Props) => {
getProjectsByOrganizationSlug(organizationSlug),
]);
return <Members members={members} projects={projects} />;
return <MembersTable data={members} projects={projects} />;
};
export default MembersServer;

View File

@@ -1,145 +0,0 @@
'use client';
import { useState } from 'react';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import { api } from '@/trpc/client';
import { MoreHorizontalIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceMember, IServiceProject } from '@openpanel/db';
interface Props {
members: IServiceMember[];
projects: IServiceProject[];
}
const Members = ({ members, projects }: Props) => {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Members</span>
</WidgetHead>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Access</TableHead>
<TableHead>More</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((item) => {
return <Item {...item} projects={projects} key={item.id} />;
})}
</TableBody>
</Table>
</Widget>
);
};
interface ItemProps extends IServiceMember {
projects: IServiceProject[];
}
function Item({
id,
user,
role,
organizationId,
createdAt,
projects,
access: prevAccess,
}: ItemProps) {
const router = useRouter();
const mutation = api.organization.updateMemberAccess.useMutation();
const revoke = api.organization.removeMember.useMutation({
onSuccess() {
toast.success(
`${user?.firstName} has been removed from the organization`
);
router.refresh();
},
onError() {
toast.error(`Failed to remove ${user?.firstName} from the organization`);
},
});
const [access, setAccess] = useState<string[]>(
prevAccess.map((item) => item.projectId)
);
if (!user) {
return null;
}
return (
<TableRow key={id}>
<TableCell className="font-medium">
<div>{[user?.firstName, user?.lastName].filter(Boolean).join(' ')}</div>
<div className="text-sm text-muted-foreground">{user?.email}</div>
</TableCell>
<TableCell>{role}</TableCell>
<TableCell>
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
{new Date(createdAt).toLocaleDateString()}
</TooltipComplete>
</TableCell>
<TableCell>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={access}
onChange={(newAccess) => {
setAccess(newAccess);
mutation.mutate({
userId: user.id,
organizationSlug: organizationId,
access: newAccess as string[],
});
}}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ organizationId: organizationId, userId: id });
}}
>
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
}
export default Members;

View File

@@ -1,8 +1,11 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { auth } from '@clerk/nextjs/server';
import { ShieldAlertIcon } from 'lucide-react';
import { notFound } from 'next/navigation';
import { parseAsStringEnum } from 'nuqs';
import { db } from '@openpanel/db';
@@ -14,11 +17,16 @@ interface PageProps {
params: {
organizationSlug: string;
};
searchParams: Record<string, string>;
}
export default async function Page({
params: { organizationSlug },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['org', 'members', 'invites'])
.withDefault('org')
.parseServerSide(searchParams.tab);
const session = auth();
const organization = await db.organization.findUnique({
where: {
@@ -49,30 +57,36 @@ export default async function Page({
const hasAccess = member?.role === 'org:admin';
if (!hasAccess) {
return (
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
);
}
return (
<>
<PageLayout
title={organization.name}
organizationSlug={organizationSlug}
/>
{hasAccess ? (
<div className="grid gap-8 p-4 lg:grid-cols-2">
<EditOrganization organization={organization} />
<div className="col-span-2">
<MembersServer organizationSlug={organizationSlug} />
</div>
<div className="col-span-2">
<InvitesServer organizationSlug={organizationSlug} />
</div>
</div>
) : (
<>
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
</>
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href={`?tab=org`} isActive={tab === 'org'}>
Organization
</PageTabsLink>
<PageTabsLink href={`?tab=members`} isActive={tab === 'members'}>
Members
</PageTabsLink>
<PageTabsLink href={`?tab=invites`} isActive={tab === 'invites'}>
Invites
</PageTabsLink>
</PageTabs>
{tab === 'org' && <EditOrganization organization={organization} />}
{tab === 'members' && (
<MembersServer organizationSlug={organizationSlug} />
)}
</>
{tab === 'invites' && (
<InvitesServer organizationSlug={organizationSlug} />
)}
</Padding>
);
}

View File

@@ -1,32 +1,18 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { Padding } from '@/components/ui/padding';
import { auth } from '@clerk/nextjs/server';
import { getUserById } from '@openpanel/db';
import EditProfile from './edit-profile';
import { Logout } from './logout';
interface PageProps {
params: {
organizationSlug: string;
};
}
export default async function Page({
params: { organizationSlug },
}: PageProps) {
export default async function Page() {
const { userId } = auth();
const profile = await getUserById(userId!);
return (
<>
<PageLayout
title={profile.lastName}
organizationSlug={organizationSlug}
/>
<div className="flex flex-col gap-4 p-4">
<EditProfile profile={profile} />
<Logout />
</div>
</>
<Padding>
<h1 className="mb-4 text-2xl font-bold">Profile</h1>
<EditProfile profile={profile} />
</Padding>
);
}

View File

@@ -24,93 +24,89 @@ interface ListProjectsProps {
export default function ListProjects({ projects, clients }: ListProjectsProps) {
return (
<>
<StickyBelowHeader>
<div className="flex items-center justify-between p-4">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<div className="card p-4">
<Alert className="mb-4">
<InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle>
<AlertDescription>
A project can be a website, mobile app or any other application
that you want to track event for. Each project can have one or
more clients. The client is used to send events to the project.
</AlertDescription>
</Alert>
<Accordion type="single" collapsible className="-mx-4">
{projects.map((project) => {
const pClients = clients.filter(
(client) => client.projectId === project.id
);
return (
<AccordionItem
value={project.id}
key={project.id}
className="last:border-b-0"
>
<AccordionTrigger className="px-4">
<div className="flex-1 text-left">
{project.name}
<span className="ml-2 text-muted-foreground">
{pClients.length > 0
? `(${pClients.length} clients)`
: 'No clients created yet'}
</span>
</div>
<div className="mx-4"></div>
</AccordionTrigger>
<AccordionContent className="px-4">
<ProjectActions {...project} />
<div className="mt-4 grid gap-4 md:grid-cols-3">
{pClients.map((item) => {
return (
<div
className="relative rounded border border-border p-4"
key={item.id}
<div className="row mb-4 justify-between">
<h1 className="text-2xl font-bold">Projects</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
<div className="card p-4">
<Alert className="mb-4">
<InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle>
<AlertDescription>
A project can be a website, mobile app or any other application that
you want to track event for. Each project can have one or more
clients. The client is used to send events to the project.
</AlertDescription>
</Alert>
<Accordion type="single" collapsible className="-mx-4">
{projects.map((project) => {
const pClients = clients.filter(
(client) => client.projectId === project.id
);
return (
<AccordionItem
value={project.id}
key={project.id}
className="last:border-b-0"
>
<AccordionTrigger className="px-4">
<div className="flex-1 text-left">
{project.name}
<span className="ml-2 text-muted-foreground">
{pClients.length > 0
? `(${pClients.length} clients)`
: 'No clients created yet'}
</span>
</div>
<div className="mx-4"></div>
</AccordionTrigger>
<AccordionContent className="px-4">
<ProjectActions {...project} />
<div className="mt-4 grid gap-4 md:grid-cols-3">
{pClients.map((item) => {
return (
<div
className="relative rounded border border-border p-4"
key={item.id}
>
<div className="mb-1 font-medium">{item.name}</div>
<Tooltiper
className="text-muted-foreground"
content={item.id}
>
<div className="mb-1 font-medium">{item.name}</div>
<Tooltiper
className="text-muted-foreground"
content={item.id}
>
Client ID: ...{item.id.slice(-12)}
</Tooltiper>
<div className="text-muted-foreground">
{item.cors &&
item.cors !== '*' &&
`Website: ${item.cors}`}
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
Client ID: ...{item.id.slice(-12)}
</Tooltiper>
<div className="text-muted-foreground">
{item.cors &&
item.cors !== '*' &&
`Website: ${item.cors}`}
</div>
);
})}
<button
onClick={() => {
pushModal('AddClient', {
projectId: project.id,
});
}}
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
>
<PlusSquareIcon />
<div className="font-medium">New client</div>
</button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
<div className="absolute right-4 top-4">
<ClientActions {...item} />
</div>
</div>
);
})}
<button
onClick={() => {
pushModal('AddClient', {
projectId: project.id,
});
}}
className="flex items-center justify-center gap-4 rounded bg-muted p-4"
>
<PlusSquareIcon />
<div className="font-medium">New client</div>
</button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</>
);

View File

@@ -1,4 +1,5 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { Padding } from '@/components/ui/padding';
import {
getClientsByOrganizationSlug,
@@ -22,9 +23,8 @@ export default async function Page({
]);
return (
<>
<PageLayout title="Projects" organizationSlug={organizationSlug} />
<Padding>
<ListProjects projects={projects} clients={clients} />
</>
</Padding>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/references/table';
import { Button } from '@/components/ui/button';
import { Padding } from '@/components/ui/padding';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
@@ -15,19 +15,15 @@ interface ListProjectsProps {
export default function ListReferences({ data }: ListProjectsProps) {
return (
<>
<StickyBelowHeader>
<div className="flex items-center justify-between p-4">
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={data} columns={columns} />
<Padding>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-2xl font-bold">References</h1>
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
<span className="max-sm:hidden">Create reference</span>
<span className="sm:hidden">Reference</span>
</Button>
</div>
</>
<DataTable data={data} columns={columns} />
</Padding>
);
}

View File

@@ -1,5 +1,3 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { getReferences } from '@openpanel/db';
import ListReferences from './list-references';
@@ -11,9 +9,7 @@ interface PageProps {
};
}
export default async function Page({
params: { organizationSlug, projectId },
}: PageProps) {
export default async function Page({ params: { projectId } }: PageProps) {
const references = await getReferences({
where: {
projectId,
@@ -24,7 +20,6 @@ export default async function Page({
return (
<>
<PageLayout title="References" organizationSlug={organizationSlug} />
<ListReferences data={references} />
</>
);

View File

@@ -20,9 +20,9 @@ const Page = ({ children }: Props) => {
</FullWidthNavbar>
<div className="mx-auto w-full md:max-w-[95vw] lg:max-w-[80vw]">
<div className="grid md:grid-cols-[25vw_1fr] lg:grid-cols-[20vw_1fr]">
<div className="max-w-screen bg-def-200 flex flex-col gap-4 overflow-hidden p-4 pr-0 md:bg-transparent md:py-14">
<div className="max-w-screen flex flex-col gap-4 overflow-hidden bg-def-200 p-4 pr-0 md:bg-transparent md:py-14">
<div>
<div className="text-xs font-bold uppercase text-[#7b94ac]">
<div className="text-sm font-bold uppercase text-[#7b94ac]">
Welcome to Openpanel
</div>
<div className="text-xl font-medium leading-loose">

View File

@@ -15,7 +15,7 @@ const ConnectApp = ({ client }: Props) => {
<SmartphoneIcon />
App
</div>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground">
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
@@ -30,7 +30,7 @@ const ConnectApp = ({ client }: Props) => {
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
@@ -43,7 +43,7 @@ const ConnectApp = ({ client }: Props) => {
</button>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"

View File

@@ -15,7 +15,7 @@ const ConnectBackend = ({ client }: Props) => {
<ServerIcon />
Backend
</div>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground">
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
@@ -30,7 +30,7 @@ const ConnectBackend = ({ client }: Props) => {
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
@@ -43,7 +43,7 @@ const ConnectBackend = ({ client }: Props) => {
</button>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"

View File

@@ -15,7 +15,7 @@ const ConnectWeb = ({ client }: Props) => {
<MonitorIcon />
Website
</div>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground">
Pick a framework below to get started.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
@@ -30,7 +30,7 @@ const ConnectWeb = ({ client }: Props) => {
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name}
>
<div className="bg-def-200 h-10 w-10 rounded-md p-2">
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
<img
className="h-full w-full object-contain"
src={framework.logo}
@@ -43,7 +43,7 @@ const ConnectWeb = ({ client }: Props) => {
</button>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '}
<a
href="mailto:hello@openpanel.dev"

View File

@@ -61,7 +61,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
<div className="flex items-center gap-2 text-2xl capitalize">
{client?.name}
</div>
<div className="mt-2 text-xs font-semibold text-muted-foreground">
<div className="mt-2 text-sm font-semibold text-muted-foreground">
Connection status: {renderBadge()}
</div>
@@ -81,13 +81,13 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
{isConnected ? (
<div className="flex flex-col-reverse">
{events.length > 5 && (
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span>
</div>
)}
{events.slice(-5).map((event, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<div key={index} className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800">
@@ -97,7 +97,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
))}
</div>
) : (
<div className="text-sm">
<div className="">
Verify that your events works before submitting any changes to App
Store/Google Play
</div>
@@ -105,7 +105,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
<div className="mt-2 text-sm text-muted-foreground">
You can{' '}
<button
className="underline"

View File

@@ -73,7 +73,7 @@ const Verify = ({ project, events }: Props) => {
{!verified && (
<Link
href={`/${project.organizationSlug}/${project.id}`}
className="text-sm text-muted-foreground underline"
className=" text-muted-foreground underline"
>
Skip for now
</Link>

View File

@@ -34,7 +34,7 @@ const SkipOnboarding = () => {
});
}
}}
className="flex items-center gap-2 text-sm text-muted-foreground"
className="flex items-center gap-2 text-muted-foreground"
>
Skip onboarding
<ChevronLastIcon size={16} />

View File

@@ -59,9 +59,9 @@ const Steps = ({ className }: Props) => {
const currentIndex = steps.findIndex((i) => i.status === 'current');
return (
<div className="relative">
<div className="bg-def-200 absolute bottom-4 left-4 top-4 w-px"></div>
<div className="absolute bottom-4 left-4 top-4 w-px bg-def-200"></div>
<div
className="bg-highlight absolute left-4 top-4 w-px"
className="absolute left-4 top-4 w-px bg-highlight"
style={{
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
}}
@@ -86,17 +86,17 @@ const Steps = ({ className }: Props) => {
>
<div
className={cn(
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm text-white'
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white'
)}
>
<div
className={cn(
'bg-highlight absolute inset-0 z-0 rounded-full',
'absolute inset-0 z-0 rounded-full bg-highlight',
step.status === 'pending' && 'bg-def-400'
)}
></div>
{step.status === 'current' && (
<div className="bg-highlight absolute inset-1 z-0 animate-ping-slow rounded-full"></div>
<div className="absolute inset-1 z-0 animate-ping-slow rounded-full bg-highlight"></div>
)}
<div className="relative">
{step.status === 'completed' && <CheckCheckIcon size={14} />}
@@ -109,7 +109,7 @@ const Steps = ({ className }: Props) => {
</div>
</div>
<div className="text-sm font-medium">{step.name}</div>
<div className=" font-medium">{step.name}</div>
</div>
))}
</div>

View File

@@ -49,7 +49,7 @@ export default async function Page({
href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="flex flex-col items-end text-lg font-medium"
>
<span className="text-xs">POWERED BY</span>
<span className="text-sm">POWERED BY</span>
<span>openpanel.dev</span>
</a>
</div>

View File

@@ -6,6 +6,9 @@ import Providers from './providers';
import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.css';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
export const metadata = {
title: 'Overview - Openpanel.dev',
};
@@ -25,7 +28,11 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn('grainy bg-def-100 min-h-screen font-sans antialiased')}
className={cn(
'grainy min-h-screen bg-def-100 font-sans text-base antialiased',
GeistSans.variable,
GeistMono.variable
)}
>
<NextTopLoader
showSpinner={false}

View File

@@ -16,7 +16,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
<div className="w-full">
<CopyInput label="Secret" value={secret} />
{cors && (
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-sm text-muted-foreground">
You will only need the secret if you want to send server events.
</p>
)}
@@ -25,7 +25,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
{cors && (
<div className="text-left">
<Label>CORS settings</Label>
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
{cors}
</div>
</div>

View File

@@ -13,7 +13,7 @@ export const columns: ColumnDef<IServiceClientWithProject>[] = [
return (
<div>
<div>{row.original.name}</div>
<div className="text-sm text-muted-foreground">
<div className=" text-muted-foreground">
{row.original.project?.name ?? 'No project'}
</div>
</div>

View File

@@ -7,7 +7,7 @@ export function ColorSquare({ children, className }: ColorSquareProps) {
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
className
)}
>

View File

@@ -1,5 +1,6 @@
'use client';
import { cn } from '@/utils/cn';
import {
flexRender,
getCoreRowModel,
@@ -7,20 +8,27 @@ import {
} from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
import { Grid, GridBody, GridCell, GridHeader, GridRow } from './grid-table';
interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
}
export function TableButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('mb-2 flex flex-wrap items-center gap-2', className)}>
{children}
</div>
);
}
export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
const table = useReactTable({
data,
@@ -29,47 +37,45 @@ export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
});
return (
<Table>
<TableHeader>
<Grid columns={columns.length}>
<GridHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
<GridRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<GridCell key={header.id} isHeader>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</GridCell>
))}
</GridRow>
))}
</TableHeader>
<TableBody>
</GridHeader>
<GridBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
<GridRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<GridCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
</GridCell>
))}
</TableRow>
</GridRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
<GridRow>
<GridCell colSpan={columns.length}>
<div className="h-24 text-center">No results.</div>
</GridCell>
</GridRow>
)}
</TableBody>
</Table>
</GridBody>
</Grid>
);
}

View File

@@ -0,0 +1,145 @@
import { EventIcon } from '@/app/(app)/[organizationSlug]/[projectId]/events/event-list/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { TooltipComplete } from '@/components/tooltip-complete';
import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>;
}
return (
<>
<span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span>
</>
);
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex items-center gap-2">
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
<span className="flex gap-2">
<button
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
});
}}
className="font-medium"
>
{renderName()}
</button>
{renderDuration()}
</span>
</div>
);
},
},
{
accessorKey: 'country',
header: 'Country',
cell({ row }) {
const { country, city } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={country} />
<span>{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { os } = row.original;
return (
<div className="flex min-w-full items-center gap-2">
<SerieIcon name={os} />
<span>{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { browser } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={browser} />
<span>{browser}</span>
</div>
);
},
},
{
accessorKey: 'profileId',
header: 'Profile',
cell({ row }) {
const { profile } = row.original;
if (!profile) {
return null;
}
return (
<ProjectLink
href={`/profiles/${profile?.id}`}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
</ProjectLink>
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
];
return columns;
}

View File

@@ -0,0 +1,71 @@
import type { Dispatch, SetStateAction } from 'react';
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 type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
import type { IServiceEvent } from '@openpanel/db';
import { useColumns } from './columns';
type Props =
| {
query: UseQueryResult<IServiceEvent[]>;
}
| {
query: UseQueryResult<IServiceEvent[]>;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
};
export const EventsTable = ({ query, ...props }: Props) => {
const columns = useColumns();
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>
);
}
if (data?.length === 0) {
return (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
<p>Could not find any events</p>
{'cursor' in props && props.cursor !== 0 && (
<Button
className="mt-8"
variant="outline"
onClick={() => props.setCursor((p) => p - 1)}
>
Go to previous page
</Button>
)}
</FullPageEmptyState>
);
}
return (
<>
<DataTable data={data ?? []} columns={columns} />
{'cursor' in props && (
<Pagination
className="mt-2"
setCursor={props.setCursor}
cursor={props.cursor}
count={Infinity}
take={50}
loading={isFetching}
/>
)}
</>
);
};

View File

@@ -24,7 +24,7 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
<div>
<label
className={cn(
'hover:bg-def-200 flex items-center gap-4 px-4 py-6 transition-colors',
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
disabled && 'cursor-not-allowed opacity-50'
)}
htmlFor={id}
@@ -32,8 +32,8 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className="text-sm text-muted-foreground">{description}</div>
{error && <div className="text-xs text-red-600">{error}</div>}
<div className=" text-muted-foreground">{description}</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
<div>
<Switch

View File

@@ -17,7 +17,7 @@ const CopyInput = ({ label, value, className }: Props) => {
onClick={() => clipboard(value)}
>
{!!label && <Label>{label}</Label>}
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
{value}
<CopyIcon size={16} />
</div>

View File

@@ -38,7 +38,7 @@ export const WithLabel = ({
</Label>
{error && (
<Tooltiper asChild content={error}>
<div className="flex items-center gap-1 text-sm leading-none text-destructive">
<div className="flex items-center gap-1 leading-none text-destructive">
Issues
<BanIcon size={14} />
</div>

View File

@@ -112,7 +112,7 @@ const TagInput = ({
data-tag={tag}
key={tag}
className={cn(
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 text-sm',
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
isMarkedForDeletion &&
i === value.length - 1 &&
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
@@ -136,7 +136,7 @@ const TagInput = ({
<input
ref={inputRef}
placeholder={`${placeholder}`}
className="min-w-20 flex-1 py-1 text-sm focus-visible:outline-none"
className="min-w-20 flex-1 py-1 focus-visible:outline-none"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -7,6 +7,7 @@ import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useDebounce } from 'usehooks-ts';
import { Button } from './ui/button';
import { Tooltiper } from './ui/tooltip';
type Props = {
@@ -37,18 +38,21 @@ export const Fullscreen = (props: Props) => {
};
export const FullscreenOpen = () => {
const [, setIsFullscreen] = useFullscreen();
const [fullscreen, setIsFullscreen] = useFullscreen();
if (fullscreen) {
return null;
}
return (
<Tooltiper content="Toggle fullscreen" asChild>
<button
className="flex items-center gap-2"
<Button
variant="outline"
size="icon"
onClick={() => {
setIsFullscreen((p) => !p);
}}
>
<FullscreenIcon />
Realtime
</button>
<FullscreenIcon className="size-4" />
</Button>
</Tooltiper>
);
};

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { cn } from '@/utils/cn';
export const Grid: React.FC<
React.HTMLAttributes<HTMLDivElement> & { columns: number }
> = ({ className, columns, children, ...props }) => (
<div className={cn('card', className)}>
<div className="relative w-full overflow-auto rounded-md">
<div
className={cn('grid w-full')}
style={{
gridTemplateColumns: `repeat(${columns}, auto)`,
width: 'max-content',
minWidth: '100%',
}}
{...props}
>
{children}
</div>
</div>
</div>
);
export const GridHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div className={cn('contents', className)} {...props}>
{children}
</div>
);
export const GridBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn('contents [&>*:last-child]:border-0', className)}
{...props}
>
{children}
</div>
);
export const GridCell: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
as?: React.ElementType;
colSpan?: number;
isHeader?: boolean;
}
> = ({
className,
children,
as: Component = 'div',
colSpan,
isHeader,
...props
}) => (
<Component
className={cn(
'flex 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
)}
{...props}
>
{children}
</Component>
);
export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
children,
...props
}) => (
<div
className={cn(
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,23 @@
import { useAppParams } from '@/hooks/useAppParams';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
export function ProjectLink({
children,
...props
}: LinkProps & {
children: React.ReactNode;
className?: string;
title?: string;
}) {
const { organizationSlug, projectId } = useAppParams();
if (typeof props.href === 'string') {
return (
<Link {...props} href={`/${organizationSlug}/${projectId}/${props.href}`}>
{children}
</Link>
);
}
return <p>ProjectLink</p>;
}

View File

@@ -23,7 +23,7 @@ export function OverviewFiltersButtons({
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
<div className={cn('flex flex-wrap gap-2', className)}>
{events.map((event) => (
<Button
key={event}
@@ -32,7 +32,7 @@ export function OverviewFiltersButtons({
icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<strong>{event}</strong>
<strong className="font-semibold">{event}</strong>
</Button>
))}
{filters.map((filter) => {
@@ -49,7 +49,7 @@ export function OverviewFiltersButtons({
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
>
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
<strong>{filter.value[0]}</strong>
<strong className="font-semibold">{filter.value[0]}</strong>
</Button>
);
})}

View File

@@ -7,7 +7,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useDebounceVal } from '@/hooks/useDebounceVal';
import { useDebounceState } from '@/hooks/useDebounceState';
import useWS from '@/hooks/useWS';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
@@ -28,7 +28,7 @@ const FIFTEEN_SECONDS = 1000 * 30;
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
const client = useQueryClient();
const counter = useDebounceVal(data, 1000, {
const counter = useDebounceState(data, 1000, {
maxWait: 5000,
});
const lastRefresh = useRef(Date.now());

View File

@@ -13,7 +13,7 @@ export function OverviewChartToggle({ chartType, setChartType }: Props) {
return (
<Button
size={'icon'}
variant={'outline'}
variant={'ghost'}
onClick={() => {
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
}}

View File

@@ -3,22 +3,25 @@ import { ScanEyeIcon } from 'lucide-react';
import type { IChartProps } from '@openpanel/validation';
import { Button } from '../ui/button';
type Props = {
chart: IChartProps;
};
const OverviewDetailsButton = ({ chart }: Props) => {
return (
<button
className="-mb-2 mt-5 flex w-full items-center justify-center gap-2 text-sm font-semibold"
<Button
size="icon"
variant="ghost"
onClick={() => {
pushModal('OverviewChartDetails', {
chart: chart,
});
}}
>
<ScanEyeIcon size={18} /> Details
</button>
<ScanEyeIcon size={18} />
</Button>
);
};

View File

@@ -131,7 +131,7 @@ interface WrapperProps {
function Wrapper({ children, count }: WrapperProps) {
return (
<div className="flex h-full flex-col">
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
{count} unique vistors last 30 minutes
</div>
<div className="relative flex h-full w-full flex-1 items-end gap-1">

View File

@@ -192,8 +192,8 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
return (
<>
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 md:m-0">
<div className="card mb-2 grid grid-cols-4">
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
{reports.map((report, index) => (
<button
key={index}

View File

@@ -32,7 +32,7 @@ const OverviewTopBots = ({ projectId }: Props) => {
<>
<div className="-m-4">
<WidgetTable
className="max-w-full [&_td:first-child]:w-full [&_th]:text-xs [&_tr]:text-xs"
className="max-w-full [&_td:first-child]:w-full [&_th]:text-sm [&_tr]:text-sm"
data={data}
keyExtractor={(item) => item.id}
columns={[

View File

@@ -11,7 +11,7 @@ import { LazyChart } from '../report/chart/LazyChart';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -271,10 +271,7 @@ export default function OverviewTopDevices({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle {...{ chartType, setChartType }} />
</div>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
@@ -312,8 +309,11 @@ export default function OverviewTopDevices({
}
}}
/>
<OverviewDetailsButton chart={widget.chart} />
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>
);

View File

@@ -10,7 +10,7 @@ import type { IChartType } from '@openpanel/validation';
import { Widget, WidgetBody } from '../../widget';
import { OverviewChartToggle } from '../overview-chart-toggle';
import OverviewDetailsButton from '../overview-details-button';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
@@ -143,10 +143,7 @@ export default function OverviewTopEvents({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle {...{ chartType, setChartType }} />
</div>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
@@ -163,8 +160,11 @@ export default function OverviewTopEvents({
</WidgetHead>
<WidgetBody>
<LazyChart hideID {...widget.chart} previous={false} />
<OverviewDetailsButton chart={widget.chart} />
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>
);

View File

@@ -13,7 +13,7 @@ import { LazyChart } from '../report/chart/LazyChart';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -143,10 +143,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle {...{ chartType, setChartType }} />
</div>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
@@ -180,8 +177,11 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}
}}
/>
<OverviewDetailsButton chart={widget.chart} />
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>

View File

@@ -14,7 +14,7 @@ import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
import OverviewTopBots from './overview-top-bots';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -154,10 +154,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle {...{ chartType, setChartType }} />
</div>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
@@ -196,8 +193,13 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
]}
/>
)}
{widget.chart?.name && <OverviewDetailsButton chart={widget.chart} />}
</WidgetBody>
{widget.chart?.name && (
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
)}
</Widget>
</>
);

View File

@@ -2,9 +2,7 @@
import { useState } from 'react';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { ScanEyeIcon } from 'lucide-react';
import type { IChartType } from '@openpanel/validation';
@@ -12,7 +10,7 @@ import { LazyChart } from '../report/chart/LazyChart';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
@@ -282,10 +280,7 @@ export default function OverviewTopSources({
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">
{widget.title}
<OverviewChartToggle {...{ chartType, setChartType }} />
</div>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
@@ -335,8 +330,11 @@ export default function OverviewTopSources({
}
}}
/>
<OverviewDetailsButton chart={widget.chart} />
</WidgetBody>
<WidgetFooter>
<OverviewDetailsButton chart={widget.chart} />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>
);

Some files were not shown because too many files have changed in this diff Show More