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,25 +53,28 @@ 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">
<OverviewReportRange /> <h1 className="text-3xl font-semibold">{dashboard.name}</h1>
<Button <div className="flex items-center justify-end gap-2">
icon={PlusIcon} <OverviewReportRange />
onClick={() => { <Button
router.push( icon={PlusIcon}
`/${params.organizationSlug}/${ onClick={() => {
params.projectId router.push(
}/reports?${new URLSearchParams({ `/${params.organizationSlug}/${
dashboardId: params.dashboardId, params.projectId
}).toString()}` }/reports?${new URLSearchParams({
); dashboardId: params.dashboardId,
}} }).toString()}`
> );
<span className="max-sm:hidden">Create report</span> }}
<span className="sm:hidden">Report</span> >
</Button> <span className="max-sm:hidden">Create report</span>
</StickyBelowHeader> <span className="sm:hidden">Report</span>
<div className="mx-auto flex max-w-3xl flex-col gap-8 p-4 md:p-8"> </Button>
</div>
</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,23 +4,19 @@ 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={() => { pushModal('AddDashboard');
pushModal('AddDashboard'); }}
}} >
> <span className="max-sm:hidden">Create dashboard</span>
<span className="max-sm:hidden">Create dashboard</span> <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>
<div className="flex flex-col gap-4"> {tab === 'events' && <Events projectId={projectId} />}
<EventsPerDayChart {tab === 'conversions' && <Conversions projectId={projectId} />}
projectId={projectId} {tab === 'charts' && <Charts projectId={projectId} />}
events={eventNames} </Padding>
filters={filters}
/>
<EventConversionsListServer projectId={projectId} />
</div>
</div>
</> </>
); );
} }

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,60 +99,40 @@ 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"> <div className="mt-4">
<LinkWithIcon <div className="mb-2 flex items-center justify-between">
icon={BuildingIcon} <div className="text-muted-foreground">Your dashboards</div>
label="Organization" <Button
href={`/${params.organizationSlug}/${projectId}/settings/organization`} size="icon"
/> variant="ghost"
<LinkWithIcon className="text-muted-foreground"
icon={WarehouseIcon} onClick={() => pushModal('AddDashboard')}
label="Projects" >
href={`/${params.organizationSlug}/${projectId}/settings/projects`} <PlusIcon size={16} />
/> </Button>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationSlug}/${projectId}/settings/profile`}
/>
<LinkWithIcon
icon={BookmarkIcon}
label="References"
href={`/${params.organizationSlug}/${projectId}/settings/references`}
/>
</div> </div>
)} <div className="flex flex-col gap-2">
{dashboards.length > 0 && ( {dashboards.map((item) => (
<div className="mt-8"> <LinkWithIcon
<div className="mb-2 text-sm font-medium">Your dashboards</div> key={item.id}
<div className="flex flex-col gap-2"> icon={LayoutPanelTopIcon}
{dashboards.map((item) => ( label={item.name}
<LinkWithIcon href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
key={item.id} />
icon={LayoutPanelTopIcon} ))}
label={item.name}
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
/>
))}
</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);
const changeProject = (newProjectId: string) => {
if (organizationSlug && projectId) {
const split = pathname
.replace(
`/${organizationSlug}/${projectId}`,
`/${organizationSlug}/${newProjectId}`
)
.split('/');
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx
router.push(split.slice(0, 4).join('/'));
} else {
router.push(`/${organizationSlug}/${newProjectId}`);
}
};
const changeOrganization = (newOrganizationId: string) => {
router.push(`/${newOrganizationId}`);
};
return ( return (
<div> <DropdownMenu open={open} onOpenChange={setOpen}>
<Combobox <DropdownMenuTrigger asChild>
portal <Button
align="end" size={'sm'}
className="w-auto min-w-0 max-sm:max-w-[100px]" variant="outline"
placeholder={'Select project'} role="combobox"
onChange={(value) => { aria-expanded={open}
if (organizationSlug && projectId) { className="flex min-w-0 flex-1 items-center justify-start"
const split = pathname.replace(projectId, value).split('/'); >
// slicing here will remove everything after /{orgId}/{projectId}/dashboards [slice here] /xxx/xxx/xxx <Building2Icon size={16} className="shrink-0" />
router.push(split.slice(0, 4).join('/')); <span className="mx-2 truncate">
} else { {projectId
router.push(`/${organizationSlug}/${value}`); ? projects.find((p) => p.id === projectId)?.name
} : 'Select project'}
}} </span>
value={projectId} <ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
items={ </Button>
projects.map((item) => ({ </DropdownMenuTrigger>
label: item.name, <DropdownMenuContent align={align} className="w-[200px]">
value: item.id, <DropdownMenuLabel>Projects</DropdownMenuLabel>
})) ?? [] <DropdownMenuGroup>
} {projects.map((project) => (
/> <DropdownMenuItem
</div> 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"> <div className="min-w-0">
<ProfileAvatar {...profile} size={'lg'} /> <ClickToCopy value={profile.id}>
<div className="min-w-0"> <h1 className="max-w-full truncate text-3xl font-semibold">
<ClickToCopy value={profile.id}> {getProfileName(profile)}
<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>
{getProfileName(profile)} </ClickToCopy>
</h1>
</ClickToCopy>
<div className="mt-1 flex items-center gap-4">
<ListPropertiesIcon {...profile.properties} />
</div>
</div>
</div> </div>
<ProfileMetrics profileId={profileId} projectId={projectId} /> </div>
</StickyBelowHeader> <div>
<div className="p-4"> <div className="grid grid-cols-6 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-6"> <div className="col-span-6">
<div className="col-span-2"> <ProfileMetrics projectId={projectId} profile={profile} />
</div>
<div className="col-span-6">
<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">
</div> <button
<div className="text-lg font-semibold"> onClick={() => setTab('profile')}
{formatDistanceToNow(utc(data.firstSeen))} className={cn(
</div> 'p-4',
</div> 'opacity-50',
<div> tab === 'profile' &&
<div className="text-xs font-medium text-muted-foreground"> 'border-b border-foreground text-foreground opacity-100'
Last seen )}
</div> >
<div className="text-lg font-semibold"> Profile
{formatDistanceToNow(utc(data.lastSeen))} </button>
</div> <div className="h-full w-px bg-border" />
</div> <button
<div> onClick={() => setTab('properties')}
<div className="text-xs font-medium text-muted-foreground"> className={cn(
Sessions 'p-4',
</div> 'opacity-50',
<div className="text-lg font-semibold"> tab === 'properties' &&
{number.format(data.sessions)} 'border-b border-foreground text-foreground opacity-100'
</div> )}
</div> >
<div> Properties
<div className="text-xs font-medium text-muted-foreground"> </button>
Avg. Session </div>
</div> <div className="grid grid-cols-2 gap-4 p-4">
<div className="text-lg font-semibold"> {tab === 'profile' && (
{number.formatWithUnit(data.durationAvg / 1000, 'min')} <>
</div> <Info title="ID" value={profile.id} />
</div> <Info title="First name" value={profile.firstName} />
<div> <Info title="Last name" value={profile.lastName} />
<div className="text-xs font-medium text-muted-foreground"> <Info title="Email" value={profile.email} />
P90. Session <Info
</div> title="Updated"
<div className="text-lg font-semibold"> value={formatDateTime(new Date(profile.createdAt))}
{number.formatWithUnit(data.durationP90 / 1000, 'min')} />
</div> <ListPropertiesIcon {...profile.properties} />
</div> </>
<div> )}
<div className="text-xs font-medium text-muted-foreground"> {tab === 'properties' && (
Page views <>
</div> {Object.entries(profile.properties)
<div className="text-lg font-semibold"> .filter(([key, value]) => value !== undefined)
{number.format(data.screenViews)} .map(([key, value]) => (
<Info key={key} title={key} value={value as string} />
))}
</>
)}
</div>
</div> </div>
<Card
title="First seen"
value={formatDistanceToNow(utc(data.firstSeen))}
/>
<Card
title="Last seen"
value={formatDistanceToNow(utc(data.lastSeen))}
/>
<Card title="Sessions" value={number.format(data.sessions)} />
<Card
title="Avg. Session"
value={number.formatWithUnit(data.durationAvg / 1000, 'min')}
/>
<Card
title="P90. Session"
value={number.formatWithUnit(data.durationP90 / 1000, 'min')}
/>
<Card title="Page views" value={number.format(data.screenViews)} />
</div> </div>
</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 (
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
);
}
return ( return (
<> <Padding>
<PageLayout <PageTabs className="mb-4">
title={organization.name} <PageTabsLink href={`?tab=org`} isActive={tab === 'org'}>
organizationSlug={organizationSlug} Organization
/> </PageTabsLink>
{hasAccess ? ( <PageTabsLink href={`?tab=members`} isActive={tab === 'members'}>
<div className="grid gap-8 p-4 lg:grid-cols-2"> Members
<EditOrganization organization={organization} /> </PageTabsLink>
<div className="col-span-2"> <PageTabsLink href={`?tab=invites`} isActive={tab === 'invites'}>
<MembersServer organizationSlug={organizationSlug} /> Invites
</div> </PageTabsLink>
<div className="col-span-2"> </PageTabs>
<InvitesServer organizationSlug={organizationSlug} />
</div> {tab === 'org' && <EditOrganization organization={organization} />}
</div> {tab === 'members' && (
) : ( <MembersServer organizationSlug={organizationSlug} />
<>
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
</>
)} )}
</> {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} <EditProfile profile={profile} />
organizationSlug={organizationSlug} </Padding>
/>
<div className="flex flex-col gap-4 p-4">
<EditProfile profile={profile} />
<Logout />
</div>
</>
); );
} }

View File

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

View File

@@ -1,5 +1,3 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { getReferences } from '@openpanel/db'; import { 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() )}
)} </GridCell>
</TableHead> ))}
); </GridRow>
})}
</TableRow>
))} ))}
</TableHeader> </GridHeader>
<TableBody> <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