migrate to app dir and ssr

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-20 22:54:38 +01:00
parent 719a82f1c4
commit 308ae98472
194 changed files with 4706 additions and 2194 deletions

View File

@@ -17,10 +17,23 @@ Mixan is a simple analytics tool for logging events on web and react-native. My
### Speed/Benchmark
As of today (2023-12-12) I have more then 1.2 million events in PSQL and performance is smooth as butter 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`.
As of today (~~2023-12-12~~ 2024-01-16) I have more then ~~1.2~~ 2.8 million events and 20 thousand profiles in postgres and performance is smooth as butter\* 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`.
\* Smooth as butter is somewhat exaggerated but I would say it still fast! It takes 1.4 sec to search through all events (3 million) with advanced where clause. I think this performance is absolutly good enough.
### GUI
- [x] Fix design for report editor
- [x] Fix profiles
- [x] Pagination
- [x] Filter by event name
- [x] Fix [profileId]
- [x] Add events
- [x] Improve design for properties and linked profiles
- [x] New design for events
- [ ] Map events to convertions
- [ ] Map ids
- [x] Fix menu links when projectId is undefined
- [x] Fix tables on settings
- [x] Rename event label
- [ ] Common web dashboard
@@ -36,14 +49,16 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform
- [x] View events in a list
- [x] Simple filters
- [x] View profiles in a list
- [ ] Invite users
- [x] Invite users
- [ ] Drag n Drop reports on dashboard
- [x] Manage dashboards
- [ ] Support more chart types
- [x] Support more chart types
- [x] Bar
- [x] Histogram
- [ ] Pie
- [ ] Area
- [x] Pie
- [x] Area
- [x] Metric
- [x] Line
- [ ] Support funnels
- [ ] Support multiple breakdowns
- [x] Aggregations (sum, average...)

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "pnpm with-env next dev",
"dev": "rm -rf .next && pnpm with-env next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
@@ -40,16 +40,19 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"hamburger-react": "^2.5.0",
"lodash.debounce": "^4.0.8",
"lottie-react": "^2.4.0",
"lucide-react": "^0.286.0",
"mitt": "^3.0.1",
"next": "13.4",
"next": "~14.0.4",
"next-auth": "^4.23.0",
"nuqs": "^1.15.2",
"prisma-error-enum": "^0.1.3",
"ramda": "^0.29.1",
"random-animal-name": "^0.1.1",
"react": "18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "18.2.0",
"react-hook-form": "^7.47.0",
"react-in-viewport": "1.0.0-alpha.30",
@@ -93,8 +96,8 @@
"root": true,
"extends": [
"@mixan/eslint-config/base",
"@mixan/eslint-config/nextjs",
"@mixan/eslint-config/react"
"@mixan/eslint-config/react",
"@mixan/eslint-config/nextjs"
]
},
"prettier": "@mixan/prettier-config"

