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

View File

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

View File

@@ -31,15 +31,16 @@ import {
getDefaultIntervalByRange, getDefaultIntervalByRange,
timeWindows, timeWindows,
} from '@openpanel/constants'; } from '@openpanel/constants';
import type { getReportsByDashboardId } from '@openpanel/db'; import type { getReportsByDashboardId, IServiceDashboard } from '@openpanel/db';
import { OverviewReportRange } from '../../overview-sticky-header'; import { OverviewReportRange } from '../../overview-sticky-header';
interface ListReportsProps { interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>; reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
dashboard: IServiceDashboard;
} }
export function ListReports({ reports }: ListReportsProps) { export function ListReports({ reports, dashboard }: ListReportsProps) {
const router = useRouter(); const router = useRouter();
const params = useAppParams<{ dashboardId: string }>(); const params = useAppParams<{ dashboardId: string }>();
const { range, startDate, endDate } = useOverviewOptions(); const { range, startDate, endDate } = useOverviewOptions();
@@ -52,7 +53,9 @@ export function ListReports({ reports }: ListReportsProps) {
}); });
return ( return (
<> <>
<StickyBelowHeader className="flex items-center justify-between p-4"> <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 /> <OverviewReportRange />
<Button <Button
icon={PlusIcon} icon={PlusIcon}
@@ -69,8 +72,9 @@ export function ListReports({ reports }: ListReportsProps) {
<span className="max-sm:hidden">Create report</span> <span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span> <span className="sm:hidden">Report</span>
</Button> </Button>
</StickyBelowHeader> </div>
<div className="mx-auto flex max-w-3xl flex-col gap-8 p-4 md:p-8"> </div>
<div className="flex max-w-6xl flex-col gap-8">
{reports.map((report) => { {reports.map((report) => {
const chartRange = report.range; const chartRange = report.range;
return ( return (
@@ -83,7 +87,7 @@ export function ListReports({ reports }: ListReportsProps) {
<div> <div>
<div className="font-medium">{report.name}</div> <div className="font-medium">{report.name}</div>
{chartRange !== null && ( {chartRange !== null && (
<div className="mt-2 flex gap-2 text-sm"> <div className="mt-2 flex gap-2 ">
<span <span
className={ className={
range !== null || (startDate && endDate) 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 { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@openpanel/db'; import { getDashboardById, getReportsByDashboardId } from '@openpanel/db';
@@ -14,7 +14,7 @@ interface PageProps {
} }
export default async function Page({ export default async function Page({
params: { organizationSlug, projectId, dashboardId }, params: { projectId, dashboardId },
}: PageProps) { }: PageProps) {
const [dashboard, reports] = await Promise.all([ const [dashboard, reports] = await Promise.all([
getDashboardById(dashboardId, projectId), getDashboardById(dashboardId, projectId),
@@ -26,9 +26,8 @@ export default async function Page({
} }
return ( return (
<> <Padding>
<PageLayout title={dashboard.name} organizationSlug={organizationSlug} /> <ListReports reports={reports} dashboard={dashboard} />
<ListReports reports={reports} /> </Padding>
</>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,11 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import ListDashboardsServer from './list-dashboards'; import ListDashboardsServer from './list-dashboards';
import { HeaderDashboards } from './list-dashboards/header';
interface PageProps { interface PageProps {
params: { params: {
projectId: string; projectId: string;
organizationSlug: string;
}; };
} }
export default function Page({ export default function Page({ params: { projectId } }: PageProps) {
params: { projectId, organizationSlug }, return <ListDashboardsServer projectId={projectId} />;
}: PageProps) {
return (
<>
<PageLayout title="Dashboards" organizationSlug={organizationSlug} />
<HeaderDashboards />
<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> & { type EventIconProps = VariantProps<typeof variants> & {
name: string; name: string;
meta?: EventMeta; meta?: EventMeta;
projectId: string;
className?: string; className?: string;
}; };

View File

@@ -1,17 +1,16 @@
'use client'; 'use client';
import { useState } from 'react';
import { SerieIcon } from '@/components/report/chart/SerieIcon'; import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { Tooltiper } from '@/components/ui/tooltip'; import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useNumber } from '@/hooks/useNumerFormatter'; import { useNumber } from '@/hooks/useNumerFormatter';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import Link from 'next/link'; import Link from 'next/link';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db'; import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { EventDetails } from './event-details';
import { EventIcon } from './event-icon'; import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent; type EventListItemProps = IServiceEventMinimal | IServiceEvent;
@@ -20,7 +19,6 @@ export function EventListItem(props: EventListItemProps) {
const { organizationSlug, projectId } = useAppParams(); const { organizationSlug, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props; const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null; const profile = 'profile' in props ? props.profile : null;
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const number = useNumber(); const number = useNumber();
@@ -52,17 +50,12 @@ export function EventListItem(props: EventListItemProps) {
return ( return (
<> <>
{!isMinimal && (
<EventDetails
event={props}
open={isDetailsOpen}
setOpen={setIsDetailsOpen}
/>
)}
<button <button
onClick={() => { onClick={() => {
if (!isMinimal) { if (!isMinimal) {
setIsDetailsOpen(true); pushModal('EventDetails', {
id: props.id,
});
} }
}} }}
className={cn( className={cn(
@@ -72,13 +65,8 @@ export function EventListItem(props: EventListItemProps) {
)} )}
> >
<div> <div>
<div className="flex items-center gap-4 text-left text-sm"> <div className="flex items-center gap-4 text-left ">
<EventIcon <EventIcon size="sm" name={name} meta={meta} />
size="sm"
name={name}
meta={meta}
projectId={projectId}
/>
<span> <span>
<span className="font-medium">{renderName()}</span> <span className="font-medium">{renderName()}</span>
{' '} {' '}
@@ -100,14 +88,14 @@ export function EventListItem(props: EventListItemProps) {
e.stopPropagation(); e.stopPropagation();
}} }}
href={`/${organizationSlug}/${projectId}/profiles/${profile?.id}`} 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)} {getProfileName(profile)}
</Link> </Link>
</Tooltiper> </Tooltiper>
<Tooltiper asChild content={createdAt.toLocaleString()}> <Tooltiper asChild content={createdAt.toLocaleString()}>
<div className="text-sm text-muted-foreground"> <div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()} {createdAt.toLocaleTimeString()}
</div> </div>
</Tooltiper> </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, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceVal } from '@/hooks/useDebounceVal'; import { useDebounceState } from '@/hooks/useDebounceState';
import useWS from '@/hooks/useWS'; import useWS from '@/hooks/useWS';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import type { IServiceEventMinimal } from '@openpanel/db'; import type { IServiceEventMinimal } from '@openpanel/db';
@@ -19,10 +18,13 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
loading: () => <div>0</div>, loading: () => <div>0</div>,
}); });
export default function EventListener() { export default function EventListener({
const router = useRouter(); onRefresh,
}: {
onRefresh: () => void;
}) {
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const counter = useDebounceVal(0, 1000, { const counter = useDebounceState(0, 1000, {
maxWait: 5000, maxWait: 5000,
}); });
@@ -38,9 +40,9 @@ export default function EventListener() {
<button <button
onClick={() => { onClick={() => {
counter.set(0); 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 className="relative">
<div <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 { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { Padding } from '@/components/ui/padding';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; import { parseAsStringEnum } from 'nuqs';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { parseAsInteger } from 'nuqs';
import { StickyBelowHeader } from '../layout-sticky-below-header'; import Charts from './charts';
import { EventsPerDayChart } from './charts/events-per-day-chart'; import Conversions from './conversions';
import EventConversionsListServer from './event-conversions-list'; import Events from './events';
import EventListServer from './event-list';
interface PageProps { interface PageProps {
params: { params: {
projectId: string; projectId: string;
organizationSlug: string; organizationSlug: string;
}; };
searchParams: { searchParams: Record<string, string>;
events?: string;
cursor?: string;
f?: string;
};
} }
const nuqsOptions = {
shallow: false,
};
export default function Page({ export default function Page({
params: { projectId, organizationSlug }, params: { projectId },
searchParams, searchParams,
}: PageProps) { }: PageProps) {
const cursor = const tab = parseAsStringEnum(['events', 'conversions', 'charts'])
parseAsInteger.parseServerSide(searchParams.cursor ?? '') ?? undefined; .withDefault('events')
const filters = .parseServerSide(searchParams.tab);
eventQueryFiltersParser.parseServerSide(searchParams.f ?? '') ?? undefined;
const eventNames =
eventQueryNamesFilter.parseServerSide(searchParams.events) ?? undefined;
return ( return (
<> <>
<PageLayout title="Events" organizationSlug={organizationSlug} /> <Padding>
<StickyBelowHeader className="flex justify-between p-4"> <div className="mb-4">
<OverviewFiltersDrawer <PageTabs>
mode="events" <PageTabsLink href={`?tab=events`} isActive={tab === 'events'}>
projectId={projectId} Events
nuqsOptions={nuqsOptions} </PageTabsLink>
enableEventsFilter <PageTabsLink
/> href={`?tab=conversions`}
<OverviewFiltersButtons isActive={tab === 'conversions'}
className="justify-end p-0" >
nuqsOptions={nuqsOptions} Conversions
/> </PageTabsLink>
</StickyBelowHeader> <PageTabsLink href={`?tab=charts`} isActive={tab === 'charts'}>
<div className="grid gap-4 p-4 md:grid-cols-2"> Charts
<div> </PageTabsLink>
<EventListServer </PageTabs>
projectId={projectId}
cursor={cursor}
filters={filters}
eventNames={eventNames}
/>
</div>
<div className="flex flex-col gap-4">
<EventsPerDayChart
projectId={projectId}
events={eventNames}
filters={filters}
/>
<EventConversionsListServer projectId={projectId} />
</div>
</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'; 'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams'; import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs'; import { useUser } from '@clerk/nextjs';
import { import {
BookmarkIcon,
BuildingIcon,
CogIcon,
GanttChartIcon, GanttChartIcon,
Globe2Icon, Globe2Icon,
KeySquareIcon,
LayoutPanelTopIcon, LayoutPanelTopIcon,
UserIcon, PlusIcon,
UserSearchIcon, ScanEyeIcon,
UsersIcon, UsersIcon,
WallpaperIcon, WallpaperIcon,
WarehouseIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@@ -42,7 +39,7 @@ function LinkWithIcon({
return ( return (
<Link <Link
className={cn( 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', active && 'bg-def-200',
className className
)} )}
@@ -60,7 +57,6 @@ interface LayoutMenuProps {
export default function LayoutMenu({ dashboards }: LayoutMenuProps) { export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
const { user } = useUser(); const { user } = useUser();
const pathname = usePathname();
const params = useAppParams(); const params = useAppParams();
const hasProjectId = const hasProjectId =
params.projectId && params.projectId &&
@@ -103,48 +99,29 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
label="Events" label="Events"
href={`/${params.organizationSlug}/${projectId}/events`} href={`/${params.organizationSlug}/${projectId}/events`}
/> />
<LinkWithIcon
icon={UserSearchIcon}
label="Retention"
href={`/${params.organizationSlug}/${projectId}/retention`}
/>
<LinkWithIcon <LinkWithIcon
icon={UsersIcon} icon={UsersIcon}
label="Profiles" label="Profiles"
href={`/${params.organizationSlug}/${projectId}/profiles`} href={`/${params.organizationSlug}/${projectId}/profiles`}
/> />
<LinkWithIcon <LinkWithIcon
icon={CogIcon} icon={ScanEyeIcon}
label="Settings" label="Retention"
href={`/${params.organizationSlug}/${projectId}/settings/organization`} 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> </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"> <div className="flex flex-col gap-2">
{dashboards.map((item) => ( {dashboards.map((item) => (
<LinkWithIcon <LinkWithIcon
@@ -156,7 +133,6 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
))} ))}
</div> </div>
</div> </div>
)}
</> </>
); );
} }

View File

@@ -1,45 +1,144 @@
'use client'; 'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox'; 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 { 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 { usePathname, useRouter } from 'next/navigation';
import type { getProjectsByOrganizationSlug } from '@openpanel/db'; import type {
getCurrentOrganizations,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
interface LayoutProjectSelectorProps { interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>; projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
organizations?: Awaited<ReturnType<typeof getCurrentOrganizations>>;
align?: 'start' | 'end';
} }
export default function LayoutProjectSelector({ export default function LayoutProjectSelector({
projects, projects,
organizations,
align = 'start',
}: LayoutProjectSelectorProps) { }: LayoutProjectSelectorProps) {
const router = useRouter(); const router = useRouter();
const { organizationSlug, projectId } = useAppParams(); const { organizationSlug, projectId } = useAppParams();
const pathname = usePathname() || ''; const pathname = usePathname() || '';
const [open, setOpen] = useState(false);
return ( const changeProject = (newProjectId: string) => {
<div>
<Combobox
portal
align="end"
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {
if (organizationSlug && projectId) { if (organizationSlug && projectId) {
const split = pathname.replace(projectId, value).split('/'); const split = pathname
.replace(
`/${organizationSlug}/${projectId}`,
`/${organizationSlug}/${newProjectId}`
)
.split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx // slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/')); router.push(split.slice(0, 4).join('/'));
} else { } else {
router.push(`/${organizationSlug}/${value}`); router.push(`/${organizationSlug}/${newProjectId}`);
} }
}} };
value={projectId}
items={ const changeOrganization = (newOrganizationId: string) => {
projects.map((item) => ({ router.push(`/${newOrganizationId}`);
label: item.name, };
value: item.id,
})) ?? [] return (
} <DropdownMenu open={open} onOpenChange={setOpen}>
/> <DropdownMenuTrigger asChild>
</div> <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'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Logo } from '@/components/logo'; import { LogoSquare } from '@/components/logo';
import { buttonVariants } from '@/components/ui/button'; import SettingsToggle from '@/components/settings-toggle';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react'; import { Rotate as Hamburger } from 'hamburger-react';
import { PlusIcon } from 'lucide-react'; import { MenuIcon, XIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation'; 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 LayoutMenu from './layout-menu';
import LayoutOrganizationSelector from './layout-organization-selector'; import LayoutProjectSelector from './layout-project-selector';
interface LayoutSidebarProps { interface LayoutSidebarProps {
organizations: IServiceOrganization[]; organizations: IServiceOrganization[];
dashboards: IServiceDashboards; dashboards: IServiceDashboards;
organizationSlug: string; organizationSlug: string;
projectId: string; projectId: string;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
} }
export function LayoutSidebar({ export function LayoutSidebar({
organizations, organizations,
dashboards, dashboards,
organizationSlug, projects,
projectId,
}: LayoutSidebarProps) { }: LayoutSidebarProps) {
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
@@ -52,30 +56,28 @@ export function LayoutSidebar({
)} )}
> >
<div className="absolute -right-12 flex h-16 items-center lg:hidden"> <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>
<div className="flex h-16 shrink-0 items-center border-b border-border px-4"> <div className="flex h-16 shrink-0 items-center gap-4 border-b border-border px-4">
<Link href="/"> <LogoSquare className="max-h-8" />
<Logo /> <LayoutProjectSelector
</Link> align="start"
projects={projects}
organizations={organizations}
/>
<SettingsToggle />
</div> </div>
<div className="flex flex-grow flex-col gap-2 overflow-auto p-4"> <div className="flex flex-grow flex-col gap-2 overflow-auto p-4">
<LayoutMenu dashboards={dashboards} /> <LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="block h-32 shrink-0"></div>
</div> </div>
<div className="fixed bottom-0 left-0 right-0"> <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="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>
</div> </div>
</> </>

View File

@@ -12,7 +12,7 @@ export function StickyBelowHeader({
return ( return (
<div <div
className={cn( 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 className
)} )}
> >

View File

@@ -6,6 +6,7 @@ import {
getDashboardsByProjectId, getDashboardsByProjectId,
} from '@openpanel/db'; } from '@openpanel/db';
import LayoutContent from './layout-content';
import { LayoutSidebar } from './layout-sidebar'; import { LayoutSidebar } from './layout-sidebar';
import SideEffects from './side-effects'; import SideEffects from './side-effects';
@@ -46,9 +47,15 @@ export default async function AppLayout({
return ( return (
<div id="dashboard"> <div id="dashboard">
<LayoutSidebar <LayoutSidebar
{...{ organizationSlug, projectId, organizations, dashboards }} {...{
organizationSlug,
projectId,
organizations,
projects,
dashboards,
}}
/> />
<div className="transition-all lg:pl-72">{children}</div> <LayoutContent>{children}</LayoutContent>
<SideEffects /> <SideEffects />
</div> </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 { interface PageLayoutProps {
title: React.ReactNode; title: React.ReactNode;
organizationSlug: string;
} }
async function PageLayout({ title, organizationSlug }: PageLayoutProps) { function PageLayout({ title }: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
return ( 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="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> </div>
</> </>
); );
} }
const Loading = ({ title }: PageLayoutProps) => ( export default PageLayout;
<>
<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);

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

View File

@@ -18,12 +18,12 @@ const MostEvents = ({ data }: Props) => {
{data.slice(0, 5).map((item) => ( {data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2"> <div key={item.name} className="relative px-3 py-2">
<div <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={{ style={{
width: `${(item.count / max) * 100}%`, width: `${(item.count / max) * 100}%`,
}} }}
></div> ></div>
<div className="relative flex justify-between text-sm"> <div className="relative flex justify-between ">
<div>{item.name}</div> <div>{item.name}</div>
<div>{item.count}</div> <div>{item.count}</div>
</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 ClickToCopy from '@/components/click-to-copy';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon'; import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { ProfileAvatar } from '@/components/profiles/profile-avatar'; import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { import { Padding } from '@/components/ui/padding';
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getProfileName } from '@/utils/getters'; import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { parseAsInteger } from 'nuqs';
import type { GetEventListOptions } from '@openpanel/db'; import { getProfileByIdCached } from '@openpanel/db';
import { getProfileById } from '@openpanel/db';
import EventListServer from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import MostEventsServer from './most-events'; import MostEventsServer from './most-events';
import PopularRoutesServer from './popular-routes'; import PopularRoutesServer from './popular-routes';
import ProfileActivityServer from './profile-activity'; import ProfileActivityServer from './profile-activity';
import ProfileCharts from './profile-charts'; import ProfileCharts from './profile-charts';
import Events from './profile-events';
import ProfileMetrics from './profile-metrics'; import ProfileMetrics from './profile-metrics';
interface PageProps { interface PageProps {
@@ -38,66 +30,50 @@ interface PageProps {
} }
export default async function Page({ export default async function Page({
params: { projectId, profileId, organizationSlug }, params: { projectId, profileId },
searchParams,
}: PageProps) { }: PageProps) {
const eventListOptions: GetEventListOptions = { const profile = await getProfileByIdCached(profileId, projectId);
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);
if (!profile) { if (!profile) {
return notFound(); return notFound();
} }
return ( return (
<> <Padding>
<PageLayout organizationSlug={organizationSlug} title={<div />} /> <div className="row mb-4 items-center gap-4">
<StickyBelowHeader className="!relative !top-auto !z-0 flex flex-col gap-8 p-4 md:flex-row md:items-center md:p-8"> <ProfileAvatar {...profile} />
<div className="flex flex-1 gap-4">
<ProfileAvatar {...profile} size={'lg'} />
<div className="min-w-0"> <div className="min-w-0">
<ClickToCopy value={profile.id}> <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"> <h1 className="max-w-full truncate text-3xl font-semibold">
{getProfileName(profile)} {getProfileName(profile)}
</h1> </h1>
</ClickToCopy> </ClickToCopy>
<div className="mt-1 flex items-center gap-4">
<ListPropertiesIcon {...profile.properties} />
</div> </div>
</div> </div>
<div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-6">
<ProfileMetrics projectId={projectId} profile={profile} />
</div> </div>
<ProfileMetrics profileId={profileId} projectId={projectId} /> <div className="col-span-6">
</StickyBelowHeader>
<div className="p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-6">
<div className="col-span-2">
<ProfileActivityServer <ProfileActivityServer
profileId={profileId} profileId={profileId}
projectId={projectId} projectId={projectId}
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-6 md:col-span-3">
<MostEventsServer profileId={profileId} projectId={projectId} /> <MostEventsServer profileId={profileId} projectId={projectId} />
</div> </div>
<div className="col-span-2"> <div className="col-span-6 md:col-span-3">
<PopularRoutesServer profileId={profileId} projectId={projectId} /> <PopularRoutesServer profileId={profileId} projectId={projectId} />
</div> </div>
<ProfileCharts profileId={profileId} projectId={projectId} /> <ProfileCharts profileId={profileId} projectId={projectId} />
</div> </div>
<div className="mt-8"> <div className="mt-8">
<EventListServer {...eventListOptions} /> <Events profileId={profileId} projectId={projectId} />
</div> </div>
</div> </div>
</> </Padding>
); );
} }

View File

@@ -18,12 +18,12 @@ const PopularRoutes = ({ data }: Props) => {
{data.slice(0, 5).map((item) => ( {data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2"> <div key={item.path} className="relative px-3 py-2">
<div <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={{ style={{
width: `${(item.count / max) * 100}%`, width: `${(item.count / max) * 100}%`,
}} }}
></div> ></div>
<div className="relative flex justify-between text-sm"> <div className="relative flex justify-between ">
<div>{item.path}</div> <div>{item.path}</div>
<div>{item.count}</div> <div>{item.count}</div>
</div> </div>

View File

@@ -15,6 +15,7 @@ import {
endOfMonth, endOfMonth,
format, format,
formatISO, formatISO,
isSameMonth,
startOfMonth, startOfMonth,
subMonths, subMonths,
} from 'date-fns'; } from 'date-fns';
@@ -43,19 +44,72 @@ const ProfileActivity = ({ data }: Props) => {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
disabled={isSameMonth(startDate, new Date())}
onClick={() => setStartDate(addMonths(startDate, 1))} onClick={() => setStartDate(addMonths(startDate, 1))}
> >
<ChevronRightIcon size={14} /> <ChevronRightIcon size={14} />
</Button> </Button>
</div> </div>
</WidgetHead> </WidgetHead>
<WidgetBody className="p-0"> <WidgetBody>
<div className="grid grid-cols-2"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div> <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')} {format(subMonths(startDate, 1), 'MMMM yyyy')}
</div> </div>
<div className="grid grid-cols-7 gap-1 p-1"> <div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({ {eachDayOfInterval({
start: startOfMonth(subMonths(startDate, 1)), start: startOfMonth(subMonths(startDate, 1)),
end: endOfMonth(subMonths(startDate, 1)), end: endOfMonth(subMonths(startDate, 1)),
@@ -78,8 +132,8 @@ const ProfileActivity = ({ data }: Props) => {
</div> </div>
</div> </div>
<div> <div>
<div className="p-1 text-xs">{format(startDate, 'MMMM yyyy')}</div> <div className="mb-2 text-sm">{format(startDate, '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({ {eachDayOfInterval({
start: startDate, start: startDate,
end: endDate, end: endDate,

View File

@@ -80,19 +80,19 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
return ( return (
<> <>
<Widget className="col-span-3 w-full"> <Widget className="col-span-6 md:col-span-3">
<WidgetHead> <WidgetHead>
<span className="title">Page views</span> <span className="title">Page views</span>
</WidgetHead> </WidgetHead>
<WidgetBody className="flex gap-2"> <WidgetBody>
<ChartRoot {...pageViewsChart} /> <ChartRoot {...pageViewsChart} />
</WidgetBody> </WidgetBody>
</Widget> </Widget>
<Widget className="col-span-3 w-full"> <Widget className="col-span-6 md:col-span-3">
<WidgetHead> <WidgetHead>
<span className="title">Events per day</span> <span className="title">Events per day</span>
</WidgetHead> </WidgetHead>
<WidgetBody className="flex gap-2"> <WidgetBody>
<ChartRoot {...eventsChart} /> <ChartRoot {...eventsChart} />
</WidgetBody> </WidgetBody>
</Widget> </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 withSuspense from '@/hocs/with-suspense';
import type { IServiceProfile } from '@openpanel/db';
import { getProfileMetrics } from '@openpanel/db'; import { getProfileMetrics } from '@openpanel/db';
import ProfileMetrics from './profile-metrics'; import ProfileMetrics from './profile-metrics';
type Props = { type Props = {
projectId: string; projectId: string;
profileId: string; profile: IServiceProfile;
}; };
const ProfileMetricsServer = async ({ projectId, profileId }: Props) => { const ProfileMetricsServer = async ({ projectId, profile }: Props) => {
const data = await getProfileMetrics(profileId, projectId); const data = await getProfileMetrics(profile.id, projectId);
return <ProfileMetrics data={data} />; return <ProfileMetrics data={data} profile={profile} />;
}; };
export default withSuspense(ProfileMetricsServer, () => null); export default withSuspense(ProfileMetricsServer, () => null);

View File

@@ -1,66 +1,115 @@
'use client'; 'use client';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { useNumber } from '@/hooks/useNumerFormatter'; 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 { formatDistanceToNow } from 'date-fns';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import type { IProfileMetrics } from '@openpanel/db'; import type { IProfileMetrics, IServiceProfile } from '@openpanel/db';
type Props = { type Props = {
data: IProfileMetrics; 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(); const number = useNumber();
return ( return (
<div className="flex flex-wrap gap-6 whitespace-nowrap md:justify-end md:text-right"> <div className="@container">
<div> <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="text-xs font-medium text-muted-foreground"> <div className="col-span-2 @xl:col-span-3 @4xl:col-span-6">
First seen <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>
<div className="text-lg font-semibold"> <div className="grid grid-cols-2 gap-4 p-4">
{formatDistanceToNow(utc(data.firstSeen))} {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>
</div> </div>
<div> <Card
<div className="text-xs font-medium text-muted-foreground"> title="First seen"
Last seen value={formatDistanceToNow(utc(data.firstSeen))}
</div> />
<div className="text-lg font-semibold"> <Card
{formatDistanceToNow(utc(data.lastSeen))} title="Last seen"
</div> value={formatDistanceToNow(utc(data.lastSeen))}
</div> />
<div> <Card title="Sessions" value={number.format(data.sessions)} />
<div className="text-xs font-medium text-muted-foreground"> <Card
Sessions title="Avg. Session"
</div> value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
<div className="text-lg font-semibold"> />
{number.format(data.sessions)} <Card
</div> title="P90. Session"
</div> value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
<div> />
<div className="text-xs font-medium text-muted-foreground"> <Card title="Page views" value={number.format(data.screenViews)} />
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>
</div> </div>
</div> </div>
); );

View File

@@ -1,50 +1,45 @@
import { Suspense } from 'react'; import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout'; import { Padding } from '@/components/ui/padding';
import { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters'; import { parseAsStringEnum } from 'nuqs';
import { parseAsInteger } from 'nuqs';
import LastActiveUsersServer from '../retention/last-active-users'; import PowerUsers from './power-users';
import ProfileListServer from './profile-list'; import Profiles from './profiles';
import ProfileTopServer from './profile-top';
interface PageProps { interface PageProps {
params: { params: {
organizationSlug: string;
projectId: string; projectId: string;
organizationSlug: string;
}; };
searchParams: { searchParams: Record<string, string>;
f?: string;
cursor?: string;
};
} }
const nuqsOptions = {
shallow: false,
};
export default function Page({ export default function Page({
params: { organizationSlug, projectId }, params: { projectId },
searchParams: { cursor, f }, searchParams,
}: PageProps) { }: PageProps) {
const tab = parseAsStringEnum(['profiles', 'power-users'])
.withDefault('profiles')
.parseServerSide(searchParams.tab);
return ( return (
<> <>
<PageLayout title="Profiles" organizationSlug={organizationSlug} /> <Padding>
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2"> <div className="mb-4">
<ProfileListServer <PageTabs>
projectId={projectId} <PageTabsLink href={`?tab=profiles`} isActive={tab === 'profiles'}>
cursor={parseAsInteger.parseServerSide(cursor ?? '') ?? undefined} Profiles
filters={ </PageTabsLink>
eventQueryFiltersParser.parseServerSide(f ?? '') ?? undefined <PageTabsLink
} href={`?tab=power-users`}
/> isActive={tab === 'power-users'}
<div className="flex flex-col gap-4"> >
<LastActiveUsersServer projectId={projectId} /> Power users
<ProfileTopServer </PageTabsLink>
projectId={projectId} </PageTabs>
organizationSlug={organizationSlug}
/>
</div>
</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"> <div className="flex w-full flex-wrap items-start justify-start">
{res.map(renderItem)} {res.map(renderItem)}
</div> </div>
<div className="text-center text-xs text-muted-foreground">DAYS</div> <div className="text-center text-sm text-muted-foreground">DAYS</div>
</WidgetBody> </WidgetBody>
</Widget> </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 <div
className={cn( className={cn(
'fixed bottom-0 left-0 right-0 top-0', 'fixed bottom-0 left-0 right-0 top-0',
!isFullscreen && 'top-16 lg:left-72' !isFullscreen && 'lg:left-72'
)} )}
ref={ref} ref={ref}
> >
@@ -123,8 +123,8 @@ const Map = ({ markers }: Props) => {
<Geography <Geography
key={geo.rsmKey} key={geo.rsmKey}
geography={geo} geography={geo}
fill={theme.theme === 'dark' ? '#0f0f0f' : '#F0F4F9'} fill={theme.theme === 'dark' ? '#000' : '#e5eef6'}
stroke={theme.theme === 'dark' ? '#262626' : '#DDE3E9'} stroke={theme.theme === 'dark' ? '#333' : '#bcccda'}
pointerEvents={'none'} pointerEvents={'none'}
/> />
)) ))

View File

@@ -18,23 +18,19 @@ type Props = {
projectId: string; projectId: string;
}; };
}; };
export default function Page({ export default function Page({ params: { projectId } }: Props) {
params: { projectId, organizationSlug },
}: Props) {
return ( return (
<> <>
<PageLayout
title={<FullscreenOpen />}
{...{ projectId, organizationSlug }}
/>
<Fullscreen> <Fullscreen>
<FullscreenClose /> <FullscreenClose />
<RealtimeReloader projectId={projectId} /> <RealtimeReloader projectId={projectId} />
<Suspense> <Suspense>
<RealtimeMap projectId={projectId} /> <RealtimeMap projectId={projectId} />
</Suspense> </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} /> <RealtimeLiveHistogram projectId={projectId} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">

View File

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

View File

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

View File

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

View File

@@ -23,15 +23,15 @@ function Tooltip(props: any) {
return null; return null;
} }
return ( 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>
<div className="text-xs text-muted-foreground"> <div className="text-sm text-muted-foreground">
Days since last seen Days since last seen
</div> </div>
<div className="text-lg font-semibold">{payload.days}</div> <div className="text-lg font-semibold">{payload.days}</div>
</div> </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 className="text-lg font-semibold">{payload.users}</div>
</div> </div>
</div> </div>

View File

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

View File

@@ -29,20 +29,20 @@ function Tooltip(props: any) {
return null; return null;
} }
return ( 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="text-xs text-muted-foreground">{payload.date}</div> <div className="text-sm text-muted-foreground">{payload.date}</div>
<div> <div>
<div className="text-xs text-muted-foreground"> <div className="text-sm text-muted-foreground">
Monthly active users Monthly active users
</div> </div>
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div> <div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
</div> </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 className="text-lg font-semibold text-chart-1">{payload.wau}</div>
</div> </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 className="text-lg font-semibold text-chart-0">{payload.dau}</div>
</div> </div>
</div> </div>

View File

@@ -33,20 +33,20 @@ function Tooltip({ payload }: any) {
return null; return null;
} }
return ( 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 className="flex justify-between gap-8">
<div>{formatDate(new Date(date))}</div> <div>{formatDate(new Date(date))}</div>
</div> </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 className="text-lg font-semibold">{active_users}</div>
</div> </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 className="text-lg font-semibold">{retained_users}</div>
</div> </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 className="text-lg font-semibold">{round(retention, 2)}%</div>
</div> </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')} className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
> >
<div <div
className="bg-highlight absolute inset-0 z-0" className="absolute inset-0 z-0 bg-highlight"
style={{ opacity: ratio }} style={{ opacity: ratio }}
></div> ></div>
<div className="relative z-10">{value}</div> <div className="relative z-10">{value}</div>
@@ -76,7 +76,7 @@ const WeeklyCohortsServer = async ({ projectId }: Props) => {
<tbody> <tbody>
{res.map((row) => ( {res.map((row) => (
<tr key={row.first_seen}> <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} {row.first_seen}
</td> </td>
<Cell <Cell

View File

@@ -1,3 +1,5 @@
'use client';
import { InputWithLabel } from '@/components/forms/input-with-label'; import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
@@ -126,7 +128,7 @@ export default function CreateInvite({ projects }: Props) {
value: item.id, 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 Leave empty to give access to all projects
</p> </p>
</div> </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 { getInvites, getProjectsByOrganizationSlug } from '@openpanel/db';
import Invites from './invites'; import CreateInvite from './create-invite';
interface Props { interface Props {
organizationSlug: string; organizationSlug: string;
@@ -12,7 +15,14 @@ const InvitesServer = async ({ organizationSlug }: Props) => {
getProjectsByOrganizationSlug(organizationSlug), getProjectsByOrganizationSlug(organizationSlug),
]); ]);
return <Invites invites={invites} projects={projects} />; return (
<div>
<TableButtons>
<CreateInvite projects={projects} />
</TableButtons>
<InvitesTable data={invites} projects={projects} />
</div>
);
}; };
export default InvitesServer; 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 { interface Props {
organizationSlug: string; organizationSlug: string;
@@ -12,7 +12,7 @@ const MembersServer = async ({ organizationSlug }: Props) => {
getProjectsByOrganizationSlug(organizationSlug), getProjectsByOrganizationSlug(organizationSlug),
]); ]);
return <Members members={members} projects={projects} />; return <MembersTable data={members} projects={projects} />;
}; };
export default MembersServer; 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 PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; 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 { auth } from '@clerk/nextjs/server';
import { ShieldAlertIcon } from 'lucide-react'; import { ShieldAlertIcon } from 'lucide-react';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { parseAsStringEnum } from 'nuqs';
import { db } from '@openpanel/db'; import { db } from '@openpanel/db';
@@ -14,11 +17,16 @@ interface PageProps {
params: { params: {
organizationSlug: string; organizationSlug: string;
}; };
searchParams: Record<string, string>;
} }
export default async function Page({ export default async function Page({
params: { organizationSlug }, params: { organizationSlug },
searchParams,
}: PageProps) { }: PageProps) {
const tab = parseAsStringEnum(['org', 'members', 'invites'])
.withDefault('org')
.parseServerSide(searchParams.tab);
const session = auth(); const session = auth();
const organization = await db.organization.findUnique({ const organization = await db.organization.findUnique({
where: { where: {
@@ -49,30 +57,36 @@ export default async function Page({
const hasAccess = member?.role === 'org:admin'; const hasAccess = member?.role === 'org:admin';
if (!hasAccess) {
return ( 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"> <FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this You do not have access to this page. You need to be an admin of this
organization to access this page. organization to access this page.
</FullPageEmptyState> </FullPageEmptyState>
</> );
}
return (
<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 { auth } from '@clerk/nextjs/server';
import { getUserById } from '@openpanel/db'; import { getUserById } from '@openpanel/db';
import EditProfile from './edit-profile'; import EditProfile from './edit-profile';
import { Logout } from './logout';
interface PageProps { export default async function Page() {
params: {
organizationSlug: string;
};
}
export default async function Page({
params: { organizationSlug },
}: PageProps) {
const { userId } = auth(); const { userId } = auth();
const profile = await getUserById(userId!); const profile = await getUserById(userId!);
return ( return (
<> <Padding>
<PageLayout <h1 className="mb-4 text-2xl font-bold">Profile</h1>
title={profile.lastName}
organizationSlug={organizationSlug}
/>
<div className="flex flex-col gap-4 p-4">
<EditProfile profile={profile} /> <EditProfile profile={profile} />
<Logout /> </Padding>
</div>
</>
); );
} }

View File

@@ -24,24 +24,21 @@ interface ListProjectsProps {
export default function ListProjects({ projects, clients }: ListProjectsProps) { export default function ListProjects({ projects, clients }: ListProjectsProps) {
return ( return (
<> <>
<StickyBelowHeader> <div className="row mb-4 justify-between">
<div className="flex items-center justify-between p-4"> <h1 className="text-2xl font-bold">Projects</h1>
<div />
<Button icon={PlusIcon} onClick={() => pushModal('AddProject')}> <Button icon={PlusIcon} onClick={() => pushModal('AddProject')}>
<span className="max-sm:hidden">Create project</span> <span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span> <span className="sm:hidden">Project</span>
</Button> </Button>
</div> </div>
</StickyBelowHeader>
<div className="p-4">
<div className="card p-4"> <div className="card p-4">
<Alert className="mb-4"> <Alert className="mb-4">
<InfoIcon size={16} /> <InfoIcon size={16} />
<AlertTitle>What is a project</AlertTitle> <AlertTitle>What is a project</AlertTitle>
<AlertDescription> <AlertDescription>
A project can be a website, mobile app or any other application A project can be a website, mobile app or any other application that
that you want to track event for. Each project can have one or you want to track event for. Each project can have one or more
more clients. The client is used to send events to the project. clients. The client is used to send events to the project.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Accordion type="single" collapsible className="-mx-4"> <Accordion type="single" collapsible className="-mx-4">
@@ -111,7 +108,6 @@ export default function ListProjects({ projects, clients }: ListProjectsProps) {
})} })}
</Accordion> </Accordion>
</div> </div>
</div>
</> </>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ const Page = ({ children }: Props) => {
</FullWidthNavbar> </FullWidthNavbar>
<div className="mx-auto w-full md:max-w-[95vw] lg:max-w-[80vw]"> <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="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>
<div className="text-xs font-bold uppercase text-[#7b94ac]"> <div className="text-sm font-bold uppercase text-[#7b94ac]">
Welcome to Openpanel Welcome to Openpanel
</div> </div>
<div className="text-xl font-medium leading-loose"> <div className="text-xl font-medium leading-loose">

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ const ConnectWeb = ({ client }: Props) => {
<MonitorIcon /> <MonitorIcon />
Website Website
</div> </div>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Pick a framework below to get started. Pick a framework below to get started.
</p> </p>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <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" className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
key={framework.name} 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 <img
className="h-full w-full object-contain" className="h-full w-full object-contain"
src={framework.logo} src={framework.logo}
@@ -43,7 +43,7 @@ const ConnectWeb = ({ client }: Props) => {
</button> </button>
))} ))}
</div> </div>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Missing a framework?{' '} Missing a framework?{' '}
<a <a
href="mailto:hello@openpanel.dev" 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"> <div className="flex items-center gap-2 text-2xl capitalize">
{client?.name} {client?.name}
</div> </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()} Connection status: {renderBadge()}
</div> </div>
@@ -81,13 +81,13 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
{isConnected ? ( {isConnected ? (
<div className="flex flex-col-reverse"> <div className="flex flex-col-reverse">
{events.length > 5 && ( {events.length > 5 && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 ">
<CheckIcon size={14} />{' '} <CheckIcon size={14} />{' '}
<span>{events.length - 5} more events</span> <span>{events.length - 5} more events</span>
</div> </div>
)} )}
{events.slice(-5).map((event, index) => ( {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} />{' '} <CheckIcon size={14} />{' '}
<span className="font-medium">{event.name}</span>{' '} <span className="font-medium">{event.name}</span>{' '}
<span className="ml-auto text-emerald-800"> <span className="ml-auto text-emerald-800">
@@ -97,7 +97,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-sm"> <div className="">
Verify that your events works before submitting any changes to App Verify that your events works before submitting any changes to App
Store/Google Play Store/Google Play
</div> </div>
@@ -105,7 +105,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
</div> </div>
</div> </div>
<div className="mt-2 text-xs text-muted-foreground"> <div className="mt-2 text-sm text-muted-foreground">
You can{' '} You can{' '}
<button <button
className="underline" className="underline"

View File

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

View File

@@ -59,9 +59,9 @@ const Steps = ({ className }: Props) => {
const currentIndex = steps.findIndex((i) => i.status === 'current'); const currentIndex = steps.findIndex((i) => i.status === 'current');
return ( return (
<div className="relative"> <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 <div
className="bg-highlight absolute left-4 top-4 w-px" className="absolute left-4 top-4 w-px bg-highlight"
style={{ style={{
height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`, height: `calc(${((currentIndex + 1) / steps.length) * 100}% - 3.5rem)`,
}} }}
@@ -86,17 +86,17 @@ const Steps = ({ className }: Props) => {
> >
<div <div
className={cn( 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 <div
className={cn( 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' step.status === 'pending' && 'bg-def-400'
)} )}
></div> ></div>
{step.status === 'current' && ( {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"> <div className="relative">
{step.status === 'completed' && <CheckCheckIcon size={14} />} {step.status === 'completed' && <CheckCheckIcon size={14} />}
@@ -109,7 +109,7 @@ const Steps = ({ className }: Props) => {
</div> </div>
</div> </div>
<div className="text-sm font-medium">{step.name}</div> <div className=" font-medium">{step.name}</div>
</div> </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" href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share"
className="flex flex-col items-end text-lg font-medium" 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> <span>openpanel.dev</span>
</a> </a>
</div> </div>

View File

@@ -6,6 +6,9 @@ import Providers from './providers';
import '@/styles/globals.css'; import '@/styles/globals.css';
import '/node_modules/flag-icons/css/flag-icons.min.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 = { export const metadata = {
title: 'Overview - Openpanel.dev', title: 'Overview - Openpanel.dev',
}; };
@@ -25,7 +28,11 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <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 <NextTopLoader
showSpinner={false} showSpinner={false}

View File

@@ -16,7 +16,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
<div className="w-full"> <div className="w-full">
<CopyInput label="Secret" value={secret} /> <CopyInput label="Secret" value={secret} />
{cors && ( {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. You will only need the secret if you want to send server events.
</p> </p>
)} )}
@@ -25,7 +25,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
{cors && ( {cors && (
<div className="text-left"> <div className="text-left">
<Label>CORS settings</Label> <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} {cors}
</div> </div>
</div> </div>

View File

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

View File

@@ -7,7 +7,7 @@ export function ColorSquare({ children, className }: ColorSquareProps) {
return ( return (
<div <div
className={cn( 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 className
)} )}
> >

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { cn } from '@/utils/cn';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@@ -7,20 +8,27 @@ import {
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { import { Grid, GridBody, GridCell, GridHeader, GridRow } from './grid-table';
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
interface DataTableProps<TData> { interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[]; columns: ColumnDef<TData, any>[];
data: TData[]; 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>) { export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -29,47 +37,45 @@ export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
}); });
return ( return (
<Table> <Grid columns={columns.length}>
<TableHeader> <GridHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <GridRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => (
return ( <GridCell key={header.id} isHeader>
<TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext()
)} )}
</TableHead> </GridCell>
);
})}
</TableRow>
))} ))}
</TableHeader> </GridRow>
<TableBody> ))}
</GridHeader>
<GridBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <GridRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <GridCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </GridCell>
))} ))}
</TableRow> </GridRow>
)) ))
) : ( ) : (
<TableRow> <GridRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <GridCell colSpan={columns.length}>
No results. <div className="h-24 text-center">No results.</div>
</TableCell> </GridCell>
</TableRow> </GridRow>
)} )}
</TableBody> </GridBody>
</Table> </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> <div>
<label <label
className={cn( 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' disabled && 'cursor-not-allowed opacity-50'
)} )}
htmlFor={id} htmlFor={id}
@@ -32,8 +32,8 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>} {Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
<div className="flex-1"> <div className="flex-1">
<div className="font-medium">{label}</div> <div className="font-medium">{label}</div>
<div className="text-sm text-muted-foreground">{description}</div> <div className=" text-muted-foreground">{description}</div>
{error && <div className="text-xs text-red-600">{error}</div>} {error && <div className="text-sm text-red-600">{error}</div>}
</div> </div>
<div> <div>
<Switch <Switch

View File

@@ -17,7 +17,7 @@ const CopyInput = ({ label, value, className }: Props) => {
onClick={() => clipboard(value)} onClick={() => clipboard(value)}
> >
{!!label && <Label>{label}</Label>} {!!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} {value}
<CopyIcon size={16} /> <CopyIcon size={16} />
</div> </div>

View File

@@ -38,7 +38,7 @@ export const WithLabel = ({
</Label> </Label>
{error && ( {error && (
<Tooltiper asChild content={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 Issues
<BanIcon size={14} /> <BanIcon size={14} />
</div> </div>

View File

@@ -112,7 +112,7 @@ const TagInput = ({
data-tag={tag} data-tag={tag}
key={tag} key={tag}
className={cn( 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 && isMarkedForDeletion &&
i === value.length - 1 && i === value.length - 1 &&
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1', 'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
@@ -136,7 +136,7 @@ const TagInput = ({
<input <input
ref={inputRef} ref={inputRef}
placeholder={`${placeholder}`} 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} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View File

@@ -7,6 +7,7 @@ import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs'; import { parseAsBoolean, useQueryState } from 'nuqs';
import { useDebounce } from 'usehooks-ts'; import { useDebounce } from 'usehooks-ts';
import { Button } from './ui/button';
import { Tooltiper } from './ui/tooltip'; import { Tooltiper } from './ui/tooltip';
type Props = { type Props = {
@@ -37,18 +38,21 @@ export const Fullscreen = (props: Props) => {
}; };
export const FullscreenOpen = () => { export const FullscreenOpen = () => {
const [, setIsFullscreen] = useFullscreen(); const [fullscreen, setIsFullscreen] = useFullscreen();
if (fullscreen) {
return null;
}
return ( return (
<Tooltiper content="Toggle fullscreen" asChild> <Tooltiper content="Toggle fullscreen" asChild>
<button <Button
className="flex items-center gap-2" variant="outline"
size="icon"
onClick={() => { onClick={() => {
setIsFullscreen((p) => !p); setIsFullscreen((p) => !p);
}} }}
> >
<FullscreenIcon /> <FullscreenIcon className="size-4" />
Realtime </Button>
</button>
</Tooltiper> </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); const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null; if (filters.length === 0 && events.length === 0) return null;
return ( 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) => ( {events.map((event) => (
<Button <Button
key={event} key={event}
@@ -32,7 +32,7 @@ export function OverviewFiltersButtons({
icon={X} icon={X}
onClick={() => setEvents((p) => p.filter((e) => e !== event))} onClick={() => setEvents((p) => p.filter((e) => e !== event))}
> >
<strong>{event}</strong> <strong className="font-semibold">{event}</strong>
</Button> </Button>
))} ))}
{filters.map((filter) => { {filters.map((filter) => {
@@ -49,7 +49,7 @@ export function OverviewFiltersButtons({
onClick={() => setFilter(filter.name, filter.value[0], 'is')} onClick={() => setFilter(filter.name, filter.value[0], 'is')}
> >
<span className="mr-1">{getPropertyLabel(filter.name)} is</span> <span className="mr-1">{getPropertyLabel(filter.name)} is</span>
<strong>{filter.value[0]}</strong> <strong className="font-semibold">{filter.value[0]}</strong>
</Button> </Button>
); );
})} })}

View File

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

View File

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

View File

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

View File

@@ -131,7 +131,7 @@ interface WrapperProps {
function Wrapper({ children, count }: WrapperProps) { function Wrapper({ children, count }: WrapperProps) {
return ( return (
<div className="flex h-full flex-col"> <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 {count} unique vistors last 30 minutes
</div> </div>
<div className="relative flex h-full w-full flex-1 items-end gap-1"> <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 ( return (
<> <>
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 md:m-0"> <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"> <div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
{reports.map((report, index) => ( {reports.map((report, index) => (
<button <button
key={index} key={index}

View File

@@ -32,7 +32,7 @@ const OverviewTopBots = ({ projectId }: Props) => {
<> <>
<div className="-m-4"> <div className="-m-4">
<WidgetTable <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} data={data}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
columns={[ columns={[

View File

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

View File

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

View File

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

View File

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

View File

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

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