feat(ai): add ai chat to dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-04-15 14:30:21 +02:00
parent 804a9c8056
commit 34769a5d58
46 changed files with 2624 additions and 1449 deletions

View File

@@ -0,0 +1,31 @@
import Chat from '@/components/chat/chat';
import { db, getOrganizationBySlug } from '@openpanel/db';
import type { UIMessage } from 'ai';
export default async function ChatPage({
params,
}: {
params: { organizationSlug: string; projectId: string };
}) {
const { projectId } = await params;
const [organization, chat] = await Promise.all([
getOrganizationBySlug(params.organizationSlug),
db.chat.findFirst({
where: {
projectId,
},
orderBy: {
createdAt: 'desc',
},
}),
]);
const messages = ((chat?.messages as UIMessage[]) || []).slice(-10);
return (
<Chat
projectId={projectId}
initialMessages={messages}
organization={organization}
/>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import { cn } from '@/utils/cn';
import { useSelectedLayoutSegments } from 'next/navigation';
const NOT_MIGRATED_PAGES = ['reports'];
@@ -16,6 +17,13 @@ export default function LayoutContent({
}
return (
<div className="pb-20 transition-all max-lg:mt-12 lg:pl-72">{children}</div>
<div
className={cn(
'pb-20 transition-all max-lg:mt-12 lg:pl-72',
segments.includes('chat') && 'pb-0',
)}
>
{children}
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
PlusIcon,
ScanEyeIcon,
ServerIcon,
SparklesIcon,
UsersIcon,
WallpaperIcon,
} from 'lucide-react';
@@ -23,6 +24,7 @@ import { usePathname } from 'next/navigation';
import { ProjectLink } from '@/components/links';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { CommandShortcut } from '@/components/ui/command';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
import { differenceInDays, format } from 'date-fns';
@@ -174,15 +176,25 @@ export default function LayoutMenu({
</div>
</ProjectLink>
)}
<ProjectLink
href={'/chat'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
>
<SparklesIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Ask AI</div>
</div>
<CommandShortcut>K</CommandShortcut>
</ProjectLink>
<ProjectLink
href={'/reports'}
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
>
<ChartLineIcon size={20} />
<div className="flex-1 col gap-1">
<div className="font-medium">Create report</div>
</div>
<PlusIcon size={16} className="text-muted-foreground" />
<CommandShortcut>J</CommandShortcut>
</ProjectLink>
</div>
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />

View File

@@ -7,8 +7,10 @@ import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import {
changeChartType,
changeDateRanges,
changeEndDate,
changeInterval,
changeStartDate,
ready,
reset,
@@ -74,7 +76,13 @@ export default function ReportEditor({
</div>
</SheetTrigger>
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
<ReportChartType className="min-w-0 flex-1" />
<ReportChartType
className="min-w-0 flex-1"
onChange={(type) => {
dispatch(changeChartType(type));
}}
value={report.chartType}
/>
<TimeWindowPicker
className="min-w-0 flex-1"
onChange={(value) => {
@@ -90,7 +98,13 @@ export default function ReportEditor({
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval className="min-w-0 flex-1" />
<ReportInterval
className="min-w-0 flex-1"
interval={report.interval}
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
range={report.range}
chartType={report.chartType}
/>
<ReportLineType className="min-w-0 flex-1" />
</div>
<div className="col-start-2 row-start-1 text-right md:col-start-6">

View File

@@ -5,6 +5,7 @@ import Providers from './providers';
import '@/styles/globals.css';
import 'flag-icons/css/flag-icons.min.css';
import 'katex/dist/katex.min.css';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';

View File

@@ -1,5 +1,7 @@
import type { MetadataRoute } from 'next';
export const dynamic = 'static';
export default function manifest(): MetadataRoute.Manifest {
return {
id: process.env.NEXT_PUBLIC_DASHBOARD_URL,

View File

@@ -54,34 +54,34 @@ function AllProviders({ children }: { children: React.ReactNode }) {
}
return (
<ThemeProvider
attribute="class"
disableTransitionOnChange
defaultTheme="system"
>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
trackScreenViews
trackOutgoingLinks
trackAttributes
/>
)}
<ReduxProvider store={storeRef.current}>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<NuqsAdapter>
<NuqsAdapter>
<ThemeProvider
attribute="class"
disableTransitionOnChange
defaultTheme="system"
>
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
trackScreenViews
trackOutgoingLinks
trackAttributes
/>
)}
<ReduxProvider store={storeRef.current}>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<NotificationProvider />
<Toaster />
<ModalProvider />
</TooltipProvider>
</NuqsAdapter>
</QueryClientProvider>
</api.Provider>
</ReduxProvider>
</ThemeProvider>
</QueryClientProvider>
</api.Provider>
</ReduxProvider>
</ThemeProvider>
</NuqsAdapter>
);
}

View File

@@ -0,0 +1,70 @@
import { cn } from '@/utils/cn';
import type { useChat } from '@ai-sdk/react';
import { useLocalStorage } from 'usehooks-ts';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
type Props = Pick<
ReturnType<typeof useChat>,
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
> & {
projectId: string;
isLimited: boolean;
};
export function ChatForm({
handleSubmit: handleSubmitProp,
input,
handleInputChange,
append,
projectId,
isLimited,
}: Props) {
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
`chat-quick-actions:${projectId}`,
[],
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
handleSubmitProp(e);
setQuickActions([input, ...quickActions].slice(0, 5));
};
return (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
<ScrollArea orientation="horizontal">
<div className="row gap-2 px-4">
{quickActions.map((q) => (
<Button
disabled={isLimited}
key={q}
type="button"
variant="outline"
size="sm"
onClick={() => {
append({
role: 'user',
content: q,
});
}}
>
{q}
</Button>
))}
</div>
</ScrollArea>
<form onSubmit={handleSubmit} className="p-4 pt-2">
<input
disabled={isLimited}
className={cn(
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
isLimited && 'opacity-50 cursor-not-allowed',
)}
value={input}
placeholder="Ask me anything..."
onChange={handleInputChange}
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
import { Card } from '../card';
import { LogoSquare } from '../logo';
import { Skeleton } from '../skeleton';
import Syntax from '../syntax';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
import { ChatReport } from './chat-report';
export const ChatMessage = memo(
({
message,
isLast,
isStreaming,
debug,
}: {
message: UIMessage;
isLast: boolean;
isStreaming: boolean;
debug: boolean;
}) => {
const showIsStreaming = isLast && isStreaming;
return (
<div className="max-w-xl w-full">
<div className="row">
<div className="w-8 shrink-0">
<div className="size-6 relative">
{message.role === 'assistant' ? (
<LogoSquare className="size-full rounded-full" />
) : (
<div className="size-full bg-black text-white rounded-full center-center">
<UserIcon className="size-4" />
</div>
)}
<div
className={cn(
'absolute inset-0 bg-background rounded-full center-center opacity-0',
showIsStreaming && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin text-foreground" />
</div>
</div>
</div>
<div className="col gap-4 flex-1">
{message.parts.map((p, index) => {
const key = index.toString() + p.type;
const isToolInvocation = p.type === 'tool-invocation';
if (p.type === 'step-start') {
return null;
}
if (!isToolInvocation && p.type !== 'text') {
return <Debug enabled={debug} json={p} />;
}
if (p.type === 'text') {
return (
<div className="prose dark:prose-invert prose-sm" key={key}>
<Markdown>{p.text}</Markdown>
</div>
);
}
if (isToolInvocation && p.toolInvocation.state === 'result') {
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>
<Debug json={result} enabled={debug} />
<ChatReport report={report.data} lazy={!isLast} />
</Fragment>
);
}
}
return (
<Debug
key={key}
json={p.toolInvocation.result}
enabled={debug}
/>
);
}
return null;
})}
{showIsStreaming && (
<div className="w-full col gap-2">
<Skeleton className="w-3/5 h-4" />
<Skeleton className="w-4/5 h-4" />
<Skeleton className="w-2/5 h-4" />
</div>
)}
</div>
</div>
{!isLast && (
<div className="w-full shrink-0 pl-8 mt-4">
<div className="h-px bg-border" />
</div>
)}
</div>
);
},
);
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
if (!enabled) {
return null;
}
return (
<Accordion type="single" collapsible>
<Card>
<AccordionItem value={'json'}>
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
<span className="flex-1">Show JSON result</span>
</AccordionTrigger>
<AccordionContent className="p-2">
<Syntax
wrapLines
language="json"
code={JSON.stringify(json, null, 2)}
/>
</AccordionContent>
</AccordionItem>
</Card>
</Accordion>
);
}

View File

@@ -0,0 +1,84 @@
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
import type { IServiceOrganization, Organization } from '@openpanel/db';
import type { UIMessage } from 'ai';
import { Loader2Icon } from 'lucide-react';
import { useEffect } from 'react';
import { ProjectLink } from '../links';
import { Markdown } from '../markdown';
import { Skeleton } from '../skeleton';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { Button, LinkButton } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { ChatMessage } from './chat-message';
export function ChatMessages({
messages,
debug,
status,
isLimited,
}: {
messages: UIMessage[];
debug: boolean;
status: 'submitted' | 'streaming' | 'ready' | 'error';
isLimited: boolean;
}) {
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
useScrollAnchor();
useEffect(() => {
scrollToBottom();
}, []);
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'user') {
scrollToBottom();
}
}, [messages]);
return (
<ScrollArea className="h-full" ref={scrollRef}>
<div ref={messagesRef} className="p-8 col gap-2">
{messages.map((m, index) => {
return (
<ChatMessage
key={m.id}
message={m}
isStreaming={status === 'streaming'}
isLast={index === messages.length - 1}
debug={debug}
/>
);
})}
{status === 'submitted' && (
<div className="card p-4 center-center max-w-xl pl-8">
<Loader2Icon className="w-4 h-4 animate-spin" />
</div>
)}
{isLimited && (
<div className="max-w-xl pl-8 mt-8">
<Alert variant={'warning'}>
<AlertTitle>Upgrade your account</AlertTitle>
<AlertDescription>
<p>
To keep using this feature you need to upgrade your account.
</p>
<p>
<ProjectLink
href="/settings/organization?tab=billing"
className="font-medium underline"
>
Visit Billing
</ProjectLink>{' '}
to upgrade.
</p>
</AlertDescription>
</Alert>
</div>
)}
<div className="h-20 p-4 w-full" />
<div className="w-full h-px" ref={visibilityRef} />
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
import { endOfDay, startOfDay } from 'date-fns';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
const [range, setRange] = useState<IChartRange>(props.report.range);
const [interval, setInterval] = useState<IInterval>(props.report.interval);
const report = {
...props.report,
lineType: 'linear' as const,
chartType,
startDate: range === 'custom' ? startDate : null,
endDate: range === 'custom' ? endDate : null,
range,
interval,
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
onChange={setRange}
value={report.range}
onStartDateChange={(date) =>
setStartDate(startOfDay(date).toISOString())
}
onEndDateChange={(date) => setEndDate(endOfDay(date).toISOString())}
endDate={report.endDate}
startDate={report.startDate}
/>
<ReportInterval
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
>
Save report
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { ChatForm } from '@/components/chat/chat-form';
import { ChatMessages } from '@/components/chat/chat-messages';
import { useChat } from '@ai-sdk/react';
import type { IServiceOrganization } from '@openpanel/db';
import type { UIMessage } from 'ai';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { toast } from 'sonner';
const getErrorMessage = (error: Error) => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || error.message;
} catch (e) {
return error.message;
}
};
export default function Chat({
initialMessages,
projectId,
organization,
}: {
initialMessages?: UIMessage[];
projectId: string;
organization: IServiceOrganization;
}) {
const { messages, input, handleInputChange, handleSubmit, status, append } =
useChat({
onError(error) {
const message = getErrorMessage(error);
toast.error(message);
},
api: `${process.env.NEXT_PUBLIC_API_URL}/ai/chat?projectId=${projectId}`,
initialMessages: (initialMessages ?? []) as any,
fetch: (url, options) => {
return fetch(url, {
...options,
credentials: 'include',
mode: 'cors',
});
},
});
const [debug, setDebug] = useQueryState(
'debug',
parseAsBoolean.withDefault(false),
);
const isLimited = Boolean(
messages.length > 5 &&
(organization.isCanceled ||
organization.isTrial ||
organization.isWillBeCanceled ||
organization.isExceeded ||
organization.isExpired),
);
return (
<div className="h-screen w-full col relative">
<ChatMessages
messages={messages}
debug={debug}
status={status}
isLimited={isLimited}
/>
<ChatForm
handleSubmit={handleSubmit}
input={input}
handleInputChange={handleInputChange}
append={append}
projectId={projectId}
isLimited={isLimited}
/>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
import ReactMarkdown, { type Options } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkHighlight from 'remark-highlight';
import remarkMath from 'remark-math';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import 'katex/dist/katex.min.css';
export const Markdown = memo<Options>(
(props) => (
<ReactMarkdown
{...props}
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, remarkRehype]}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
'className' in prevProps &&
'className' in nextProps &&
prevProps.className === nextProps.className,
);
Markdown.displayName = 'Markdown';

View File

@@ -39,6 +39,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput;
lazy?: boolean;
};
const context = createContext<ReportChartContextType | null>(null);

View File

@@ -157,7 +157,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
interval: IInterval;
}
>(({ data, context }) => {
const { date } = data[0]!;
if (!data[0]) {
return null;
}
const { date } = data[0];
const formatDate = useFormatDateInterval(context.interval);
const number = useNumber();
return (

View File

@@ -1,9 +1,10 @@
'use client';
import { mergeDeepRight } from 'ramda';
import React, { useEffect, useRef } from 'react';
import React, { memo, useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import { shallowEqual } from 'react-redux';
import { ReportAreaChart } from './area';
import { ReportBarChart } from './bar';
import type { ReportChartProps } from './context';
@@ -17,7 +18,7 @@ import { ReportMetricChart } from './metric';
import { ReportPieChart } from './pie';
import { ReportRetentionChart } from './retention';
export function ReportChart(props: ReportChartProps) {
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
@@ -30,7 +31,7 @@ export function ReportChart(props: ReportChartProps) {
}
}, [inViewport]);
const loaded = once.current || inViewport;
const loaded = lazy ? once.current || inViewport : true;
const renderReportChart = () => {
switch (props.report.chartType) {
@@ -69,4 +70,4 @@ export function ReportChart(props: ReportChartProps) {
</ReportChartProvider>
</div>
);
}
};

View File

@@ -14,7 +14,7 @@ import {
} from 'lucide-react';
import { chartTypes } from '@openpanel/constants';
import { objectToZodEnums } from '@openpanel/validation';
import { type IChartType, objectToZodEnums } from '@openpanel/validation';
import {
DropdownMenu,
@@ -32,10 +32,14 @@ import { changeChartType } from './reportSlice';
interface ReportChartTypeProps {
className?: string;
value: IChartType;
onChange: (type: IChartType) => void;
}
export function ReportChartType({ className }: ReportChartTypeProps) {
const dispatch = useDispatch();
const type = useSelector((state) => state.report.chartType);
export function ReportChartType({
className,
value,
onChange,
}: ReportChartTypeProps) {
const items = objectToZodEnums(chartTypes).map((key) => ({
label: chartTypes[key],
value: key,
@@ -61,10 +65,10 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={Icons[type]}
icon={Icons[value]}
className={cn('justify-start', className)}
>
{items.find((item) => item.value === type)?.label}
{items.find((item) => item.value === value)?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
@@ -77,7 +81,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
return (
<DropdownMenuItem
key={item.value}
onClick={() => dispatch(changeChartType(item.value))}
onClick={() => onChange(item.value)}
className="group"
>
{item.label}

View File

@@ -6,17 +6,36 @@ import {
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import { Combobox } from '../ui/combobox';
import { cn } from '@/utils/cn';
import type { IChartRange, IChartType, IInterval } from '@openpanel/validation';
import { Button } from '../ui/button';
import { CommandShortcut } from '../ui/command';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { changeInterval } from './reportSlice';
interface ReportIntervalProps {
className?: string;
interval: IInterval;
onChange: (range: IInterval) => void;
chartType: IChartType;
range: IChartRange;
}
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);
export function ReportInterval({
className,
interval,
onChange,
chartType,
range,
}: ReportIntervalProps) {
if (
chartType !== 'linear' &&
chartType !== 'histogram' &&
@@ -28,37 +47,66 @@ export function ReportInterval({ className }: ReportIntervalProps) {
return null;
}
const items = [
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'month',
label: 'Month',
disabled: range === 'today' || range === 'lastHour' || range === '30min',
},
];
const selectedItem = items.find((item) => item.value === interval);
return (
<Combobox
icon={ClockIcon}
className={className}
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value));
}}
value={interval}
items={[
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'month',
label: 'Month',
disabled:
range === 'today' || range === 'lastHour' || range === '30min',
},
]}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
icon={ClockIcon}
className={cn('justify-start', className)}
>
{items.find((item) => item.value === interval)?.label || 'Interval'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="row items-center justify-between">
Select interval
{!!selectedItem && (
<CommandShortcut>{selectedItem?.label}</CommandShortcut>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value as IInterval)}
disabled={item.disabled}
>
{item.label}
{item.value === interval && (
<DropdownMenuShortcut>
<ClockIcon className="size-4" />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -5,22 +5,26 @@ import { cn } from '@/utils/cn';
import { CopyIcon } from 'lucide-react';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
SyntaxHighlighter.registerLanguage('typescript', ts);
SyntaxHighlighter.registerLanguage('json', json);
SyntaxHighlighter.registerLanguage('bash', bash);
interface SyntaxProps {
code: string;
className?: string;
language?: 'typescript' | 'bash';
language?: 'typescript' | 'bash' | 'json';
wrapLines?: boolean;
}
export default function Syntax({
code,
className,
language = 'typescript',
wrapLines = false,
}: SyntaxProps) {
return (
<div className={cn('group relative rounded-lg', className)}>
@@ -35,7 +39,7 @@ export default function Syntax({
<CopyIcon size={12} />
</button>
<SyntaxHighlighter
// wrapLongLines
wrapLongLines={wrapLines}
style={docco}
language={language}
customStyle={{

View File

@@ -6,17 +6,21 @@ import * as React from 'react';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
orientation?: 'vertical' | 'horizontal';
}
>(({ className, children, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport
className="h-full w-full rounded-[inherit]"
ref={ref}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation={orientation} />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));

View File

@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export const useScrollAnchor = () => {
const messagesRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const visibilityRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isVisible, setIsVisible] = useState(false);
const scrollToBottom = useCallback(() => {
if (messagesRef.current) {
messagesRef.current.scrollIntoView({
block: 'end',
behavior: 'smooth',
});
}
}, []);
useEffect(() => {
if (messagesRef.current) {
if (isAtBottom && !isVisible) {
messagesRef.current.scrollIntoView({
block: 'end',
});
}
}
}, [isAtBottom, isVisible]);
useEffect(() => {
const { current } = scrollRef;
if (current) {
const handleScroll = (event: Event) => {
const target = event.target as HTMLDivElement;
const offset = 20;
const isAtBottom =
target.scrollTop + target.clientHeight >=
target.scrollHeight - offset;
setIsAtBottom(isAtBottom);
};
current.addEventListener('scroll', handleScroll, {
passive: true,
});
return () => {
current.removeEventListener('scroll', handleScroll);
};
}
}, []);
useEffect(() => {
if (visibilityRef.current) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
});
observer.observe(visibilityRef.current);
return () => {
observer.disconnect();
};
}
});
return {
messagesRef,
scrollRef,
visibilityRef,
scrollToBottom,
isAtBottom,
isVisible,
};
};

View File

@@ -18,7 +18,7 @@ import { ModalContent, ModalHeader } from './Modal/Container';
type SaveReportProps = {
report: IChartProps;
reportId?: string;
disableRedirect?: boolean;
};
const validator = z.object({
@@ -28,7 +28,10 @@ const validator = z.object({
type IForm = z.infer<typeof validator>;
export default function SaveReport({ report }: SaveReportProps) {
export default function SaveReport({
report,
disableRedirect,
}: SaveReportProps) {
const router = useRouter();
const { organizationId, projectId } = useAppParams();
const searchParams = useSearchParams();
@@ -37,15 +40,27 @@ export default function SaveReport({ report }: SaveReportProps) {
const save = api.report.create.useMutation({
onError: handleError,
onSuccess(res) {
toast('Success', {
description: 'Report saved.',
const goToReport = () => {
router.push(
`/${organizationId}/${projectId}/reports/${
res.id
}?${searchParams?.toString()}`,
);
};
toast('Report created', {
description: <div>Hello world</div>,
action: {
label: 'View report',
onClick: () => goToReport(),
},
});
if (!disableRedirect) {
goToReport();
}
popModal();
router.push(
`/${organizationId}/${projectId}/reports/${
res.id
}?${searchParams?.toString()}`,
);
},
});

View File

@@ -168,4 +168,4 @@
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}