15
apps/web/public/logo.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="824" height="824" fill="#007BFF" fill-opacity="0.3"/>
<path d="M0 0L824 0V824H0L0 0Z" fill="url(#paint0_linear_0_131)"/>
<path d="M436 220H508C520.73 220 532.939 225.057 541.941 234.059C550.943 243.061 556 255.27 556 268V604" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M172 604H244" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 604H652" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M364 412V412.24" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 233.488V621.256C435.999 624.902 435.168 628.499 433.569 631.775C431.97 635.052 429.646 637.92 426.772 640.164C423.899 642.408 420.553 643.968 416.987 644.726C413.421 645.483 409.729 645.418 406.192 644.536L244 604V257.488C244.002 246.784 247.581 236.388 254.169 227.952C260.757 219.516 269.976 213.524 280.36 210.928L376.36 186.928C383.434 185.16 390.818 185.026 397.951 186.538C405.084 188.05 411.779 191.167 417.528 195.652C423.277 200.138 427.928 205.874 431.129 212.426C434.329 218.978 435.995 226.196 436 233.488Z" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_0_131" x1="353" y1="93.0001" x2="528" y2="747.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#1D54CD"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import type { getReportsByDashboardId } from '@/server/services/reports.service';
import type { IChartRange } from '@/types';
import { cn } from '@/utils/cn';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
}
export function ListReports({ reports }: ListReportsProps) {
const router = useRouter();
const params = useAppParams<{ dashboardId: string }>();
const [range, setRange] = useState<null | IChartRange>(null);
return (
<>
<StickyBelowHeader className="p-4 items-center justify-between flex">
<Combobox
className="min-w-0"
placeholder="Override range"
value={range}
onChange={(value) => {
setRange((p) => (p === value ? null : value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/>
<Button
icon={PlusIcon}
onClick={() => {
router.push(
`/${params.organizationId}/${
params.projectId
}/reports?${new URLSearchParams({
dashboardId: params.dashboardId,
}).toString()}`
);
}}
>
<span className="max-sm:hidden">Create report</span>
<span className="sm:hidden">Report</span>
</Button>
</StickyBelowHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4">
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
return (
<div
className="rounded-md border border-border bg-white shadow"
key={report.id}
>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"
shallow
>
<div>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span className={range !== null ? 'line-through' : ''}>
{chartRange}
</span>
{range !== null && <span>{range}</span>}
</div>
)}
</div>
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger className="h-8 w-8 hover:border rounded justify-center items-center flex">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
// event.stopPropagation();
// deletion.mutate({
// reportId: report.id,
// });
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
className="opacity-10 transition-opacity"
size={16}
/>
</div>
</Link>
<div
className={cn(
'p-4 pl-2',
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
)}
>
<LazyChart
{...report}
range={range ?? report.range}
interval={
range ? getDefaultIntervalByRange(range) : report.interval
}
editMode={false}
/>
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
import PageLayout from '@/app/(app)/page-layout';
import { getSession } from '@/server/auth';
import {
createRecentDashboard,
getDashboardById,
} from '@/server/services/dashboard.service';
import { getReportsByDashboardId } from '@/server/services/reports.service';
import { revalidateTag } from 'next/cache';
import { ListReports } from './list-reports';
interface PageProps {
params: {
organizationId: string;
projectId: string;
dashboardId: string;
};
}
export default async function Page({
params: { organizationId, projectId, dashboardId },
}: PageProps) {
const session = await getSession();
const dashboard = await getDashboardById(dashboardId);
const reports = await getReportsByDashboardId(dashboardId);
const userId = session?.user.id;
if (userId && dashboard) {
await createRecentDashboard({
userId,
organizationId,
projectId,
dashboardId,
});
revalidateTag(`recentDashboards__${userId}`);
}
return (
<PageLayout title={dashboard.name} organizationId={organizationId}>
<ListReports reports={reports} />
</PageLayout>
);
}

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react';
const variants = cva('flex items-center justify-center shrink-0', {
variants: {
size: {
sm: 'w-6 h-6 rounded',
default: 'w-12 h-12 rounded-xl',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
className?: string;
};
const records = {
default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' },
screen_view: {
Icon: MonitorPlayIcon,
text: 'text-chart-3',
bg: 'bg-chart-3/10',
},
session_start: {
Icon: ActivityIcon,
text: 'text-chart-2',
bg: 'bg-chart-2/10',
},
};
export function EventIcon({ className, name, size }: EventIconProps) {
const { Icon, text, bg } =
name in records ? records[name as keyof typeof records] : records.default;
return (
<div className={cn(variants({ size }), bg, className)}>
<Icon size={20} className={text} />
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { useMemo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import { Activity, BotIcon, MonitorPlay } from 'lucide-react';
import Link from 'next/link';
import { EventIcon } from './event-icon';
type EventListItemProps = RouterOutputs['event']['list'][number];
export function EventListItem({
profile,
createdAt,
name,
properties,
}: EventListItemProps) {
const params = useAppParams();
const bullets = useMemo(() => {
const bullets: React.ReactNode[] = [
<span>{formatDateTime(createdAt)}</span>,
];
if (profile) {
bullets.push(
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${profile.id}`}
className="flex items-center gap-1 text-black font-medium hover:underline"
>
<ProfileAvatar size="xs" {...(profile ?? {})}></ProfileAvatar>
{getProfileName(profile)}
</Link>
);
}
if (typeof properties.duration === 'number') {
bullets.push(`${round(properties.duration / 1000, 1)}s`);
}
switch (name) {
case 'screen_view': {
const route = (properties?.route || properties?.path) as string;
if (route) {
bullets.push(route);
}
break;
}
}
return bullets;
}, [name, createdAt, profile, properties, params]);
return (
<ExpandableListItem
title={name.split('_').join(' ')}
bullets={bullets}
image={<EventIcon name={name} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useMemo, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { EventListItem } from './event-list-item';
interface ListEventsProps {
projectId: string;
}
export function ListEvents({ projectId }: ListEventsProps) {
const pagination = usePagination();
const [eventFilters, setEventFilters] = useState<string[]>([]);
const eventsQuery = api.event.list.useQuery(
{
events: eventFilters,
projectId: projectId,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const filterEventsQuery = api.chart.events.useQuery({
projectId: projectId,
});
const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<div>
<ComboboxAdvanced
items={filterEvents}
value={eventFilters}
onChange={setEventFilters}
placeholder="Filter by event"
/>
</div>
</StickyBelowHeader>
<div className="p-4">
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,19 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListEvents } from './list-events';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({
params: { organizationId, projectId },
}: PageProps) {
return (
<PageLayout title="Events" organizationId={organizationId}>
<ListEvents projectId={projectId} />
</PageLayout>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
interface HeaderDashboardsProps {
projectId: string;
}
export function HeaderDashboards({ projectId }: HeaderDashboardsProps) {
return (
<StickyBelowHeader>
<div className="p-4 flex justify-between items-center">
<div />
<Button
icon={PlusIcon}
onClick={() => {
pushModal('AddDashboard', {
projectId,
});
}}
>
<span className="max-sm:hidden">Create dashboard</span>
<span className="sm:hidden">Dashboard</span>
</Button>
</div>
</StickyBelowHeader>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { api, handleError, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { ToastAction } from '@/components/ui/toast';
import { toast } from '@/components/ui/use-toast';
import { pushModal } from '@/modals';
import type { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { Pencil, Plus, Trash } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
interface ListDashboardsProps {
dashboards: Awaited<ReturnType<typeof getDashboardsByProjectId>>;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useParams();
const { organizationId, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: (
<ToastAction
altText="Force delete"
onClick={() => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
}}
>
Force delete
</ToastAction>
),
})(error);
},
onSuccess() {
router.refresh();
toast({
title: 'Success',
description: 'Dashboard deleted.',
});
},
});
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{dashboards.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${projectId}/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground text-sm">
{item.project.name}
</span>
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="text-destructive w-full" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
import PageLayout from '@/app/(app)/page-layout';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<PageLayout title="Dashboards" organizationId={organizationId}>
<HeaderDashboards projectId={projectId} />
<ListDashboards dashboards={dashboards} />
</PageLayout>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { useEventNames } from '@/hooks/useEventNames';
import { parseAsJson, useQueryState } from 'nuqs';
import { EventListItem } from '../../events/event-list-item';
interface ListProfileEvents {
projectId: string;
profileId: string;
}
export default function ListProfileEvents({
projectId,
profileId,
}: ListProfileEvents) {
const pagination = usePagination();
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsJson<string[]>().withDefault([])
);
const eventNames = useEventNames(projectId);
const eventsQuery = api.event.list.useQuery(
{
projectId,
profileId,
events: eventFilters,
...pagination,
},
{
keepPreviousData: true,
}
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<div className="flex items-center justify-between mb-4">
<ComboboxAdvanced
placeholder="Filter events"
items={eventNames}
value={eventFilters}
onChange={setEventFilters}
/>
</div>
<div className="flex flex-col gap-4">
{events.map((item) => (
<EventListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
);
}

View File

@@ -0,0 +1,87 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListProperties } from '@/components/events/ListProperties';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Avatar } from '@/components/ui/avatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import {
getProfileById,
getProfilesByExternalId,
} from '@/server/services/profile.service';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import ListProfileEvents from './list-profile-events';
interface PageProps {
params: {
organizationId: string;
projectId: string;
profileId: string;
};
}
export default async function Page({
params: { organizationId, projectId, profileId },
}: PageProps) {
const profile = await getProfileById(profileId);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
return (
<PageLayout
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</div>
}
organizationId={organizationId}
>
<div className="p-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
<Widget>
<WidgetHead>
<span className="title">Properties</span>
</WidgetHead>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</Widget>
<Widget>
<WidgetHead>
<span className="title">Linked profile</span>
</WidgetHead>
{profiles.length > 0 ? (
<div className="flex flex-col gap-4">
{profiles.map((profile) => (
<div key={profile.id} className="border-b border-border">
<WidgetBody className="flex gap-4">
<ProfileAvatar {...profile} />
<div>
<div className="font-medium mt-1">
{getProfileName(profile)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
<span>{profile.id}</span>
<span>{formatDateTime(profile.createdAt)}</span>
</div>
</div>
</WidgetBody>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</div>
))}
</div>
) : (
<div className="p-4">No linked profiles</div>
)}
</Widget>
</div>
<ListProfileEvents projectId={projectId} profileId={profileId} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input';
import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
organizationId: string;
}
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const eventsQuery = api.profile.list.useQuery(
{
projectId,
query,
...pagination,
},
{
keepPreviousData: true,
}
);
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<Input
placeholder="Search by name"
value={query ?? ''}
onChange={(event) => setQuery(event.target.value || null)}
/>
</StickyBelowHeader>
<div className="p-4">
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,19 @@
import PageLayout from '@/app/(app)/page-layout';
import { ListProfiles } from './list-profiles';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({
params: { organizationId, projectId },
}: PageProps) {
return (
<PageLayout title="Events" organizationId={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
</PageLayout>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useMemo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { useAppParams } from '@/hooks/useAppParams';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const bullets = useMemo(() => {
const bullets: React.ReactNode[] = [
<span>{formatDateTime(createdAt)}</span>,
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>,
];
return bullets;
}, [createdAt, id, params]);
return (
<ExpandableListItem
title={getProfileName(props)}
bullets={bullets}
image={<ProfileAvatar {...props} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,32 @@
import PageLayout from '@/app/(app)/page-layout';
import { getReportById } from '@/server/services/reports.service';
import { Pencil } from 'lucide-react';
import ReportEditor from '../report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
reportId: string;
};
}
export default async function Page({
params: { organizationId, reportId },
}: PageProps) {
const report = await getReportById(reportId);
return (
<PageLayout
title={
<div className="flex gap-2 items-center cursor-pointer">
{report.name}
<Pencil size={16} />
</div>
}
organizationId={organizationId}
>
<ReportEditor report={report} />
</PageLayout>
);
}

View File

@@ -0,0 +1,27 @@
import PageLayout from '@/app/(app)/page-layout';
import { Pencil } from 'lucide-react';
import ReportEditor from './report-editor';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout
title={
<div className="flex gap-2 items-center cursor-pointer">
Unnamed report
<Pencil size={16} />
</div>
}
organizationId={organizationId}
>
<ReportEditor reportId={null} />
</PageLayout>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useEffect, useRef } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeDateRanges,
ready,
reset,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useDispatch, useSelector } from '@/redux';
import type { IServiceReport } from '@/server/services/reports.service';
import { timeRanges } from '@/utils/constants';
import { GanttChartSquareIcon } from 'lucide-react';
interface ReportEditorProps {
report: IServiceReport | null;
}
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
// Set report if reportId exists
useEffect(() => {
if (initialReport) {
dispatch(setReport(initialReport));
} else {
dispatch(ready());
}
return () => {
dispatch(reset());
};
}, [initialReport, dispatch]);
return (
<Sheet>
<StickyBelowHeader className="p-4 grid grid-cols-2 gap-2 md:grid-cols-6">
<SheetTrigger asChild>
<div>
<Button icon={GanttChartSquareIcon} variant="cta">
Pick events
</Button>
</div>
</SheetTrigger>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
<Combobox
className="min-w-0 flex-1"
placeholder="Range"
value={report.range}
onChange={(value) => {
dispatch(changeDateRanges(value));
}}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportChartType className="min-w-0 flex-1" />
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
<ReportSaveButton />
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && <Chart {...report} editMode />}
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<ReportSidebar />
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { Card } from '@/components/Card';
import { pushModal } from '@/modals';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { Plus } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export function ListProjects({ projects }: ListProjectsProps) {
const params = useParams();
const organizationId = params.organizationId as string;
return (
<>
<div className="grid sm:grid-cols-2 gap-4 p-4">
{projects.map((item) => (
<Card key={item.id} hover>
<div>
<Link
href={`/${organizationId}/${item.id}`}
className="block p-4 flex flex-col"
>
<span className="font-medium">{item.name}</span>
</Link>
</div>
</Card>
))}
<Card hover className="border-dashed">
<button
className="flex items-center justify-between w-full p-4 font-medium leading-none"
onClick={() => {
pushModal('AddProject', {
organizationId,
});
}}
>
Create new project
<Plus size={16} />
</button>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,17 @@
import { getFirstProjectByOrganizationId } from '@/server/services/project.service';
import { redirect } from 'next/navigation';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const project = await getFirstProjectByOrganizationId(organizationId);
if (project) {
return redirect(`/${organizationId}/${project.id}`);
}
return <p>List projects maybe?</p>;
}

View File

@@ -0,0 +1,38 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { ContentHeader } from '@/components/Content';
import { DataTable } from '@/components/DataTable';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { getClientsByOrganizationId } from '@/server/services/clients.service';
import { KeySquareIcon, PlusIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}
export default function ListClients({ clients }: ListClientsProps) {
const organizationId = useParams().organizationId as string;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() => pushModal('AddClient', { organizationId })}
>
<span className="max-sm:hidden">Create client</span>
<span className="sm:hidden">Client</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={clients} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,20 @@
import PageLayout from '@/app/(app)/page-layout';
import { getClientsByOrganizationId } from '@/server/services/clients.service';
import ListClients from './list-clients';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const clients = await getClientsByOrganizationId(organizationId);
return (
<PageLayout title="Clients" organizationId={organizationId}>
<ListClients clients={clients} />
</PageLayout>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getOrganizationById } from '@/server/services/organization.service';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
id: z.string().min(2),
name: z.string().min(2),
});
type IForm = z.infer<typeof validator>;
interface EditOrganizationProps {
organization: Awaited<ReturnType<typeof getOrganizationById>>;
}
export default function EditOrganization({
organization,
}: EditOrganizationProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: organization,
});
const mutation = api.organization.update.useMutation({
onSuccess(res) {
toast({
title: 'Organization updated',
description: 'Your organization has been updated.',
});
reset(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Name"
{...register('name')}
defaultValue={organization.name}
/>
</WidgetBody>
</Widget>
</form>
);
}
// <ContentSection
// title="Invite user"
// text="Invite users to this organization. You can invite several users with (,)"
// >
// </ContentSection>

View File

@@ -0,0 +1,111 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvite } from '@/server/services/user.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
email: z.string().email(),
});
type IForm = z.infer<typeof validator>;
interface InvitedUsersProps {
invites: IServiceInvite[];
organizationId: string;
}
export default function InvitedUsers({
invites,
organizationId,
}: InvitedUsersProps) {
const router = useRouter();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
email: '',
},
});
const mutation = api.user.invite.useMutation({
onSuccess() {
toast({
title: 'User invited',
description: "We have sent an invitation to the user's email",
});
reset();
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
organizationId,
});
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Invite
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Accepted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.accepted ? 'Yes' : 'No'}</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,26 @@
import PageLayout from '@/app/(app)/page-layout';
import { getOrganizationById } from '@/server/services/organization.service';
import { getInvitesByOrganizationId } from '@/server/services/user.service';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationById(organizationId);
const invites = await getInvitesByOrganizationId(organizationId);
return (
<PageLayout title={organization.name} organizationId={organizationId}>
<div className="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} organizationId={organizationId} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1 @@
export { default } from './organization/page';

View File

@@ -0,0 +1,81 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ContentHeader, ContentSection } from '@/components/Content';
import { InputError } from '@/components/forms/InputError';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getOrganizationById } from '@/server/services/organization.service';
import type { getProfileById } from '@/server/services/profile.service';
import type { getUserById } from '@/server/services/user.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
name: z.string().min(2),
email: z.string().email(),
});
type IForm = z.infer<typeof validator>;
interface EditProfileProps {
profile: Awaited<ReturnType<typeof getUserById>>;
}
export default function EditProfile({ profile }: EditProfileProps) {
const router = useRouter();
const { register, handleSubmit, reset, formState } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
name: profile.name ?? '',
email: profile.email ?? '',
},
});
const mutation = api.user.update.useMutation({
onSuccess(res) {
toast({
title: 'Profile updated',
description: 'Your profile has been updated.',
});
reset(res);
router.refresh();
},
onError: handleError,
});
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Your profile</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
Save
</Button>
</WidgetHead>
<WidgetBody className="flex flex-col gap-4">
<InputWithLabel
label="Name"
placeholder="Your name"
defaultValue={profile.name ?? ''}
{...register('name')}
/>
<InputWithLabel
label="Email"
placeholder="Your email"
defaultValue={profile.email ?? ''}
{...register('email')}
/>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { signOut } from 'next-auth/react';
export function Logout() {
return (
<Widget className="border-destructive">
<WidgetHead className="border-destructive">
<span className="title text-destructive">Sad part</span>
</WidgetHead>
<WidgetBody>
<p className="mb-4">
Sometime&apos;s you need to go. See you next time
</p>
<Button
variant={'destructive'}
onClick={() => {
signOut();
}}
>
Logout 🤨
</Button>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,26 @@
import PageLayout from '@/app/(app)/page-layout';
import { getSession } from '@/server/auth';
import { getUserById } from '@/server/services/user.service';
import EditProfile from './edit-profile';
import { Logout } from './logout';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const session = await getSession();
const profile = await getUserById(session?.user.id!);
return (
<PageLayout title={profile.name} organizationId={organizationId}>
<div className="p-4 flex flex-col gap-4">
<EditProfile profile={profile} />
<Logout />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { PlusIcon, WarehouseIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
}
export default function ListProjects({ projects }: ListProjectsProps) {
const organizationId = useParams().organizationId as string;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() =>
pushModal('AddProject', {
organizationId,
})
}
>
<span className="max-sm:hidden">Create project</span>
<span className="sm:hidden">Project</span>
</Button>
</div>
</StickyBelowHeader>
<div className="p-4">
<DataTable data={projects} columns={columns} />
</div>
</>
);
}

View File

@@ -0,0 +1,20 @@
import PageLayout from '@/app/(app)/page-layout';
import { getProjectsByOrganizationId } from '@/server/services/project.service';
import ListProjects from './list-projects';
interface PageProps {
params: {
organizationId: string;
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const projects = await getProjectsByOrganizationId(organizationId);
return (
<PageLayout title="Projects" organizationId={organizationId}>
<ListProjects projects={projects} />
</PageLayout>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
import {
BuildingIcon,
CogIcon,
GanttChartIcon,
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UsersIcon,
WarehouseIcon,
} from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
function LinkWithIcon({
href,
icon: Icon,
label,
}: {
href: string;
icon: React.ElementType<LucideProps>;
label: React.ReactNode;
}) {
return (
<Link
className="flex gap-2 items-center px-3 py-3 transition-colors hover:bg-slate-100 leading-none rounded-lg"
href={href}
>
<Icon size={20} />
{label}
</Link>
);
}
interface LayoutMenuProps {
recentDashboards: IServiceRecentDashboards;
fallbackProjectId: string | null;
}
export default function LayoutMenu({
recentDashboards,
fallbackProjectId,
}: LayoutMenuProps) {
const pathname = usePathname();
const params = useParams();
const projectId = (
!params.projectId || params.projectId === 'undefined'
? fallbackProjectId
: params.projectId
) as string | null;
return (
<>
<LinkWithIcon
icon={LayoutPanelTopIcon}
label="Dashboards"
href={`/${params.organizationId}/${projectId}`}
/>
<LinkWithIcon
icon={GanttChartIcon}
label="Events"
href={`/${params.organizationId}/${projectId}/events`}
/>
<LinkWithIcon
icon={UsersIcon}
label="Profiles"
href={`/${params.organizationId}/${projectId}/profiles`}
/>
<LinkWithIcon
icon={CogIcon}
label="Settings"
href={`/${params.organizationId}/settings/organization`}
/>
{pathname.includes('/settings/') && (
<div className="pl-7">
<LinkWithIcon
icon={BuildingIcon}
label="Organization"
href={`/${params.organizationId}/settings/organization`}
/>
<LinkWithIcon
icon={WarehouseIcon}
label="Projects"
href={`/${params.organizationId}/settings/projects`}
/>
<LinkWithIcon
icon={KeySquareIcon}
label="Clients"
href={`/${params.organizationId}/settings/clients`}
/>
<LinkWithIcon
icon={UserIcon}
label="Profile (yours)"
href={`/${params.organizationId}/settings/profile`}
/>
</div>
)}
{recentDashboards.length > 0 && (
<div className="mt-8">
<div className="font-medium mb-2">Recent dashboards</div>
{recentDashboards.map((item) => (
<LinkWithIcon
key={item.id}
icon={LayoutPanelTopIcon}
label={
<div className="flex flex-col">
<span>{item.dashboard.name}</span>
<span className="text-xs text-muted-foreground">
{item.project.name}
</span>
</div>
}
href={`/${item.organization_id}/${item.project_id}/${item.dashboard_id}`}
/>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import type { IServiceOrganization } from '@/server/services/organization.service';
import { Building } from 'lucide-react';
import { useParams } from 'next/navigation';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
}
export default function LayoutOrganizationSelector({
organizations,
}: LayoutOrganizationSelectorProps) {
const params = useParams();
const organization = organizations.find(
(item) => item.id === params.organizationId
);
if (!organization) {
return null;
}
return (
<div className="border border-border p-3 flex gap-2 rounded items-center">
<Building size={20} />
<span className="font-medium text-sm">{organization.name}</span>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { useParams, usePathname, useRouter } from 'next/navigation';
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
organizationId: string | null;
}
export default function LayoutProjectSelector({
projects,
organizationId,
}: LayoutProjectSelectorProps) {
const router = useRouter();
const params = useParams<{ projectId: string }>();
const projectId = params?.projectId ? params.projectId : null;
const pathname = usePathname() || '';
return (
<div>
<Combobox
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {
// If we are on a page with only organizationId and projectId (as params)
// we know its safe to just replace the current projectId
// since the rest of the url is to a static page
// e.g. /[organizationId]/[projectId]/events
if (params && projectId && Object.keys(params).length === 2) {
router.push(pathname.replace(projectId, value));
} else {
router.push(`/${organizationId}/${value}`);
}
}}
value={projectId}
items={
projects.map((item) => ({
label: item.name,
value: item.id,
})) ?? []
}
/>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import type { IServiceRecentDashboards } from '@/server/services/dashboard.service';
import type { IServiceOrganization } from '@/server/services/organization.service';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import LayoutMenu from './layout-menu';
import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps {
recentDashboards: IServiceRecentDashboards;
organizations: IServiceOrganization[];
}
export function LayoutSidebar({
organizations,
recentDashboards,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const fallbackProjectId = recentDashboards[0]?.project_id ?? null;
const pathname = usePathname();
useEffect(() => {
setActive(false);
}, [pathname]);
return (
<>
<button
onClick={() => setActive(false)}
className={cn(
'fixed top-0 left-0 right-0 bottom-0 backdrop-blur-sm z-30 transition-opacity',
active
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
)}
/>
<div
className={cn(
'fixed top-0 left-0 h-screen border-r border-border w-72 bg-white flex flex-col z-30 transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0' // force active on mobile
)}
>
<div className="absolute -right-12 h-16 flex items-center lg:hidden">
<Hamburger toggled={active} onToggle={setActive} size={20} />
</div>
<div className="h-16 border-b border-border px-4 shrink-0 flex items-center">
<Link href="/">
<Logo />
</Link>
</div>
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu
recentDashboards={recentDashboards}
fallbackProjectId={fallbackProjectId}
/>
{/* Placeholder for LayoutOrganizationSelector */}
<div className="h-16 block shrink-0"></div>
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
<div className="bg-white p-4 pt-0">
<LayoutOrganizationSelector organizations={organizations} />
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { cn } from '@/utils/cn';
interface StickyBelowHeaderProps {
children: React.ReactNode;
className?: string;
}
export function StickyBelowHeader({
children,
className,
}: StickyBelowHeaderProps) {
return (
<div
className={cn(
'md:sticky top-16 bg-white border-b border-border z-10',
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { getSession } from '@/server/auth';
import { getRecentDashboardsByUserId } from '@/server/services/dashboard.service';
import { getOrganizations } from '@/server/services/organization.service';
import { LayoutSidebar } from './layout-sidebar';
interface AppLayoutProps {
children: React.ReactNode;
}
export default async function AppLayout({ children }: AppLayoutProps) {
const session = await getSession();
const organizations = await getOrganizations();
const recentDashboards = session?.user.id
? await getRecentDashboardsByUserId(session?.user.id)
: [];
return (
<div>
<LayoutSidebar {...{ organizations, recentDashboards }} />
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
interface ListOrganizationsProps {
organizations: any[];
}
export function ListOrganizations({ organizations }: ListOrganizationsProps) {
return (
<>
<div className="flex flex-col gap-4 -mx-6">
{organizations.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className="block px-6 py-3 flex items-center justify-between border-b border-border last:border-b-0 hover:bg-slate-100"
>
<span className="font-medium">{item.name}</span>
<ChevronRight size={20} />
</Link>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { getProjectsByOrganizationId } from '@/server/services/project.service';
import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationId: string | null;
}
export default async function PageLayout({
children,
title,
organizationId,
}: PageLayoutProps) {
const projects = organizationId
? await getProjectsByOrganizationId(organizationId)
: [];
return (
<>
<div className="h-16 border-b border-border flex-shrink-0 sticky top-0 bg-white px-4 flex items-center justify-between z-20 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
{projects.length > 0 && (
<LayoutProjectSelector
projects={projects}
organizationId={organizationId}
/>
)}
</div>
<div>{children}</div>
</>
);
}

View File

@@ -0,0 +1,26 @@
import { ModalWrapper } from '@/modals';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { redirect } from 'next/navigation';
import { db } from '@mixan/db';
import { ListOrganizations } from './list-organizations';
export default async function Page() {
const organizations = await db.organization.findMany();
if (organizations.length === 1 && organizations[0]?.id) {
redirect(`/${organizations[0].id}`);
}
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-[rgba(0,0,0,0.2)] z-50">
<ModalWrapper>
<ModalContent>
<ModalHeader title="Select organization" onClose={false} />
<ListOrganizations organizations={organizations} />
</ModalContent>
</ModalWrapper>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { Toast } from '@/components/ui/use-toast';
import { toast } from '@/components/ui/use-toast';
import type { AppRouter } from '@/server/api/root';
import type { TRPCClientErrorBase } from '@trpc/react-query';
import { createTRPCReact } from '@trpc/react-query';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
export const api = createTRPCReact<AppRouter>({});
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export type IChartData = RouterOutputs['chart']['chart'];
export function handleError(error: TRPCClientErrorBase<any>) {
toast({
title: 'Error',
description: error.message,
});
}
export function handleErrorToastOptions(options: Toast) {
return function (error: TRPCClientErrorBase<any>) {
toast({
title: 'Error',
description: error.message,
...options,
});
};
}

View File

@@ -0,0 +1,6 @@
import { authOptions } from '@/server/auth';
import NextAuth from 'next-auth/next';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,20 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; // defaults to auto
export function GET(req: Request) {
const qwe = new URL(req.url);
const item = qwe.searchParams.entries();
const {
value: [key, value],
} = item.next();
if (key && value) {
cookies().set(`@mixan-${key}`, JSON.stringify(value), {
httpOnly: true,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
}
return NextResponse.json({ key, value });
}

View File

@@ -0,0 +1,10 @@
import { createRecentDashboard } from '@/server/services/dashboard.service';
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; // defaults to auto
export async function POST(req: Request) {
await createRecentDashboard(await req.json());
revalidatePath('/', 'layout');
return NextResponse.json({ ok: 'qe' });
}

View File

@@ -1,9 +1,15 @@
import { randomUUID } from 'crypto';
import { db } from '@/server/db';
import { db, getId } from '@/server/db';
import { handleError } from '@/server/exceptions';
import { hashPassword } from '@/server/services/hash.service';
import type { NextApiRequest, NextApiResponse } from 'next';
const userName = 'Admin';
const userPassword = 'password';
const userEmail = 'acme@acme.com';
const organizationName = 'Acme Inc.';
const projectName = 'Website';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
@@ -22,29 +28,31 @@ export default async function handler(
const organization = await db.organization.create({
data: {
name: 'Acme Inc.',
id: await getId('organization', organizationName),
name: organizationName,
},
});
const user = await db.user.create({
data: {
name: 'Carl',
password: await hashPassword('password'),
email: 'lindesvard@gmail.com',
name: userName,
password: await hashPassword(userPassword),
email: userEmail,
organization_id: organization.id,
},
});
const project = await db.project.create({
data: {
name: 'Acme Website',
id: await getId('project', projectName),
name: projectName,
organization_id: organization.id,
},
});
const secret = randomUUID();
const client = await db.client.create({
data: {
name: 'Acme Website Client',
name: `${projectName} Client`,
project_id: project.id,
organization_id: organization.id,
secret: await hashPassword(secret),
@@ -54,6 +62,7 @@ export default async function handler(
res.json({
clientId: client.id,
clientSecret: secret,
user,
});
} catch (error) {
handleError(res, error);

View File

@@ -0,0 +1,18 @@
import { appRouter } from '@/server/api/root';
import { getSession } from '@/server/auth';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => {
const session = await getSession();
return {
session,
};
},
});
export { handler as GET, handler as POST };

87
apps/web/src/app/auth.tsx Normal file
View File

@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Logo } from '@/components/Logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody } from '@/components/Widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { KeySquareIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const validator = z.object({
email: z.string().email(),
password: z.string().min(6),
});
type IForm = z.infer<typeof validator>;
export default function Auth() {
const form = useForm<IForm>({
resolver: zodResolver(validator),
});
const router = useRouter();
const pathname = usePathname();
const [state, setState] = useState<string | null>(null);
return (
<div className="flex items-center justify-center flex-col h-screen p-4">
<Widget className="max-w-md w-full mb-4">
<WidgetBody>
<div className="flex justify-center py-8">
<Logo />
</div>
<form
onSubmit={form.handleSubmit(async (values) => {
const res = await signIn('credentials', {
email: values.email,
password: values.password,
redirect: false,
}).catch(() => {
setState('Something went wrong. Please try again later');
});
if (res?.ok) {
router.refresh();
}
if (res?.status === 401) {
setState('Wrong email or password. Please try again');
}
})}
className="flex flex-col gap-4"
>
<InputWithLabel
label="Email"
placeholder="Your email"
error={form.formState.errors.email?.message}
{...form.register('email')}
/>
<InputWithLabel
label="Password"
placeholder="...and your password"
error={form.formState.errors.password?.message}
{...form.register('password')}
/>
{state !== null && (
<Alert variant="destructive">
<KeySquareIcon className="h-4 w-4" />
<AlertTitle>Failed</AlertTitle>
<AlertDescription>{state}</AlertDescription>
</Alert>
)}
<Button type="submit">Sign in</Button>
<Link href="/register" className="text-center text-sm">
No account?{' '}
<span className="font-medium text-blue-600">Sign up here!</span>
</Link>
</form>
</WidgetBody>
</Widget>
<p className="text-xs">Terms & conditions</p>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react';
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
type ICookies = Record<string, string | null>;
const context = createContext<ICookies>({});
export const CookieProvider = ({
value,
children,
}: {
children: React.ReactNode;
value: RequestCookie[];
}) => {
const cookies = value.reduce((acc, cookie) => {
return {
...acc,
[cookie.name]: cookie.value,
};
}, {} as ICookies);
return <context.Provider value={cookies}>{children}</context.Provider>;
};
export const useCookies = (): ICookies => useContext(context);

View File

@@ -0,0 +1,52 @@
import { cn } from '@/utils/cn';
import { Space_Grotesk } from 'next/font/google';
import Providers from './providers';
import '@/styles/globals.css';
import { getSession } from '@/server/auth';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import Auth from './auth';
// import { cookies } from 'next/headers';
const font = Space_Grotesk({
subsets: ['latin'],
display: 'swap',
variable: '--text',
});
export const metadata = {};
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: 1,
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
return (
<html lang="en" className="light">
<body
className={cn(
'min-h-screen font-sans antialiased grainy bg-slate-50',
font.className
)}
>
<Providers cookies={cookies().getAll()} session={session}>
{session ? children : <Auth />}
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import React, { useRef, useState } from 'react';
import { api } from '@/app/_trpc/client';
import { Toaster } from '@/components/ui/toaster';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ModalProvider } from '@/modals';
import type { AppStore } from '@/redux';
import makeStore from '@/redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { Provider as ReduxProvider } from 'react-redux';
import superjson from 'superjson';
import { CookieProvider } from './cookie-provider';
export default function Providers({
children,
session,
cookies,
}: {
children: React.ReactNode;
session: Session | null;
cookies: RequestCookie[];
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: true,
refetchOnWindowFocus: false,
},
},
})
);
const [trpcClient] = useState(() =>
api.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
})
);
const storeRef = useRef<AppStore>();
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore();
}
return (
<SessionProvider session={session}>
<ReduxProvider store={storeRef.current}>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
<CookieProvider value={cookies}>{children}</CookieProvider>
<Toaster />
<ModalProvider />
</TooltipProvider>
</QueryClientProvider>
</api.Provider>
</ReduxProvider>
</SessionProvider>
);
}

View File

@@ -1,3 +1,5 @@
'use client';
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,7 +19,7 @@ export function Card({ children, hover, className }: CardProps) {
return (
<div
className={cn(
'border border-border rounded relative',
'border border-border rounded relative bg-white',
hover && 'transition-all hover:shadow hover:border-black',
className
)}

View File

@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/utils/cn';
interface ContentHeaderProps {

View File

@@ -1,3 +1,5 @@
'use client';
import {
flexRender,
getCoreRowModel,

View File

@@ -0,0 +1,8 @@
export function Logo() {
return (
<div className="text-xl flex gap-2 items-center">
<img src="/logo.svg" className="w-8 rounded-lg" />
<span className="relative -top-0.5">openpanel</span>
</div>
);
}

View File

@@ -12,6 +12,7 @@ export function usePagination(take = 100) {
take,
canPrev: skip > 0,
canNext: true,
page: skip / take + 1,
}),
[skip, setSkip, take]
);
@@ -21,7 +22,8 @@ export type PaginationProps = ReturnType<typeof usePagination>;
export function Pagination(props: PaginationProps) {
return (
<div className="flex select-none items-center justify-end space-x-2 py-4">
<div className="flex select-none items-center justify-end gap-2">
<div className="font-medium text-xs">Page: {props.page}</div>
<Button
variant="outline"
size="sm"

View File

@@ -1,3 +1,5 @@
'use client';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/docco';

View File

@@ -0,0 +1,43 @@
import { cn } from '@/utils/cn';
interface WidgetHeadProps {
children: React.ReactNode;
className?: string;
}
export function WidgetHead({ children, className }: WidgetHeadProps) {
return (
<div
className={cn(
'p-4 border-b border-border [&_.title]:font-medium',
className
)}
>
{children}
</div>
);
}
interface WidgetBodyProps {
children: React.ReactNode;
className?: string;
}
export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>;
}
interface WidgetProps {
children: React.ReactNode;
className?: string;
}
export function Widget({ children, className }: WidgetProps) {
return (
<div
className={cn(
'border border-border rounded-md bg-white self-start',
className
)}
>
{children}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useRefetchActive } from '@/hooks/useRefetchActive';
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import type { IClientWithProject } from '@/types';
import { api } from '@/utils/api';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '../ui/button';
import {
@@ -16,15 +18,16 @@ import {
} from '../ui/dropdown-menu';
import { toast } from '../ui/use-toast';
export function ClientActions({ id }: IClientWithProject) {
const refetch = useRefetchActive();
export function ClientActions(client: IClientWithProject) {
const { id } = client;
const router = useRouter();
const deletion = api.client.remove.useMutation({
onSuccess() {
toast({
title: 'Success',
description: 'Client revoked, incoming requests will be rejected.',
});
refetch();
router.refresh();
},
});
return (
@@ -42,7 +45,7 @@ export function ClientActions({ id }: IClientWithProject) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditClient', { id });
pushModal('EditClient', client);
}}
>
Edit

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import { DataTable } from '@/components/DataTable';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import type { RouterOutputs } from '@/utils/api';
import { formatDateTime } from '@/utils/date';
import { toDots } from '@/utils/object';
import { AvatarImage } from '@radix-ui/react-avatar';
import { createColumnHelper } from '@tanstack/react-table';
import Link from 'next/link';
const columnHelper =
createColumnHelper<RouterOutputs['event']['list'][number]>();
interface EventsTableProps {
data: RouterOutputs['event']['list'];
}
export function EventsTable({ data }: EventsTableProps) {
const params = useOrganizationParams();
const columns = useMemo(() => {
return [
columnHelper.accessor((row) => row.createdAt, {
id: 'createdAt',
header: () => 'Created At',
cell(info) {
return formatDateTime(info.getValue());
},
footer: () => 'Created At',
}),
columnHelper.accessor((row) => row.name, {
id: 'event',
header: () => 'Event',
cell(info) {
return <span className="font-medium">{info.getValue()}</span>;
},
footer: () => 'Created At',
}),
columnHelper.accessor((row) => row.profile, {
id: 'profile',
header: () => 'Profile',
cell(info) {
const profile = info.getValue();
return (
<Link
shallow
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
className="flex items-center gap-2"
>
<Avatar className="h-6 w-6">
{profile?.avatar && <AvatarImage src={profile.avatar} />}
<AvatarFallback className="text-xs">
{profile?.first_name?.at(0)}
</AvatarFallback>
</Avatar>
{`${profile?.first_name} ${profile?.last_name ?? ''}`}
</Link>
);
},
footer: () => 'Created At',
}),
columnHelper.accessor((row) => row.properties, {
id: 'properties',
header: () => 'Properties',
cell(info) {
const dots = toDots(info.getValue() as Record<string, any>);
return (
<Table className="mini">
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === 'boolean'
? dots[key]
? 'true'
: 'false'
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
},
footer: () => 'Created At',
}),
];
}, [params]);
return <DataTable data={data} columns={columns} />;
}

View File

@@ -0,0 +1,35 @@
import { toDots } from '@/utils/object';
import { Table, TableBody, TableCell, TableRow } from '../ui/table';
interface ListPropertiesProps {
data: any;
className?: string;
}
export function ListProperties({
data,
className = 'mini',
}: ListPropertiesProps) {
const dots = toDots(data);
return (
<Table className={className}>
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === 'boolean'
? dots[key]
? 'true'
: 'false'
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

View File

@@ -6,15 +6,23 @@ import { Label } from '../ui/label';
type InputWithLabelProps = InputProps & {
label: string;
error?: string | undefined;
};
export const InputWithLabel = forwardRef<HTMLInputElement, InputWithLabelProps>(
({ label, className, ...props }, ref) => {
return (
<div className={className}>
<Label htmlFor={label} className="block mb-2">
{label}
</Label>
<div className="block mb-2 flex justify-between">
<Label className="mb-0" htmlFor={label}>
{label}
</Label>
{props.error && (
<span className="text-sm text-destructive leading-none">
{props.error}
</span>
)}
</div>
<Input ref={ref} id={label} {...props} />
</div>
);

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { cn } from '@/utils/cn';
import { ChevronUp } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import { Button } from '../ui/button';
interface ExpandableListItemProps {
children: React.ReactNode;
bullets: React.ReactNode[];
title: string;
image?: React.ReactNode;
initialOpen?: boolean;
}
export function ExpandableListItem({
title,
bullets,
image,
initialOpen = false,
children,
}: ExpandableListItemProps) {
const [open, setOpen] = useState(initialOpen ?? false);
return (
<div className="bg-white shadow rounded-xl overflow-hidden">
<div className="p-3 sm:p-6 flex gap-4 items-start">
<div className="flex gap-1">{image}</div>
<div className="flex flex-col flex-1 gap-1 min-w-0">
<span className="text-lg font-medium leading-none mb-1">{title}</span>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-sm text-muted-foreground">
{bullets.map((bullet) => (
<span key={bullet}>{bullet}</span>
))}
</div>
</div>
<Button
variant="secondary"
size="icon"
onClick={() => setOpen((p) => !p)}
>
<ChevronUp
size={20}
className={cn(
'transition-transform',
open ? 'rotate-180' : 'rotate-0'
)}
/>
</Button>
</div>
<AnimateHeight duration={200} height={open ? 'auto' : 0}>
<div className="border-t border-border">{children}</div>
</AnimateHeight>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { useState } from 'react';
import { cn } from '@/utils/cn';
import { MenuIcon } from 'lucide-react';
import Link from 'next/link';
import { Container } from '../Container';
import { Breadcrumbs } from '../navbar/Breadcrumbs';
import { NavbarMenu } from '../navbar/NavbarMenu';
interface MainLayoutProps {
children: React.ReactNode;
className?: string;
}
export function MainLayout({ children, className }: MainLayoutProps) {
const [visible, setVisible] = useState(false);
return (
<>
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
<nav className="border-b border-border">
<Container className="flex h-20 items-center justify-between ">
<Link shallow href="/" className="text-3xl">
mixan
</Link>
<div
className={cn(
'flex items-center gap-8 z-50',
visible === false && 'max-sm:hidden',
visible === true &&
'max-sm:flex max-sm:flex-col max-sm:absolute max-sm:inset-0 max-sm:bg-white max-sm:justify-center max-sm:top-4 max-sm:shadow-lg'
)}
>
<NavbarMenu />
</div>
<button
className={cn(
'px-4 sm:hidden absolute z-50 top-9 right-4 transition-all',
visible === true && 'rotate-90'
)}
onClick={() => {
setVisible((p) => !p);
}}
>
<MenuIcon />
</button>
</Container>
</nav>
<Breadcrumbs />
<main className={cn(className, 'mb-8')}>{children}</main>
</>
);
}

View File

@@ -1,55 +0,0 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Container } from '../Container';
import { PageTitle } from '../PageTitle';
import { Sidebar, WithSidebar } from '../WithSidebar';
import { MainLayout } from './MainLayout';
interface SettingsLayoutProps {
children: React.ReactNode;
className?: string;
}
export function SettingsLayout({ children, className }: SettingsLayoutProps) {
const params = useOrganizationParams();
const pathname = usePathname();
const links = [
{
href: `/${params.organization}/settings/organization`,
label: 'Organization',
},
{ href: `/${params.organization}/settings/projects`, label: 'Projects' },
{ href: `/${params.organization}/settings/clients`, label: 'Clients' },
{ href: `/${params.organization}/settings/profile`, label: 'Profile' },
];
return (
<MainLayout>
<Container>
<PageTitle>Settings</PageTitle>
<WithSidebar>
<Sidebar>
{links.map(({ href, label }) => (
<Link
shallow
key={href}
href={href}
className={cn(
'p-4 py-3 leading-none rounded-lg transition-colors',
pathname.startsWith(href)
? 'bg-slate-100'
: 'hover:bg-slate-100'
)}
>
{label}
</Link>
))}
</Sidebar>
<div className={cn('flex flex-col', className)}>{children}</div>
</WithSidebar>
</Container>
</MainLayout>
);
}

View File

@@ -1,5 +1,5 @@
import { api } from '@/app/_trpc/client';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { api } from '@/utils/api';
import { ChevronRight, HomeIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -11,30 +11,30 @@ export function Breadcrumbs() {
const org = api.organization.get.useQuery(
{
slug: params.organization,
id: params.organizationId,
},
{
enabled: !!params.organization,
enabled: !!params.organizationId,
staleTime: Infinity,
}
);
const pro = api.project.get.useQuery(
{
slug: params.project,
id: params.projectId,
},
{
enabled: !!params.project,
enabled: !!params.projectId,
staleTime: Infinity,
}
);
const dashboard = api.dashboard.get.useQuery(
{
slug: params.dashboard,
id: params.dashboardId,
},
{
enabled: !!params.dashboard,
enabled: !!params.dashboardId,
staleTime: Infinity,
}
);
@@ -48,7 +48,7 @@ export function Breadcrumbs() {
{org.data && (
<>
<HomeIcon size={14} />
<Link shallow href={`/${org.data.slug}`}>
<Link shallow href={`/${org.data.id}`}>
{org.data.name}
</Link>
</>
@@ -57,7 +57,7 @@ export function Breadcrumbs() {
{org.data && pro.data && (
<>
<ChevronRight size={10} />
<Link shallow href={`/${org.data.slug}/${pro.data.slug}`}>
<Link shallow href={`/${org.data.id}/${pro.data.id}`}>
{pro.data.name}
</Link>
</>
@@ -68,7 +68,7 @@ export function Breadcrumbs() {
<ChevronRight size={10} />
<Link
shallow
href={`/${org.data.slug}/${pro.data.slug}/${dashboard.data.slug}`}
href={`/${org.data.id}/${pro.data.id}/${dashboard.data.id}`}
>
{dashboard.data.name}
</Link>

View File

@@ -25,7 +25,7 @@ export function NavbarCreate() {
<DropdownMenuItem asChild>
<Link
shallow
href={`/${params.organization}/${params.project}/reports`}
href={`/${params.organizationId}/${params.projectId}/reports`}
>
<LineChart className="mr-2 h-4 w-4" />
<span>Create a report</span>

View File

@@ -26,27 +26,27 @@ export function NavbarMenu() {
const params = useOrganizationParams();
return (
<div className={cn('flex gap-1 items-center text-sm', 'max-sm:flex-col')}>
{params.project && (
<Item href={`/${params.organization}/${params.project}`}>
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}`}>
Dashboards
</Item>
)}
{params.project && (
<Item href={`/${params.organization}/${params.project}/events`}>
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}/events`}>
Events
</Item>
)}
{params.project && (
<Item href={`/${params.organization}/${params.project}/profiles`}>
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}/profiles`}>
Profiles
</Item>
)}
{params.project && (
{params.projectId && (
<Item
href={{
pathname: `/${params.organization}/${params.project}/reports`,
pathname: `/${params.organizationId}/${params.projectId}/reports`,
query: strip({
dashboard: params.dashboard,
dashboardId: params.dashboardId,
}),
}}
>

View File

@@ -28,7 +28,7 @@ export function NavbarUserDropdown() {
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link
href={`/${params.organization}/settings/organization`}
href={`/${params.organizationId}/settings/organization`}
shallow
>
<User className="mr-2 h-4 w-4" />
@@ -36,19 +36,19 @@ export function NavbarUserDropdown() {
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/projects`} shallow>
<Link href={`/${params.organizationId}/settings/projects`} shallow>
<User className="mr-2 h-4 w-4" />
Projects
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/clients`} shallow>
<Link href={`/${params.organizationId}/settings/clients`} shallow>
<User className="mr-2 h-4 w-4" />
Clients
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/profile`} shallow>
<Link href={`/${params.organizationId}/settings/profile`} shallow>
<User className="mr-2 h-4 w-4" />
Profile
</Link>

View File

@@ -0,0 +1,53 @@
'use client';
import type { IServiceProfile } from '@/server/services/profile.service';
import { cn } from '@/utils/cn';
import { AvatarImage } from '@radix-ui/react-avatar';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
extends VariantProps<typeof variants>,
Partial<Pick<IServiceProfile, 'avatar' | 'first_name'>> {
className?: string;
}
const variants = cva('', {
variants: {
size: {
default: 'h-12 w-12 rounded-xl [&>span]:rounded-xl',
sm: 'h-6 w-6 rounded [&>span]:rounded',
xs: 'h-4 w-4 rounded [&>span]:rounded',
},
},
defaultVariants: {
size: 'default',
},
});
export function ProfileAvatar({
avatar,
first_name,
className,
size,
}: ProfileAvatarProps) {
return (
<Avatar className={cn(variants({ className, size }), className)}>
{avatar && <AvatarImage src={avatar} />}
<AvatarFallback
className={cn(
size === 'sm'
? 'text-xs'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
'bg-slate-200 text-slate-800'
)}
>
{first_name?.at(0) ?? '🫣'}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -1,9 +1,11 @@
import { useRefetchActive } from '@/hooks/useRefetchActive';
'use client';
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import type { IProject } from '@/types';
import { api } from '@/utils/api';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '../ui/button';
import {
@@ -16,15 +18,16 @@ import {
} from '../ui/dropdown-menu';
import { toast } from '../ui/use-toast';
export function ProjectActions({ id }: IProject) {
const refetch = useRefetchActive();
export function ProjectActions(project: IProject) {
const { id } = project;
const router = useRouter();
const deletion = api.project.remove.useMutation({
onSuccess() {
toast({
title: 'Success',
description: 'Project deleted successfully.',
});
refetch();
router.refresh();
},
});
@@ -43,7 +46,7 @@ export function ProjectActions({ id }: IProject) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
pushModal('EditProject', { id });
pushModal('EditProject', project);
}}
>
Edit

View File

@@ -1,3 +1,4 @@
import { IServiceProject } from '@/server/services/project.service';
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
@@ -6,7 +7,6 @@ import type { Project as IProject } from '@mixan/db';
import { ProjectActions } from './ProjectActions';
export type Project = IProject;
export const columns: ColumnDef<Project>[] = [
{
accessorKey: 'name',

View File

@@ -1,23 +1,27 @@
import { useDispatch, useSelector } from '@/redux';
import type { IChartType } from '@/types';
import { chartTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { Combobox } from '../ui/combobox';
import { changeChartType } from './reportSlice';
export function ReportChartType() {
interface ReportChartTypeProps {
className?: string;
}
export function ReportChartType({ className }: ReportChartTypeProps) {
const dispatch = useDispatch();
const type = useSelector((state) => state.report.chartType);
return (
<Combobox
className={className}
placeholder="Chart type"
onChange={(value) => {
dispatch(changeChartType(value as IChartType));
dispatch(changeChartType(value));
}}
value={type}
items={Object.entries(chartTypes).map(([key, value]) => ({
label: value,
items={objectToZodEnums(chartTypes).map((key) => ({
label: chartTypes[key],
value: key,
}))}
/>

View File

@@ -1,21 +1,28 @@
import { useDispatch, useSelector } from '@/redux';
import type { IInterval } from '@/types';
import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
export function ReportInterval() {
interface ReportIntervalProps {
className?: string;
}
export function ReportInterval({ className }: ReportIntervalProps) {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const chartType = useSelector((state) => state.report.chartType);
if (chartType !== 'linear') {
if (chartType !== 'linear' && chartType !== 'histogram') {
return null;
}
return (
<Combobox
className={className}
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
@@ -30,6 +37,7 @@ export function ReportInterval() {
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',

View File

@@ -0,0 +1,34 @@
import { useDispatch, useSelector } from '@/redux';
import { lineTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { Combobox } from '../ui/combobox';
import { changeLineType } from './reportSlice';
interface ReportLineTypeProps {
className?: string;
}
export function ReportLineType({ className }: ReportLineTypeProps) {
const dispatch = useDispatch();
const chartType = useSelector((state) => state.report.chartType);
const type = useSelector((state) => state.report.lineType);
if (chartType != 'linear' && chartType != 'area') {
return null;
}
return (
<Combobox
className={className}
placeholder="Line type"
onChange={(value) => {
dispatch(changeLineType(value));
}}
value={type}
items={objectToZodEnums(lineTypes).map((key) => ({
label: lineTypes[key],
value: key,
}))}
/>
);
}

View File

@@ -1,15 +1,20 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { api, handleError } from '@/utils/api';
import { SaveIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useReportId } from './hooks/useReportId';
import { resetDirty } from './reportSlice';
export function ReportSaveButton() {
const { reportId } = useReportId();
interface ReportSaveButtonProps {
className?: string;
}
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
const { reportId } = useParams();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
onSuccess() {
@@ -26,11 +31,12 @@ export function ReportSaveButton() {
if (reportId) {
return (
<Button
className={className}
disabled={!report.dirty}
loading={update.isLoading}
onClick={() => {
update.mutate({
reportId,
reportId: reportId as string,
report,
});
}}
@@ -42,6 +48,7 @@ export function ReportSaveButton() {
} else {
return (
<Button
className={className}
disabled={!report.dirty}
onClick={() => {
pushModal('SaveReport', {

View File

@@ -24,6 +24,9 @@ export const ChartAnimationContainer = (
) => (
<div
{...props}
className={cn('border border-border rounded-md p-8', props.className)}
className={cn(
'border border-border rounded-md p-8 bg-white',
props.className
)}
/>
);

View File

@@ -1,3 +1,5 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';

View File

@@ -0,0 +1,104 @@
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportAreaChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportAreaChart({
lineType,
interval,
data,
}: ReportAreaChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(data);
return (
<>
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<AreaChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
data={rechartData}
>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
{series.map((serie) => {
return (
<Area
key={serie.name}
type={lineType}
isAnimationActive={false}
strokeWidth={0}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
fill={getChartColor(serie.index)}
stackId={'1'}
fillOpacity={1}
/>
);
})}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import {
Table,
@@ -8,8 +9,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { IChartData } from '@/types';
import type { RouterOutputs } from '@/utils/api';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
@@ -36,6 +35,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode } = useChartContext();
const [ref, { width }] = useElementSize();
const [sorting, setSorting] = useState<SortingState>([]);
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
const table = useReactTable({
data: useMemo(
() => (editMode ? data.series : data.series.slice(0, 20)),
@@ -57,7 +57,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
footer: (info) => info.column.id,
size: width ? width * 0.3 : undefined,
}),
columnHelper.accessor((row) => row.metrics.total, {
columnHelper.accessor((row) => row.metrics.sum, {
id: 'totalCount',
cell: (info) => (
<div className="text-right font-medium">{info.getValue()}</div>
@@ -67,15 +67,13 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
size: width ? width * 0.1 : undefined,
enableSorting: true,
}),
columnHelper.accessor((row) => row.metrics.total, {
columnHelper.accessor((row) => row.metrics.sum, {
id: 'graph',
cell: (info) => (
<div
className="shine h-4 rounded [.mini_&]:h-3"
style={{
width:
(info.getValue() / info.row.original.meta.highest) * 100 +
'%',
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
@@ -93,30 +91,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
// debugTable: true,
// debugHeaders: true,
// debugColumns: true,
});
return (
<div ref={ref}>
{editMode && (
<div className="mb-8 flex flex-wrap gap-4">
{data.events.map((event) => {
return (
<div className="border border-border p-4" key={event.id}>
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{event.id}</ColorSquare> {event.name}
</div>
<div className="mt-6 font-mono text-5xl font-light">
{new Intl.NumberFormat('en-IN', {
maximumSignificantDigits: 20,
}).format(event.count)}
</div>
</div>
);
})}
</div>
)}
<div className="overflow-x-auto">
<Table
{...{

View File

@@ -2,19 +2,19 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useSelector } from '@/redux';
import type { IToolTipProps } from '@/types';
import { alphabetIds } from '@/utils/constants';
type ReportLineChartTooltipProps = IToolTipProps<{
color: string;
value: number;
dataKey: string;
payload: {
date: Date;
count: number;
label: string;
color: string;
} & Record<string, any>;
}>;
export function ReportLineChartTooltip({
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
@@ -34,31 +34,32 @@ export function ReportLineChartTooltip({
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const first = visible[0]!;
const isBarChart = first.payload.count === undefined;
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
{formatDate(new Date(first.payload.date))}
{visible.map((item, index) => {
const id = alphabetIds[index];
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = item.dataKey.includes(':')
? payload[`${item.dataKey.split(':')[0]}:payload`]
: payload;
return (
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
></div>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{isBarChart
? item.payload[`${id}:label`]
: getLabel(item.payload.label)}
</div>
<div>
{isBarChart ? item.payload[`${id}:count`] : item.payload.count}
<>
{index === 0 && data.date ? formatDate(new Date(data.date)) : null}
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.label)}
</div>
<div>{data.count}</div>
</div>
</div>
</div>
</>
);
})}
{hidden.length > 0 && (

View File

@@ -1,13 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import type { IChartData, IInterval } from '@/types';
import { alphabetIds } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportHistogramChartProps {
@@ -15,80 +18,61 @@ interface ReportHistogramChartProps {
interval: IInterval;
}
function BarHover(props: any) {
const bg = theme?.colors?.slate?.['200'] as string;
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode } = useChartContext();
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const ref = useRef(false);
useEffect(() => {
if (!ref.current && data) {
const max = 20;
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
// ref.current = true;
}
}, [data]);
const rel = data.series[0]?.data.map(({ date }) => {
return {
date,
...data.series.reduce((acc, serie, idx) => {
return {
...acc,
...serie.data.reduce(
(acc2, item) => {
const id = alphabetIds[idx];
if (item.date === date) {
acc2[`${id}:count`] = item.count;
acc2[`${id}:label`] = item.label;
}
return acc2;
},
{} as Record<string, any>
),
};
}, {}),
};
});
const rechartData = useRechartDataModel(data);
return (
<>
<div className="max-sm:-mx-3">
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<BarChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
data={rel}
data={rechartData}
>
<CartesianGrid strokeDasharray="3 3" />
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
{data.series.map((serie, index) => {
const id = alphabetIds[index];
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
/>
{series.map((serie) => {
return (
<>
<YAxis dataKey={`${id}:count`} fontSize={12}></YAxis>
<Bar
stackId={id}
key={serie.name}
isAnimationActive={false}
name={serie.name}
dataKey={`${id}:count`}
fill={getChartColor(index)}
/>
</>
<Bar
stackId={serie.index}
key={serie.name}
name={serie.name}
dataKey={`${serie.index}:count`}
fill={getChartColor(serie.index)}
radius={8}
/>
);
})}
</BarChart>
@@ -98,7 +82,7 @@ export function ReportHistogramChart({
{editMode && (
<ReportTable
data={data}
visibleSeries={visibleSeries}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import type { IChartData, IInterval } from '@/types';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
@@ -12,76 +16,76 @@ import {
YAxis,
} from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportLineChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportLineChart({ interval, data }: ReportLineChartProps) {
export function ReportLineChart({
lineType,
interval,
data,
}: ReportLineChartProps) {
const { editMode } = useChartContext();
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
const formatDate = useFormatDateInterval(interval);
const ref = useRef(false);
useEffect(() => {
if (!ref.current && data) {
const max = 20;
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
// ref.current = true;
}
}, [data]);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
return (
<>
<div className="max-sm:-mx-3">
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<LineChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
data={rechartData}
>
<YAxis dataKey={'count'} fontSize={12}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" />
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => {
return formatDate(m);
}}
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
{data?.series
.filter((serie) => {
return visibleSeries.includes(serie.name);
})
.map((serie) => {
const realIndex = data?.series.findIndex(
(item) => item.name === serie.name
);
const key = serie.name;
const strokeColor = getChartColor(realIndex);
return (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
{series.map((serie) => {
return (
<Line
type={lineType}
key={serie.name}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
name={serie.name}
/>
);
})}
</LineChart>
)}
</AutoSizer>
@@ -89,7 +93,7 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
{editMode && (
<ReportTable
data={data}
visibleSeries={visibleSeries}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}

View File

@@ -0,0 +1,79 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { useChartContext } from './ChartProvider';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
const color = theme?.colors['chart-0'];
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
editMode && 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{series.map((serie) => {
return (
<div
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-50">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop
offset="95%"
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<div className="mt-6 font-mono text-4xl font-light">
{new Intl.NumberFormat('en', {
maximumSignificantDigits: 20,
}).format(serie.metrics.sum)}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportPieChartProps {
data: IChartData;
}
export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
// Get max 10 series and than combine others into one
const pieData = series.map((serie) => {
return {
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
};
});
return (
<>
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => {
const height = Math.min(Math.max(width * 0.5, 250), 400);
return (
<PieChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={height / 4}
outerRadius={height / 2 - 20}
isAnimationActive={false}
>
{pieData.map((item) => {
return (
<Cell
key={item.id}
strokeWidth={2}
stroke={item.color}
fill={item.color}
/>
);
})}
</Pie>
</PieChart>
);
}}
</AutoSizer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,15 +1,20 @@
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Checkbox } from '@/components/ui/checkbox';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useSelector } from '@/redux';
import type { IChartData } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
interface ReportTableProps {
data: IChartData;
visibleSeries: string[];
visibleSeries: IChartData['series'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
@@ -40,31 +45,23 @@ export function ReportTable({
'bg-gray-50 text-emerald-600 font-medium border-r border-border';
return (
<>
<div className="flex w-fit max-w-full rounded-md border border-border">
<div className="flex w-fit max-w-full rounded-md border border-border bg-white">
{/* Labels */}
<div className="border-r border-border">
<div className={cn(header, row, cell)}>Name</div>
{/* <div
className={cn(
'flex max-w-[200px] w-full min-w-full items-center gap-2',
row,
cell
)}
>
<div className="font-medium min-w-0 overflow-scroll whitespace-nowrap scrollbar-hide">
Summary
</div>
</div> */}
{data.series.map((serie, index) => {
const checked = visibleSeries.includes(serie.name);
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
);
return (
<div
key={serie.name}
className={cn(
'flex max-w-[200px] w-full min-w-full items-center gap-2',
'flex max-w-[200px] lg:max-w-[400px] xl:max-w-[600px] w-full min-w-full items-center gap-2',
row,
cell
// avoid using cell since its better on the right side
'p-2'
)}
>
<Checkbox
@@ -81,12 +78,16 @@ export function ReportTable({
}
checked={checked}
/>
<div
title={getLabel(serie.name)}
className="min-w-full overflow-scroll whitespace-nowrap scrollbar-hide"
>
{getLabel(serie.name)}
</div>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
{getLabel(serie.name)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getLabel(serie.name)}</p>
</TooltipContent>
</Tooltip>
</div>
);
})}
@@ -113,7 +114,7 @@ export function ReportTable({
return (
<div className={cn('w-max', row)} key={serie.name}>
<div className={cn(header, value, cell, total)}>
{serie.metrics.total}
{serie.metrics.sum}
</div>
<div className={cn(header, value, cell, total)}>
{serie.metrics.average}
@@ -130,14 +131,22 @@ export function ReportTable({
})}
</div>
</div>
<div className="flex gap-2">
<div>Total</div>
<div>
{data.series.reduce((acc, serie) => serie.metrics.total + acc, 0)}
<div className="flex gap-4">
<div className="flex gap-1">
<div>Total</div>
<div>{data.metrics.sum}</div>
</div>
<div>Average</div>
<div>
{data.series.reduce((acc, serie) => serie.metrics.average + acc, 0)}
<div className="flex gap-1">
<div>Average</div>
<div>{data.metrics.averge}</div>
</div>
<div className="flex gap-1">
<div>Min</div>
<div>{data.metrics.min}</div>
</div>
<div className="flex gap-1">
<div>Max</div>
<div>{data.metrics.max}</div>
</div>
</div>
</>

View File

@@ -0,0 +1,5 @@
import { round } from '@/utils/math';
export function getYAxisWidth(value: number) {
return round(value, 0).toString().length * 7.5 + 7.5;
}

View File

@@ -1,13 +1,18 @@
'use client';
import { memo } from 'react';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { api } from '@/utils/api';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
@@ -19,8 +24,9 @@ export const Chart = memo(
chartType,
name,
range,
lineType,
}: ReportChartProps) {
const params = useOrganizationParams();
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
@@ -29,13 +35,15 @@ export const Chart = memo(
{
interval,
chartType,
// dont send lineType since it does not need to be sent
lineType: 'monotone',
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectSlug: params.project,
projectId: params.projectId,
},
{
keepPreviousData: false,
@@ -97,8 +105,32 @@ export const Chart = memo(
return <ReportBarChart data={chart.data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={chart.data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={chart.data} />;
}
if (chartType === 'linear') {
return <ReportLineChart interval={interval} data={chart.data} />;
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
);
}
if (chartType === 'area') {
return (
<ReportAreaChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
);
}
return (

View File

@@ -1,9 +0,0 @@
import { useQueryParams } from '@/hooks/useQueryParams';
import { z } from 'zod';
export const useReportId = () =>
useQueryParams(
z.object({
reportId: z.string().optional(),
})
);

View File

@@ -2,25 +2,34 @@ import type {
IChartBreakdown,
IChartEvent,
IChartInput,
IChartLineType,
IChartRange,
IChartType,
IInterval,
} from '@/types';
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
import {
alphabetIds,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
type InitialState = IChartInput & {
dirty: boolean;
ready: boolean;
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
ready: false,
dirty: false,
name: 'Untitled',
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
@@ -42,12 +51,19 @@ export const reportSlice = createSlice({
reset() {
return initialState;
},
ready() {
return {
...initialState,
ready: true,
};
},
setReport(state, action: PayloadAction<IChartInput>) {
return {
...action.payload,
startDate: null,
endDate: null,
dirty: false,
ready: true,
};
},
setName(state, action: PayloadAction<string>) {
@@ -132,6 +148,19 @@ export const reportSlice = createSlice({
) {
state.interval = 'hour';
}
if (
!isHourIntervalEnabledByRange(state.range) &&
state.interval === 'hour'
) {
state.interval = 'day';
}
},
// Line type
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
state.dirty = true;
state.lineType = action.payload;
},
// Date range
@@ -149,15 +178,7 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
if (action.payload === '30min' || action.payload === '1h') {
state.interval = 'minute';
} else if (action.payload === 'today' || action.payload === '24h') {
state.interval = 'hour';
} else if (action.payload === '7d' || action.payload === '14d') {
state.interval = 'day';
} else {
state.interval = 'month';
}
state.interval = getDefaultIntervalByRange(action.payload);
},
},
});
@@ -165,6 +186,7 @@ export const reportSlice = createSlice({
// Action creators are generated for each case reducer function
export const {
reset,
ready,
setReport,
setName,
addEvent,
@@ -176,6 +198,7 @@ export const {
changeInterval,
changeDateRanges,
changeChartType,
changeLineType,
resetDirty,
} = reportSlice.actions;

View File

@@ -1,20 +1,22 @@
'use client';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { api } from '@/utils/api';
import { useParams } from 'next/navigation';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const params = useOrganizationParams();
const params = useParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
projectSlug: params.project,
projectId: params.projectId as string,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
@@ -43,6 +45,8 @@ export function ReportBreakdowns() {
<div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare>
<Combobox
className="flex-1"
searchable
value={item.name}
onChange={(value) => {
dispatch(
@@ -63,6 +67,7 @@ export function ReportBreakdowns() {
{selectedBreakdowns.length === 0 && (
<Combobox
searchable
value={''}
onChange={(value) => {
dispatch(

View File

@@ -1,9 +1,9 @@
import type { Dispatch } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxMulti } from '@/components/ui/combobox-multi';
import {
CommandDialog,
CommandEmpty,
@@ -15,16 +15,15 @@ import {
} from '@/components/ui/command';
import { RenderDots } from '@/components/ui/RenderDots';
import { useMappings } from '@/hooks/useMappings';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import { useDispatch } from '@/redux';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterValue,
} from '@/types';
import { api } from '@/utils/api';
import { operators } from '@/utils/constants';
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation';
import { changeEvent } from '../reportSlice';
@@ -39,12 +38,12 @@ export function ReportEventFilters({
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const params = useOrganizationParams();
const params = useParams();
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery(
{
event: event.name,
projectSlug: params.project,
projectId: params.projectId as string,
},
{
enabled: !!event.name,
@@ -103,13 +102,13 @@ interface FilterProps {
}
function Filter({ filter, event }: FilterProps) {
const params = useOrganizationParams();
const params = useParams<{ organizationId: string; projectId: string }>();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectSlug: params.project,
projectId: params?.projectId!,
});
const valuesCombobox =
@@ -196,8 +195,8 @@ function Filter({ filter, event }: FilterProps) {
</Dropdown>
<ComboboxAdvanced
items={valuesCombobox}
selected={filter.value}
setSelected={(setFn) => {
value={filter.value}
onChange={(setFn) => {
changeFilterValue(
typeof setFn === 'function' ? setFn(filter.value) : setFn
);

View File

@@ -1,14 +1,16 @@
'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { api } from '@/utils/api';
import { Filter, GanttChart, Users } from 'lucide-react';
import { useParams } from 'next/navigation';
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
import { ReportEventFilters } from './ReportEventFilters';
@@ -19,9 +21,9 @@ export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const params = useOrganizationParams();
const params = useParams();
const eventsQuery = api.chart.events.useQuery({
projectSlug: params.project,
projectId: String(params.projectId),
});
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,
@@ -56,6 +58,8 @@ export function ReportEvents() {
<div className="flex items-center gap-2 p-2">
<ColorSquare>{event.id}</ColorSquare>
<Combobox
className="flex-1"
searchable
value={event.name}
onChange={(value) => {
dispatch(

View File

@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/utils/cn';
import { Asterisk, ChevronRight } from 'lucide-react';

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';

View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import { cn } from '@/utils/cn';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,3 +1,5 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as AvatarPrimitive from '@radix-ui/react-avatar';

View File

@@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import { cn } from '@/utils/cn';
import { cva } from 'class-variance-authority';

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