Feature/move list to client (#50)
This commit is contained in:
committed by
GitHub
parent
c2abdaadf2
commit
668434d246
@@ -16,7 +16,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
|
||||
<div className="w-full">
|
||||
<CopyInput label="Secret" value={secret} />
|
||||
{cors && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
You will only need the secret if you want to send server events.
|
||||
</p>
|
||||
)}
|
||||
@@ -25,7 +25,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
|
||||
{cors && (
|
||||
<div className="text-left">
|
||||
<Label>CORS settings</Label>
|
||||
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
|
||||
{cors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export const columns: ColumnDef<IServiceClientWithProject>[] = [
|
||||
return (
|
||||
<div>
|
||||
<div>{row.original.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className=" text-muted-foreground">
|
||||
{row.original.project?.name ?? 'No project'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function ColorSquare({ children, className }: ColorSquareProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -7,20 +8,27 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from './ui/table';
|
||||
import { Grid, GridBody, GridCell, GridHeader, GridRow } from './grid-table';
|
||||
|
||||
interface DataTableProps<TData> {
|
||||
columns: ColumnDef<TData, any>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function TableButtons({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-2 flex flex-wrap items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -29,47 +37,45 @@ export function DataTable<TData>({ columns, data }: DataTableProps<TData>) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<Grid columns={columns.length}>
|
||||
<GridHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<GridRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<GridCell key={header.id} isHeader>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</GridCell>
|
||||
))}
|
||||
</GridRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
</GridHeader>
|
||||
<GridBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
<GridRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<GridCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
</GridCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</GridRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<GridRow>
|
||||
<GridCell colSpan={columns.length}>
|
||||
<div className="h-24 text-center">No results.</div>
|
||||
</GridCell>
|
||||
</GridRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</GridBody>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
145
apps/dashboard/src/components/events/table/columns.tsx
Normal file
145
apps/dashboard/src/components/events/table/columns.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { EventIcon } from '@/app/(app)/[organizationSlug]/[projectId]/events/event-list/event-icon';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export function useColumns() {
|
||||
const number = useNumber();
|
||||
const columns: ColumnDef<IServiceEvent>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell({ row }) {
|
||||
const { name, path, duration } = row.original;
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return <span className="max-w-md truncate">{path}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">Screen: </span>
|
||||
<span className="max-w-md truncate">{path}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
/>
|
||||
<span className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
className="font-medium"
|
||||
>
|
||||
{renderName()}
|
||||
</button>
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span>{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="flex min-w-full items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span>{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="inline-flex min-w-full flex-none items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span>{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'profileId',
|
||||
header: 'Profile',
|
||||
cell({ row }) {
|
||||
const { profile } = row.original;
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${profile?.id}`}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap font-medium hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
cell({ row }) {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
}
|
||||
71
apps/dashboard/src/components/events/table/index.tsx
Normal file
71
apps/dashboard/src/components/events/table/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type Props =
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
}
|
||||
| {
|
||||
query: UseQueryResult<IServiceEvent[]>;
|
||||
cursor: number;
|
||||
setCursor: Dispatch<SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query, ...props }: Props) => {
|
||||
const columns = useColumns();
|
||||
const { data, isFetching, isLoading } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
|
||||
<p>Could not find any events</p>
|
||||
{'cursor' in props && props.cursor !== 0 && (
|
||||
<Button
|
||||
className="mt-8"
|
||||
variant="outline"
|
||||
onClick={() => props.setCursor((p) => p - 1)}
|
||||
>
|
||||
Go to previous page
|
||||
</Button>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
{'cursor' in props && (
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
setCursor={props.setCursor}
|
||||
cursor={props.cursor}
|
||||
count={Infinity}
|
||||
take={50}
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
<div>
|
||||
<label
|
||||
className={cn(
|
||||
'hover:bg-def-200 flex items-center gap-4 px-4 py-6 transition-colors',
|
||||
'flex items-center gap-4 px-4 py-6 transition-colors hover:bg-def-200',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor={id}
|
||||
@@ -32,8 +32,8 @@ export const CheckboxItem = forwardRef<HTMLButtonElement, Props>(
|
||||
{Icon && <div className="w-6 shrink-0">{<Icon />}</div>}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-sm text-muted-foreground">{description}</div>
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
<div className=" text-muted-foreground">{description}</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
|
||||
@@ -17,7 +17,7 @@ const CopyInput = ({ label, value, className }: Props) => {
|
||||
onClick={() => clipboard(value)}
|
||||
>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<div className="flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 font-mono text-sm">
|
||||
<div className="font-mono flex items-center justify-between rounded border-input bg-def-200 p-2 px-3 ">
|
||||
{value}
|
||||
<CopyIcon size={16} />
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const WithLabel = ({
|
||||
</Label>
|
||||
{error && (
|
||||
<Tooltiper asChild content={error}>
|
||||
<div className="flex items-center gap-1 text-sm leading-none text-destructive">
|
||||
<div className="flex items-center gap-1 leading-none text-destructive">
|
||||
Issues
|
||||
<BanIcon size={14} />
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ const TagInput = ({
|
||||
data-tag={tag}
|
||||
key={tag}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 text-sm',
|
||||
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
|
||||
isMarkedForDeletion &&
|
||||
i === value.length - 1 &&
|
||||
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
|
||||
@@ -136,7 +136,7 @@ const TagInput = ({
|
||||
<input
|
||||
ref={inputRef}
|
||||
placeholder={`${placeholder} ↵`}
|
||||
className="min-w-20 flex-1 py-1 text-sm focus-visible:outline-none"
|
||||
className="min-w-20 flex-1 py-1 focus-visible:outline-none"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronLeftIcon, FullscreenIcon } from 'lucide-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useDebounce } from 'usehooks-ts';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltiper } from './ui/tooltip';
|
||||
|
||||
type Props = {
|
||||
@@ -37,18 +38,21 @@ export const Fullscreen = (props: Props) => {
|
||||
};
|
||||
|
||||
export const FullscreenOpen = () => {
|
||||
const [, setIsFullscreen] = useFullscreen();
|
||||
const [fullscreen, setIsFullscreen] = useFullscreen();
|
||||
if (fullscreen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltiper content="Toggle fullscreen" asChild>
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsFullscreen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
Realtime
|
||||
</button>
|
||||
<FullscreenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltiper>
|
||||
);
|
||||
};
|
||||
|
||||
88
apps/dashboard/src/components/grid-table.tsx
Normal file
88
apps/dashboard/src/components/grid-table.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export const Grid: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & { columns: number }
|
||||
> = ({ className, columns, children, ...props }) => (
|
||||
<div className={cn('card', className)}>
|
||||
<div className="relative w-full overflow-auto rounded-md">
|
||||
<div
|
||||
className={cn('grid w-full')}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, auto)`,
|
||||
width: 'max-content',
|
||||
minWidth: '100%',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div className={cn('contents', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('contents [&>*:last-child]:border-0', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GridCell: React.FC<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
as?: React.ElementType;
|
||||
colSpan?: number;
|
||||
isHeader?: boolean;
|
||||
}
|
||||
> = ({
|
||||
className,
|
||||
children,
|
||||
as: Component = 'div',
|
||||
colSpan,
|
||||
isHeader,
|
||||
...props
|
||||
}) => (
|
||||
<Component
|
||||
className={cn(
|
||||
'flex h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
|
||||
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
|
||||
colSpan && `col-span-${colSpan}`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
export const GridRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
'contents transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
23
apps/dashboard/src/components/links.tsx
Normal file
23
apps/dashboard/src/components/links.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ProjectLink({
|
||||
children,
|
||||
...props
|
||||
}: LinkProps & {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { organizationSlug, projectId } = useAppParams();
|
||||
if (typeof props.href === 'string') {
|
||||
return (
|
||||
<Link {...props} href={`/${organizationSlug}/${projectId}/${props.href}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>ProjectLink</p>;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function OverviewFiltersButtons({
|
||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||
if (filters.length === 0 && events.length === 0) return null;
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{events.map((event) => (
|
||||
<Button
|
||||
key={event}
|
||||
@@ -32,7 +32,7 @@ export function OverviewFiltersButtons({
|
||||
icon={X}
|
||||
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
|
||||
>
|
||||
<strong>{event}</strong>
|
||||
<strong className="font-semibold">{event}</strong>
|
||||
</Button>
|
||||
))}
|
||||
{filters.map((filter) => {
|
||||
@@ -49,7 +49,7 @@ export function OverviewFiltersButtons({
|
||||
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
|
||||
>
|
||||
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
|
||||
<strong>{filter.value[0]}</strong>
|
||||
<strong className="font-semibold">{filter.value[0]}</strong>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useDebounceVal } from '@/hooks/useDebounceVal';
|
||||
import { useDebounceState } from '@/hooks/useDebounceState';
|
||||
import useWS from '@/hooks/useWS';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -28,7 +28,7 @@ const FIFTEEN_SECONDS = 1000 * 30;
|
||||
|
||||
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
|
||||
const client = useQueryClient();
|
||||
const counter = useDebounceVal(data, 1000, {
|
||||
const counter = useDebounceState(data, 1000, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
const lastRefresh = useRef(Date.now());
|
||||
|
||||
@@ -13,7 +13,7 @@ export function OverviewChartToggle({ chartType, setChartType }: Props) {
|
||||
return (
|
||||
<Button
|
||||
size={'icon'}
|
||||
variant={'outline'}
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setChartType((p) => (p === 'linear' ? 'bar' : 'linear'));
|
||||
}}
|
||||
|
||||
@@ -3,22 +3,25 @@ import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type Props = {
|
||||
chart: IChartProps;
|
||||
};
|
||||
|
||||
const OverviewDetailsButton = ({ chart }: Props) => {
|
||||
return (
|
||||
<button
|
||||
className="-mb-2 mt-5 flex w-full items-center justify-center gap-2 text-sm font-semibold"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
pushModal('OverviewChartDetails', {
|
||||
chart: chart,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ScanEyeIcon size={18} /> Details
|
||||
</button>
|
||||
<ScanEyeIcon size={18} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ interface WrapperProps {
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative mb-1 text-xs font-medium text-muted-foreground">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} unique vistors last 30 minutes
|
||||
</div>
|
||||
<div className="relative flex h-full w-full flex-1 items-end gap-1">
|
||||
|
||||
@@ -192,8 +192,8 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 md:m-0">
|
||||
<div className="card mb-2 grid grid-cols-4">
|
||||
<div className="relative -top-0.5 col-span-6 -m-4 mb-0 mt-0 md:m-0">
|
||||
<div className="card mb-2 grid grid-cols-4 overflow-hidden rounded-md">
|
||||
{reports.map((report, index) => (
|
||||
<button
|
||||
key={index}
|
||||
|
||||
@@ -32,7 +32,7 @@ const OverviewTopBots = ({ projectId }: Props) => {
|
||||
<>
|
||||
<div className="-m-4">
|
||||
<WidgetTable
|
||||
className="max-w-full [&_td:first-child]:w-full [&_th]:text-xs [&_tr]:text-xs"
|
||||
className="max-w-full [&_td:first-child]:w-full [&_th]:text-sm [&_tr]:text-sm"
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id}
|
||||
columns={[
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -271,10 +271,7 @@ export default function OverviewTopDevices({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -312,8 +309,11 @@ export default function OverviewTopDevices({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IChartType } from '@openpanel/validation';
|
||||
import { Widget, WidgetBody } from '../../widget';
|
||||
import { OverviewChartToggle } from '../overview-chart-toggle';
|
||||
import OverviewDetailsButton from '../overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from '../overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from '../overview-widget';
|
||||
import { useOverviewOptions } from '../useOverviewOptions';
|
||||
import { useOverviewWidget } from '../useOverviewWidget';
|
||||
|
||||
@@ -143,10 +143,7 @@ export default function OverviewTopEvents({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets
|
||||
.filter((item) => item.hide !== true)
|
||||
@@ -163,8 +160,11 @@ export default function OverviewTopEvents({
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<LazyChart hideID {...widget.chart} previous={false} />
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -143,10 +143,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -180,8 +177,11 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import OverviewTopBots from './overview-top-bots';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -154,10 +154,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
@@ -196,8 +193,13 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{widget.chart?.name && <OverviewDetailsButton chart={widget.chart} />}
|
||||
</WidgetBody>
|
||||
{widget.chart?.name && (
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
)}
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ScanEyeIcon } from 'lucide-react';
|
||||
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
@@ -12,7 +10,7 @@ import { LazyChart } from '../report/chart/LazyChart';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
@@ -282,10 +280,7 @@ export default function OverviewTopSources({
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHead>
|
||||
<div className="title">
|
||||
{widget.title}
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</div>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
@@ -335,8 +330,11 @@ export default function OverviewTopSources({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton chart={widget.chart} />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -85,7 +85,7 @@ export function WidgetButtons({
|
||||
<div
|
||||
ref={container}
|
||||
className={cn(
|
||||
'-mb-px -mt-2 flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-b-2 [&_button.active]:border-black [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-xs [&_button]:opacity-50',
|
||||
'-mb-px -mt-2 flex flex-wrap justify-start self-stretch px-4 transition-opacity [&_button.active]:border-b-2 [&_button.active]:border-black [&_button.active]:opacity-100 dark:[&_button.active]:border-white [&_button]:whitespace-nowrap [&_button]:py-1 [&_button]:text-sm [&_button]:opacity-50',
|
||||
className
|
||||
)}
|
||||
style={{ gap }}
|
||||
@@ -93,7 +93,12 @@ export function WidgetButtons({
|
||||
>
|
||||
{Children.map(children, (child, index) => {
|
||||
return (
|
||||
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex [&_button]:leading-normal',
|
||||
slice < index ? hidden : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
@@ -123,3 +128,21 @@ export function WidgetButtons({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetFooter({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WidgetHeadProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-b-md border-t bg-def-100 p-2 py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
62
apps/dashboard/src/components/page-tabs.tsx
Normal file
62
apps/dashboard/src/components/page-tabs.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function PageTabs({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('overflow-x-auto', className)}>
|
||||
<div className="flex gap-4 whitespace-nowrap text-3xl font-semibold">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsLink({
|
||||
href,
|
||||
children,
|
||||
isActive = false,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
|
||||
isActive ? 'opacity-100' : 'opacity-50'
|
||||
)}
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTabsItem({
|
||||
onClick,
|
||||
children,
|
||||
isActive = false,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-block opacity-100 transition-transform hover:translate-y-[-1px]',
|
||||
isActive ? 'opacity-100' : 'opacity-50'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -77,17 +77,6 @@ export function Pagination({
|
||||
disabled={isNextDisabled}
|
||||
icon={ChevronRightIcon}
|
||||
/>
|
||||
|
||||
{size === 'base' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCursor(lastCursor)}
|
||||
disabled={isNextDisabled}
|
||||
className="max-sm:hidden"
|
||||
icon={ChevronsRightIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ProfileAvatar({
|
||||
size === 'lg'
|
||||
? 'text-lg'
|
||||
: size === 'sm'
|
||||
? 'text-xs'
|
||||
? 'text-sm'
|
||||
: size === 'xs'
|
||||
? 'text-[8px]'
|
||||
: 'text-base',
|
||||
|
||||
102
apps/dashboard/src/components/profiles/table/columns.tsx
Normal file
102
apps/dashboard/src/components/profiles/table/columns.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report/chart/SerieIcon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
import { ProfileAvatar } from '../profile-avatar';
|
||||
|
||||
export function useColumns(type?: 'profiles' | 'power-users') {
|
||||
const columns: ColumnDef<IServiceProfile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${profile.id}`}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
title={getProfileName(profile, false)}
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Country',
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
cell({ row }) {
|
||||
const { os } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
cell({ row }) {
|
||||
const { browser } = row.original.properties;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Last seen',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<Tooltiper asChild content={formatDateTime(profile.createdAt)}>
|
||||
<div className="text-muted-foreground">
|
||||
{isToday(profile.createdAt)
|
||||
? formatTime(profile.createdAt)
|
||||
: formatDateTime(profile.createdAt)}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type === 'power-users') {
|
||||
columns.unshift({
|
||||
accessorKey: 'count',
|
||||
header: 'Events',
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
// @ts-expect-error
|
||||
return <div>{profile.count}</div>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
72
apps/dashboard/src/components/profiles/table/index.tsx
Normal file
72
apps/dashboard/src/components/profiles/table/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type CommonProps = {
|
||||
type?: 'profiles' | 'power-users';
|
||||
query: UseQueryResult<IServiceProfile[]>;
|
||||
};
|
||||
type Props =
|
||||
| CommonProps
|
||||
| (CommonProps & {
|
||||
cursor: number;
|
||||
setCursor: Dispatch<SetStateAction<number>>;
|
||||
});
|
||||
|
||||
export const ProfilesTable = ({ type, query, ...props }: Props) => {
|
||||
const columns = useColumns(type);
|
||||
const { data, isFetching, isLoading } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No profiles here" icon={GanttChartIcon}>
|
||||
<p>Could not find any profiles</p>
|
||||
{'cursor' in props && props.cursor !== 0 && (
|
||||
<Button
|
||||
className="mt-8"
|
||||
variant="outline"
|
||||
onClick={() => props.setCursor((p) => p - 1)}
|
||||
>
|
||||
Go to previous page
|
||||
</Button>
|
||||
)}
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
{'cursor' in props && (
|
||||
<Pagination
|
||||
className="mt-2"
|
||||
setCursor={props.setCursor}
|
||||
cursor={props.cursor}
|
||||
count={Infinity}
|
||||
take={50}
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,7 @@ function ProjectCard({ id, name, organizationSlug }: IServiceProject) {
|
||||
<ProjectChart id={id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 text-sm">
|
||||
<div className="flex justify-end gap-4 ">
|
||||
<Suspense>
|
||||
<ProjectMetrics id={id} />
|
||||
</Suspense>
|
||||
|
||||
@@ -62,10 +62,10 @@ export function PreviousDiffIndicator({
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <ArrowUpIcon strokeWidth={3} size={12} color="#000" />;
|
||||
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <ArrowDownIcon strokeWidth={3} size={12} color="#000" />;
|
||||
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export function PreviousDiffIndicator({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-medium',
|
||||
'font-mono flex items-center gap-1 font-medium',
|
||||
size === 'lg' && 'gap-2',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -89,21 +89,21 @@ export function MetricCard({
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="col relative gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-left font-semibold">
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-muted-foreground">
|
||||
<SerieName name={serie.names} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-2xl font-bold">
|
||||
<div className="font-mono truncate text-3xl font-bold">
|
||||
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
||||
</div>
|
||||
<PreviousDiffIndicator
|
||||
{...previous}
|
||||
className="text-xs text-muted-foreground"
|
||||
className="text-sm text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col text-xs',
|
||||
'flex flex-col text-sm',
|
||||
editMode ? 'card gap-2 p-4 text-base' : '-m-3 gap-1'
|
||||
)}
|
||||
>
|
||||
@@ -60,9 +60,9 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
className="absolute bottom-0.5 left-1 right-1 top-0.5 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(serie.metrics.sum / maxCount) * 100}%`,
|
||||
width: `calc(${(serie.metrics.sum / maxCount) * 100}% - 8px)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
|
||||
@@ -70,7 +70,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
<SerieIcon name={serie.names[0]} />
|
||||
<SerieName name={serie.names} />
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-end gap-4">
|
||||
<div className="font-mono flex flex-shrink-0 items-center justify-end gap-4">
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous?.[metric]}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ReportChartTooltip({
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 text-sm shadow-xl">
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
@@ -65,20 +65,22 @@ export function ReportChartTooltip({
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={data.names} />
|
||||
<SerieName name={data.names} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{number.formatWithUnit(data.count, unit)}</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous &&
|
||||
`(${number.formatWithUnit(data.previous.value, unit)})`}
|
||||
</PreviousDiffIndicator>
|
||||
<div className="font-mono flex justify-between gap-8 font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count, unit)}
|
||||
{!!data.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicator {...data.previous} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
|
||||
@@ -10,24 +10,19 @@ import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
|
||||
import { SplineIcon } from 'lucide-react';
|
||||
import { last, pathOr } from 'ramda';
|
||||
import { last } from 'ramda';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { IServiceReference } from '@openpanel/db';
|
||||
import type { IChartLineType, IInterval } from '@openpanel/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { ReportChartTooltip } from './ReportChartTooltip';
|
||||
|
||||
@@ -48,6 +48,7 @@ interface ChartRootShortcutProps {
|
||||
chartType?: IChartProps['chartType'];
|
||||
interval?: IChartProps['interval'];
|
||||
events: IChartProps['events'];
|
||||
breakdowns?: IChartProps['breakdowns'];
|
||||
}
|
||||
|
||||
export const ChartRootShortcut = ({
|
||||
@@ -57,12 +58,13 @@ export const ChartRootShortcut = ({
|
||||
chartType = 'linear',
|
||||
interval = 'day',
|
||||
events,
|
||||
breakdowns,
|
||||
}: ChartRootShortcutProps) => {
|
||||
return (
|
||||
<ChartRoot
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
breakdowns={[]}
|
||||
breakdowns={breakdowns ?? []}
|
||||
previous={previous}
|
||||
chartType={chartType}
|
||||
interval={interval}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function FunnelSteps({
|
||||
</div>
|
||||
{finalStep ? (
|
||||
<div className={cn('flex flex-col items-center p-4')}>
|
||||
<div className="text-xs font-medium uppercase">
|
||||
<div className="text-sm font-medium uppercase">
|
||||
Conversion
|
||||
</div>
|
||||
<div
|
||||
@@ -143,13 +143,13 @@ export function FunnelSteps({
|
||||
>
|
||||
{round(step.percent, 1)}%
|
||||
</div>
|
||||
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground">
|
||||
<div className="mt-0 font-medium uppercase text-muted-foreground">
|
||||
Converted {step.current} of {totalSessions} sessions
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('flex flex-col items-center p-4')}>
|
||||
<div className="text-xs font-medium uppercase">Dropoff</div>
|
||||
<div className="text-sm font-medium uppercase">Dropoff</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-3xl font-bold uppercase',
|
||||
@@ -158,7 +158,7 @@ export function FunnelSteps({
|
||||
>
|
||||
{round(step.dropoff.percent, 1)}%
|
||||
</div>
|
||||
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground">
|
||||
<div className="mt-0 font-medium uppercase text-muted-foreground">
|
||||
Lost {step.dropoff.count} sessions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { AlertCircleIcon, TrendingUp } from 'lucide-react';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { Cell, Pie, PieChart } from 'recharts';
|
||||
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
@@ -40,7 +37,7 @@ function InsightCard({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border border-border p-4 py-3">
|
||||
<span className="text-sm">{title}</span>
|
||||
<span className="">{title}</span>
|
||||
<div className="whitespace-nowrap text-lg">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export function EventPropertiesCombobox({
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 text-xs font-medium leading-none',
|
||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 text-sm font-medium leading-none',
|
||||
!event.property && 'border-destructive text-destructive'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function ReportEvents() {
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
<div className="flex gap-2 p-2 pt-0 text-sm">
|
||||
<div className="flex gap-2 p-2 pt-0 ">
|
||||
<DropdownMenuComposed
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
@@ -148,7 +148,7 @@ export function ReportEvents() {
|
||||
]}
|
||||
label="Segment"
|
||||
>
|
||||
<button className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-xs font-medium leading-none">
|
||||
<button className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none">
|
||||
{event.segment === 'user' ? (
|
||||
<>
|
||||
<Users size={12} /> Unique users
|
||||
@@ -216,7 +216,7 @@ export function ReportEvents() {
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
className="mt-4 flex cursor-pointer select-none items-center gap-2 text-sm font-medium"
|
||||
className="mt-4 flex cursor-pointer select-none items-center gap-2 font-medium"
|
||||
htmlFor="previous"
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -103,7 +103,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
<ColorSquare className="bg-emerald-500">
|
||||
<SlidersHorizontal size={10} />
|
||||
</ColorSquare>
|
||||
<div className="flex flex-1 text-sm">
|
||||
<div className="flex flex-1 ">
|
||||
<RenderDots truncate>{filter.name}</RenderDots>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={removeFilter}>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
);
|
||||
}}
|
||||
>
|
||||
<button className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-xs font-medium leading-none">
|
||||
<button className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none">
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
</Combobox>
|
||||
|
||||
86
apps/dashboard/src/components/settings-toggle.tsx
Normal file
86
apps/dashboard/src/components/settings-toggle.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CheckIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { ProjectLink } from './links';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SettingsToggle({ className }: Props) {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className={className}>
|
||||
<MoreHorizontalIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Toggle settings</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-56">
|
||||
<DropdownMenuItem asChild>
|
||||
<ProjectLink href="/reports">
|
||||
Create report
|
||||
<DropdownMenuShortcut>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</DropdownMenuShortcut>
|
||||
</ProjectLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Settings</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<ProjectLink href="/settings/organization">Organization</ProjectLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<ProjectLink href="/settings/projects">Projects</ProjectLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<ProjectLink href="/settings/profile">Your profile</ProjectLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<ProjectLink href="/settings/references">References</ProjectLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex w-full items-center justify-between">
|
||||
Theme
|
||||
<DropdownMenuShortcut>{theme}</DropdownMenuShortcut>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="p-0">
|
||||
{['system', 'light', 'dark'].map((themeOption) => (
|
||||
<DropdownMenuItem
|
||||
key={themeOption}
|
||||
onClick={() => setTheme(themeOption)}
|
||||
className="capitalize"
|
||||
>
|
||||
{themeOption}
|
||||
{theme === themeOption && (
|
||||
<CheckIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
112
apps/dashboard/src/components/settings/invites/columns.tsx
Normal file
112
apps/dashboard/src/components/settings/invites/columns.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { api } from '@/trpc/client';
|
||||
import type { ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { pathOr } from 'ramda';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
||||
|
||||
export function useColumns(
|
||||
projects: IServiceProject[]
|
||||
): ColumnDef<IServiceInvite>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Mail',
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.original.email}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Role',
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => (
|
||||
<TooltipComplete
|
||||
content={new Date(row.original.createdAt).toLocaleString()}
|
||||
>
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</TooltipComplete>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'access',
|
||||
header: 'Access',
|
||||
cell: ({ row }) => {
|
||||
const access = pathOr<string[]>([], ['meta', 'access'], row.original);
|
||||
return (
|
||||
<>
|
||||
{access.map((id) => {
|
||||
const project = projects.find((p) => p.id === id);
|
||||
if (!project) {
|
||||
return (
|
||||
<Badge key={id} className="mr-1">
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge key={id} color="blue" className="mr-1">
|
||||
{project.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{access.length === 0 && (
|
||||
<Badge variant={'secondary'}>All projects</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return <ActionCell row={row} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ActionCell({ row }: { row: Row<IServiceInvite> }) {
|
||||
const router = useRouter();
|
||||
const revoke = api.organization.revokeInvite.useMutation({
|
||||
onSuccess() {
|
||||
toast.success(`Invite for ${row.original.email} revoked`);
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
toast.error(`Failed to revoke invite for ${row.original.email}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
revoke.mutate({ memberId: row.original.id });
|
||||
}}
|
||||
>
|
||||
Revoke invite
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
46
apps/dashboard/src/components/settings/invites/index.tsx
Normal file
46
apps/dashboard/src/components/settings/invites/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type CommonProps = {
|
||||
projects: IServiceProject[];
|
||||
data: IServiceInvite[];
|
||||
};
|
||||
|
||||
type Props = CommonProps;
|
||||
|
||||
export const InvitesTable = ({ projects, data }: Props) => {
|
||||
const columns = useColumns(projects);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No members here" icon={GanttChartIcon}>
|
||||
<p>Could not find any members</p>
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
140
apps/dashboard/src/components/settings/members/columns.tsx
Normal file
140
apps/dashboard/src/components/settings/members/columns.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { api } from '@/trpc/client';
|
||||
import type { ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { MoreHorizontalIcon } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { IServiceMember, IServiceProject } from '@openpanel/db';
|
||||
|
||||
export function useColumns(projects: IServiceProject[]) {
|
||||
const columns: ColumnDef<IServiceMember>[] = [
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.user;
|
||||
if (!user) return null;
|
||||
return [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.user;
|
||||
if (!user) return null;
|
||||
return <div className="font-medium">{user.email}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Role',
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => (
|
||||
<TooltipComplete
|
||||
content={new Date(row.original.createdAt).toLocaleString()}
|
||||
>
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</TooltipComplete>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'access',
|
||||
header: 'Access',
|
||||
cell: ({ row }) => {
|
||||
return <AccessCell row={row} projects={projects} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
return <ActionsCell row={row} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
function AccessCell({
|
||||
row,
|
||||
projects,
|
||||
}: {
|
||||
row: Row<IServiceMember>;
|
||||
projects: IServiceProject[];
|
||||
}) {
|
||||
const [access, setAccess] = useState<string[]>(
|
||||
row.original.access.map((item) => item.projectId)
|
||||
);
|
||||
const mutation = api.organization.updateMemberAccess.useMutation();
|
||||
|
||||
return (
|
||||
<ComboboxAdvanced
|
||||
placeholder="Restrict access to projects"
|
||||
value={access}
|
||||
onChange={(newAccess) => {
|
||||
setAccess(newAccess);
|
||||
mutation.mutate({
|
||||
userId: row.original.user!.id,
|
||||
organizationSlug: row.original.organizationId,
|
||||
access: newAccess as string[],
|
||||
});
|
||||
}}
|
||||
items={projects.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsCell({ row }: { row: Row<IServiceMember> }) {
|
||||
const router = useRouter();
|
||||
const revoke = api.organization.removeMember.useMutation({
|
||||
onSuccess() {
|
||||
toast.success(
|
||||
`${row.original.user?.firstName} has been removed from the organization`
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
toast.error(
|
||||
`Failed to remove ${row.original.user?.firstName} from the organization`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
revoke.mutate({
|
||||
organizationId: row.original.organizationId,
|
||||
userId: row.original.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
46
apps/dashboard/src/components/settings/members/index.tsx
Normal file
46
apps/dashboard/src/components/settings/members/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { GanttChartIcon } from 'lucide-react';
|
||||
|
||||
import type { IServiceMember, IServiceProject } from '@openpanel/db';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type CommonProps = {
|
||||
projects: IServiceProject[];
|
||||
data: IServiceMember[];
|
||||
};
|
||||
|
||||
type Props = CommonProps;
|
||||
|
||||
export const MembersTable = ({ projects, data }: Props) => {
|
||||
const columns = useColumns(projects);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return (
|
||||
<FullPageEmptyState title="No members here" icon={GanttChartIcon}>
|
||||
<p>Could not find any members</p>
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable data={data ?? []} columns={columns} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -43,7 +43,7 @@ const AccordionContent = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ const AlertDialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn(' text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -52,7 +52,7 @@ const AlertDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
className={cn(' [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -11,7 +11,7 @@ const Avatar = React.forwardRef<
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full text-sm',
|
||||
'relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full ',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex h-[20px] items-center rounded-full border px-1.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex h-[20px] items-center rounded-full border px-1.5 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'bg-def-100 hover:bg-def-100/80 border-transparent text-secondary-foreground',
|
||||
'border-transparent bg-def-100 text-secondary-foreground hover:bg-def-100/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive-foreground text-destructive hover:bg-destructive/80',
|
||||
success:
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex flex-shrink-0 select-none items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -20,7 +20,7 @@ function Calendar({
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
caption_label: ' font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
@@ -33,7 +33,7 @@ function Calendar({
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
cell: 'h-9 w-9 text-center p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
|
||||
@@ -52,7 +52,7 @@ const CheckboxInput = React.forwardRef<
|
||||
)}
|
||||
>
|
||||
<Checkbox ref={ref} {...props} className="relative top-0.5" />
|
||||
<div className="text-sm font-medium">{props.children}</div>
|
||||
<div className=" font-medium">{props.children}</div>
|
||||
</label>
|
||||
));
|
||||
CheckboxInput.displayName = 'CheckboxInput';
|
||||
|
||||
@@ -45,7 +45,7 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,11 +72,7 @@ const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center " {...props} />
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
@@ -88,7 +84,7 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -116,7 +112,7 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-disabled={props.disabled}
|
||||
@@ -133,7 +129,7 @@ const CommandShortcut = ({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
'ml-auto text-sm tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -99,7 +99,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn(' text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -26,7 +26,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
@@ -82,7 +82,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:mr-2',
|
||||
'relative flex min-h-8 cursor-pointer select-none items-center rounded-sm px-2 py-1.5 outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:mr-2',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -145,11 +145,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
className={cn('px-2 py-1.5 font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -173,7 +169,7 @@ const DropdownMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
className={cn('ml-auto text-sm tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ const InputOTPSlot = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -6,11 +6,11 @@ import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
const inputVariant = cva(
|
||||
'flex w-full rounded-md border border-input bg-card ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'file: flex w-full rounded-md border border-input bg-card ring-offset-background file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-8 px-3 py-2 text-sm',
|
||||
default: 'h-8 px-3 py-2 ',
|
||||
large: 'h-12 px-4 py-3 text-lg',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
'group flex min-w-0 max-w-full divide-x self-start overflow-hidden rounded-md border border-border text-xs font-medium transition-transform',
|
||||
'group flex min-w-0 max-w-full divide-x self-start overflow-hidden rounded-md border border-border text-sm font-medium transition-transform',
|
||||
clickable && 'hover:-translate-y-0.5'
|
||||
)}
|
||||
{...{ href, onClick }}
|
||||
@@ -23,7 +23,7 @@ export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
|
||||
<div className="bg-black/5 p-1 px-2 capitalize">{name}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap bg-card p-1 px-2 font-mono text-highlight',
|
||||
'font-mono overflow-hidden text-ellipsis whitespace-nowrap bg-card p-1 px-2 text-highlight',
|
||||
clickable && 'group-hover:underline'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
const labelVariants = cva(
|
||||
'mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
'mb-2 block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
|
||||
17
apps/dashboard/src/components/ui/padding.tsx
Normal file
17
apps/dashboard/src/components/ui/padding.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const padding = 'p-4 lg:p-8';
|
||||
|
||||
export const Padding = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
|
||||
return <div className={cn(padding, className)} {...props} />;
|
||||
};
|
||||
|
||||
export const Spacer = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HtmlHTMLAttributes<HTMLDivElement>) => {
|
||||
return <div className={cn('h-8', className)} {...props} />;
|
||||
};
|
||||
@@ -13,7 +13,7 @@ const Progress = React.forwardRef<
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-def-200 relative h-4 w-full min-w-16 overflow-hidden rounded shadow-sm',
|
||||
'relative h-4 w-full min-w-16 overflow-hidden rounded bg-def-200 shadow-sm',
|
||||
size == 'sm' && 'h-2',
|
||||
size == 'lg' && 'h-8',
|
||||
className
|
||||
@@ -28,7 +28,7 @@ const Progress = React.forwardRef<
|
||||
}}
|
||||
/>
|
||||
{value && size != 'sm' && (
|
||||
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-xs font-semibold">
|
||||
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-sm font-semibold">
|
||||
<div>{round(value, 2)}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ const SheetContent = React.forwardRef<
|
||||
<SheetPrimitive.Close id="close-sheet" className="hidden" />
|
||||
<SheetPrimitive.Close
|
||||
onClick={onClose}
|
||||
className="data-[state=open]:bg-def-100 absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-def-100"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -126,7 +126,7 @@ const SheetDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn(' text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -13,7 +13,7 @@ const Table = React.forwardRef<
|
||||
<div className={cn('relative w-full', overflow && 'overflow-auto')}>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
className={cn('w-full caption-bottom', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -75,7 +75,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 border-b border-border bg-def-200 px-4 text-left align-middle text-sm font-medium text-muted-foreground shadow-[0_0_0_0.5px] shadow-border [&:has([role=checkbox])]:pr-0',
|
||||
'h-10 whitespace-nowrap border-b border-border bg-def-100 px-4 text-left align-middle font-semibold text-muted-foreground shadow-[0_0_0_0.5px] shadow-border [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -104,7 +104,7 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
className={cn('mt-4 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -62,7 +62,7 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'hover:bg-def-100 inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 font-medium ring-offset-background transition-colors hover:bg-def-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -94,7 +94,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
className={cn(' font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -106,7 +106,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
className={cn(' opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva } from 'class-variance-authority';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
|
||||
const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
'inline-flex items-center justify-center rounded-md font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -22,7 +22,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover-foreground px-3 py-1.5 text-sm text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 overflow-hidden rounded-md border bg-popover-foreground px-3 py-1.5 text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const WidgetTableHead = ({
|
||||
return (
|
||||
<thead
|
||||
className={cn(
|
||||
'bg-def-100 text-def-1000 sticky top-0 z-10 border-b border-border text-sm [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium',
|
||||
'text-def-1000 sticky top-0 z-10 border-b border-border bg-def-100 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -49,7 +49,7 @@ export function WidgetTable<T>({
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border text-right text-sm last:border-0 [&_td:first-child]:text-left [&_td]:p-4"
|
||||
className="border-b border-border text-right last:border-0 [&_td:first-child]:text-left [&_td]:p-4"
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.name}>{column.render(item)}</td>
|
||||
|
||||
Reference in New Issue
Block a user