server side events and ui improvemnt
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
# Ready for docker-compose
|
# Ready for docker-compose
|
||||||
REDIS_URL="redis://127.0.0.1:6379"
|
REDIS_URL="redis://127.0.0.1:6379"
|
||||||
DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable"
|
DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable"
|
||||||
NEXTAUTH_SECRET="secret_sauce"
|
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from 'react';
|
|
||||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1;
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000;
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string;
|
|
||||||
title?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
action?: ToastActionElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionTypes = {
|
|
||||||
ADD_TOAST: 'ADD_TOAST',
|
|
||||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
|
||||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
|
||||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_VALUE;
|
|
||||||
return count.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActionType = typeof actionTypes;
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: ActionType['ADD_TOAST'];
|
|
||||||
toast: ToasterToast;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType['UPDATE_TOAST'];
|
|
||||||
toast: Partial<ToasterToast>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType['DISMISS_TOAST'];
|
|
||||||
toastId?: ToasterToast['id'];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: ActionType['REMOVE_TOAST'];
|
|
||||||
toastId?: ToasterToast['id'];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId);
|
|
||||||
dispatch({
|
|
||||||
type: 'REMOVE_TOAST',
|
|
||||||
toastId: toastId,
|
|
||||||
});
|
|
||||||
}, TOAST_REMOVE_DELAY);
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'ADD_TOAST':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'UPDATE_TOAST':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'DISMISS_TOAST': {
|
|
||||||
const { toastId } = action;
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId);
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'REMOVE_TOAST':
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listeners: ((state: State) => void)[] = [];
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] };
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action);
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Toast = Omit<ToasterToast, 'id'>;
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId();
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_TOAST',
|
|
||||||
toast: { ...props, id },
|
|
||||||
});
|
|
||||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'ADD_TOAST',
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState);
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState);
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast };
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function clipboard(value: string | number) {
|
export function clipboard(value: string | number) {
|
||||||
navigator.clipboard.writeText(value.toString());
|
navigator.clipboard.writeText(value.toString());
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"@mixan/common": "workspace:*",
|
"@mixan/common": "workspace:*",
|
||||||
"@mixan/db": "workspace:*",
|
"@mixan/db": "workspace:*",
|
||||||
"@mixan/queue": "workspace:*",
|
"@mixan/queue": "workspace:*",
|
||||||
|
"@mixan/redis": "workspace:*",
|
||||||
"fastify": "^4.25.2",
|
"fastify": "^4.25.2",
|
||||||
|
"fastify-sse-v2": "^3.1.2",
|
||||||
"pino": "^8.17.2",
|
"pino": "^8.17.2",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
|
|||||||
96
apps/sdk-api/src/controllers/live.controller.ts
Normal file
96
apps/sdk-api/src/controllers/live.controller.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { combine } from '@/sse/combine';
|
||||||
|
import { redisMessageIterator } from '@/sse/redis-message-iterator';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import { getSafeJson } from '@mixan/common';
|
||||||
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
import { chQuery, getEvents } from '@mixan/db';
|
||||||
|
import { redis, redisPub, redisSub } from '@mixan/redis';
|
||||||
|
|
||||||
|
async function getLiveCount(projectId: string) {
|
||||||
|
const keys = await redis.keys(`live:event:${projectId}:*`);
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiveEventInfo(key: string) {
|
||||||
|
return key.split(':').slice(2) as [string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function test(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const [event] = await getEvents(
|
||||||
|
`SELECT * FROM events LIMIT 1 OFFSET ${Math.floor(Math.random() * 1000)}`
|
||||||
|
);
|
||||||
|
if (!event) {
|
||||||
|
return reply.status(404).send('No event found');
|
||||||
|
}
|
||||||
|
redisPub.publish('event', JSON.stringify(event));
|
||||||
|
redis.set(`live:event:${event.projectId}:${event.profileId}`, '', 'EX', 10);
|
||||||
|
reply.status(202).send('OK');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function events(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Params: { projectId: string };
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const reqProjectId = request.params.projectId;
|
||||||
|
|
||||||
|
// Subscribe
|
||||||
|
redisSub.subscribe('event');
|
||||||
|
redisSub.psubscribe('__key*:*');
|
||||||
|
const listeners: ((...args: any[]) => void)[] = [];
|
||||||
|
|
||||||
|
const incomingEvents = redisMessageIterator({
|
||||||
|
listenOn: 'message',
|
||||||
|
async transformer(message) {
|
||||||
|
const event = getSafeJson<IServiceCreateEventPayload>(message);
|
||||||
|
if (event && event.projectId === reqProjectId) {
|
||||||
|
return {
|
||||||
|
visitors: await getLiveCount(event.projectId),
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
registerListener(fn) {
|
||||||
|
listeners.push(fn);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiredEvents = redisMessageIterator({
|
||||||
|
listenOn: 'pmessage',
|
||||||
|
async transformer(message) {
|
||||||
|
// message = live:event:${projectId}:${profileId}
|
||||||
|
const [projectId] = getLiveEventInfo(message);
|
||||||
|
if (projectId && projectId === reqProjectId) {
|
||||||
|
return {
|
||||||
|
visitors: await getLiveCount(projectId),
|
||||||
|
event: null as null | IServiceCreateEventPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
registerListener(fn) {
|
||||||
|
listeners.push(fn);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function* consumeMessages() {
|
||||||
|
for await (const result of combine([incomingEvents, expiredEvents])) {
|
||||||
|
if (result.data) {
|
||||||
|
yield {
|
||||||
|
data: JSON.stringify(result.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.sse(consumeMessages());
|
||||||
|
|
||||||
|
reply.raw.on('close', () => {
|
||||||
|
redisSub.unsubscribe('event');
|
||||||
|
redisSub.punsubscribe('__key*:expired');
|
||||||
|
listeners.forEach((listener) => redisSub.off('message', listener));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
|
import { FastifySSEPlugin } from 'fastify-sse-v2';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
|
|
||||||
|
import { redisPub } from '@mixan/redis';
|
||||||
|
|
||||||
import eventRouter from './routes/event.router';
|
import eventRouter from './routes/event.router';
|
||||||
import { validateSdkRequest } from './utils/auth';
|
import liveRouter from './routes/live.router';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
@@ -23,22 +26,10 @@ const startServer = async () => {
|
|||||||
origin: '*',
|
origin: '*',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.register(FastifySSEPlugin);
|
||||||
fastify.decorateRequest('projectId', '');
|
fastify.decorateRequest('projectId', '');
|
||||||
|
|
||||||
fastify.addHook('preHandler', (req, reply, done) => {
|
|
||||||
validateSdkRequest(req.headers)
|
|
||||||
.then((projectId) => {
|
|
||||||
req.projectId = projectId;
|
|
||||||
done();
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log(e);
|
|
||||||
|
|
||||||
reply.status(401).send();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.register(eventRouter, { prefix: '/event' });
|
fastify.register(eventRouter, { prefix: '/event' });
|
||||||
|
fastify.register(liveRouter, { prefix: '/live' });
|
||||||
fastify.setErrorHandler((error, request, reply) => {
|
fastify.setErrorHandler((error, request, reply) => {
|
||||||
fastify.log.error(error);
|
fastify.log.error(error);
|
||||||
});
|
});
|
||||||
@@ -65,6 +56,9 @@ const startServer = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fastify.listen({ host: '0.0.0.0', port });
|
await fastify.listen({ host: '0.0.0.0', port });
|
||||||
|
|
||||||
|
// Notify when keys expires
|
||||||
|
redisPub.config('SET', 'notify-keyspace-events', 'Ex');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import * as controller from '@/controllers/event.controller';
|
import * as controller from '@/controllers/event.controller';
|
||||||
|
import { validateSdkRequest } from '@/utils/auth';
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||||
|
fastify.addHook('preHandler', (req, reply, done) => {
|
||||||
|
validateSdkRequest(req.headers)
|
||||||
|
.then((projectId) => {
|
||||||
|
req.projectId = projectId;
|
||||||
|
done();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
reply.status(401).send();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fastify.route({
|
fastify.route({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/',
|
url: '/',
|
||||||
|
|||||||
19
apps/sdk-api/src/routes/live.router.ts
Normal file
19
apps/sdk-api/src/routes/live.router.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as controller from '@/controllers/live.controller';
|
||||||
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
|
const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/events/test',
|
||||||
|
handler: controller.test,
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/events/:projectId',
|
||||||
|
handler: controller.events,
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default liveRouter;
|
||||||
33
apps/sdk-api/src/sse/combine.ts
Normal file
33
apps/sdk-api/src/sse/combine.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
export async function* combine<T>(iterable: AsyncGenerator<T>[]): T[] {
|
||||||
|
const asyncIterators = Array.from(iterable, (o) => o[Symbol.asyncIterator]());
|
||||||
|
const results = [];
|
||||||
|
let count = asyncIterators.length;
|
||||||
|
const never = new Promise(() => {});
|
||||||
|
function getNext(asyncIterator: AsyncGenerator, index: number) {
|
||||||
|
return asyncIterator.next().then((result) => ({
|
||||||
|
index,
|
||||||
|
result,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const nextPromises = asyncIterators.map(getNext);
|
||||||
|
try {
|
||||||
|
while (count) {
|
||||||
|
const { index, result } = await Promise.race(nextPromises);
|
||||||
|
if (result.done) {
|
||||||
|
nextPromises[index] = never;
|
||||||
|
results[index] = result.value;
|
||||||
|
count--;
|
||||||
|
} else {
|
||||||
|
nextPromises[index] = getNext(asyncIterators[index], index);
|
||||||
|
yield result.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const [index, iterator] of asyncIterators.entries())
|
||||||
|
if (nextPromises[index] != never && iterator.return != null)
|
||||||
|
iterator.return();
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
49
apps/sdk-api/src/sse/redis-message-iterator.ts
Normal file
49
apps/sdk-api/src/sse/redis-message-iterator.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { redisSub } from '@mixan/redis';
|
||||||
|
|
||||||
|
export async function* redisMessageIterator<T>(opts: {
|
||||||
|
transformer: (payload: string) => Promise<T>;
|
||||||
|
listenOn: string;
|
||||||
|
registerListener: (listener: (...args: any[]) => void) => void;
|
||||||
|
}) {
|
||||||
|
// Subscribe to a channel
|
||||||
|
interface Payload {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
// Promise resolver to signal new messages
|
||||||
|
let messageNotifier: null | ((payload: Payload) => void) = null;
|
||||||
|
|
||||||
|
// Promise to wait for new messages
|
||||||
|
let waitForMessage: Promise<Payload> = new Promise<Payload>((resolve) => {
|
||||||
|
messageNotifier = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function listener(pattern: string, channel: string, message: string) {
|
||||||
|
const data = await opts.transformer(
|
||||||
|
pattern && channel && message ? message : channel
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve the waiting promise to notify the generator of new message arrival
|
||||||
|
if (typeof messageNotifier === 'function') {
|
||||||
|
messageNotifier({ data });
|
||||||
|
}
|
||||||
|
// Clear the notifier to avoid multiple resolutions for a single message
|
||||||
|
messageNotifier = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listener for messages on the subscribed channel
|
||||||
|
redisSub.on(opts.listenOn, listener);
|
||||||
|
opts.registerListener(listener);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Wait for a new message
|
||||||
|
const { data } = await waitForMessage;
|
||||||
|
|
||||||
|
// Reset the waiting promise for the next message
|
||||||
|
waitForMessage = new Promise((resolve) => {
|
||||||
|
messageNotifier = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yield the received message
|
||||||
|
yield { data };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,6 @@ ENV REDIS_URL=$REDIS_URL
|
|||||||
ARG NEXTAUTH_SECRET
|
ARG NEXTAUTH_SECRET
|
||||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||||
|
|
||||||
ARG NEXTAUTH_URL
|
|
||||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
@@ -51,12 +52,14 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"next": "~14.0.4",
|
"next": "~14.0.4",
|
||||||
"next-auth": "^4.23.0",
|
"next-auth": "^4.23.0",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^1.15.2",
|
"nuqs": "^1.15.2",
|
||||||
"prisma-error-enum": "^0.1.3",
|
"prisma-error-enum": "^0.1.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"random-animal-name": "^0.1.1",
|
"random-animal-name": "^0.1.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
|
"react-animated-numbers": "^0.18.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.47.0",
|
||||||
"react-in-viewport": "1.0.0-alpha.30",
|
"react-in-viewport": "1.0.0-alpha.30",
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"sonner": "^1.4.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ListReports({ reports }: ListReportsProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 pl-2',
|
'p-4',
|
||||||
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
|
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Card, CardActions, CardActionsItem } from '@/components/Card';
|
|||||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ToastAction } from '@/components/ui/toast';
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type { IServiceDashboards } from '@/server/services/dashboard.service';
|
import type { IServiceDashboards } from '@/server/services/dashboard.service';
|
||||||
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
|
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ListDashboardsProps {
|
interface ListDashboardsProps {
|
||||||
dashboards: IServiceDashboards;
|
dashboards: IServiceDashboards;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function LinkWithIcon({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-slate-600 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
|
'text-slate-800 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
|
||||||
active && 'bg-blue-50',
|
active && 'bg-blue-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -134,13 +134,14 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{dashboards.map((item) => (
|
{dashboards.map((item) => (
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
className="py-1"
|
|
||||||
key={item.id}
|
key={item.id}
|
||||||
icon={LayoutPanelTopIcon}
|
icon={LayoutPanelTopIcon}
|
||||||
label={
|
label={
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex justify-between gap-0.5 items-center">
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
<span className="text-xs">{item.project.name}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.project.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
|
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import type { IServiceOrganization } from '@/server/services/organization.service';
|
import type { IServiceOrganization } from '@/server/services/organization.service';
|
||||||
import { Building } from 'lucide-react';
|
import { Building } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface LayoutOrganizationSelectorProps {
|
interface LayoutOrganizationSelectorProps {
|
||||||
organizations: IServiceOrganization[];
|
organizations: IServiceOrganization[];
|
||||||
@@ -12,6 +14,7 @@ export default function LayoutOrganizationSelector({
|
|||||||
organizations,
|
organizations,
|
||||||
}: LayoutOrganizationSelectorProps) {
|
}: LayoutOrganizationSelectorProps) {
|
||||||
const params = useAppParams();
|
const params = useAppParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const organization = organizations.find(
|
const organization = organizations.find(
|
||||||
(item) => item.slug === params.organizationId
|
(item) => item.slug === params.organizationId
|
||||||
@@ -22,9 +25,22 @@ export default function LayoutOrganizationSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-border p-3 flex gap-2 rounded items-center">
|
<Combobox
|
||||||
<Building size={20} />
|
className="w-full"
|
||||||
<span className="font-medium text-sm">{organization.name}</span>
|
placeholder="Select organization"
|
||||||
</div>
|
icon={Building}
|
||||||
|
value={organization.slug}
|
||||||
|
items={
|
||||||
|
organizations
|
||||||
|
.filter((item) => item.slug)
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.slug!,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
router.push(`/${value}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function LayoutProjectSelector({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Combobox
|
<Combobox
|
||||||
|
align="end"
|
||||||
className="w-auto min-w-0 max-sm:max-w-[100px]"
|
className="w-auto min-w-0 max-sm:max-w-[100px]"
|
||||||
placeholder={'Select project'}
|
placeholder={'Select project'}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import AnimatedNumbers from 'react-animated-numbers';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { getSafeJson } from '@mixan/common';
|
||||||
|
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||||
|
|
||||||
|
interface LiveCounterProps {
|
||||||
|
initialCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [counter, setCounter] = useState(initialCount);
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const [es] = useState(
|
||||||
|
typeof window != 'undefined' &&
|
||||||
|
new EventSource(`http://localhost:3333/live/events/${projectId}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!es) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler(event: MessageEvent<string>) {
|
||||||
|
const parsed = getSafeJson<{
|
||||||
|
visitors: number;
|
||||||
|
event: IServiceCreateEventPayload | null;
|
||||||
|
}>(event.data);
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
setCounter(parsed.visitors);
|
||||||
|
if (parsed.event) {
|
||||||
|
client.refetchQueries({
|
||||||
|
type: 'active',
|
||||||
|
});
|
||||||
|
toast('New event', {
|
||||||
|
description: `${parsed.event.name}`,
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
es.addEventListener('message', handler);
|
||||||
|
return () => es.removeEventListener('message', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
|
||||||
|
counter === 0 && 'bg-destructive opacity-0'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
|
||||||
|
counter === 0 && 'bg-destructive'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<AnimatedNumbers
|
||||||
|
includeComma
|
||||||
|
transitions={(index) => ({
|
||||||
|
type: 'spring',
|
||||||
|
duration: index + 0.3,
|
||||||
|
})}
|
||||||
|
animateToNumber={counter}
|
||||||
|
locale="en"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{counter} unique visitors last 5 minutes
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { OverviewFilters } from '@/components/overview/overview-filters';
|
import { OverviewFilters } from '@/components/overview/overview-filters';
|
||||||
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
|
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
|
||||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||||
@@ -10,6 +11,8 @@ import OverviewTopSources from '@/components/overview/overview-top-sources';
|
|||||||
import { WidgetHead } from '@/components/overview/overview-widget';
|
import { WidgetHead } from '@/components/overview/overview-widget';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
|
import { MetricCardLoading } from '@/components/report/chart/MetricCard';
|
||||||
import { ReportRange } from '@/components/report/ReportRange';
|
import { ReportRange } from '@/components/report/ReportRange';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +30,7 @@ import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||||
|
import { LiveCounter } from './live-counter';
|
||||||
|
|
||||||
export default function OverviewMetrics() {
|
export default function OverviewMetrics() {
|
||||||
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
||||||
@@ -200,6 +204,7 @@ export default function OverviewMetrics() {
|
|||||||
onChange={(value) => setRange(value)}
|
onChange={(value) => setRange(value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-wrap flex gap-2">
|
<div className="flex-wrap flex gap-2">
|
||||||
|
<LiveCounter initialCount={0} />
|
||||||
<OverviewFiltersButtons />
|
<OverviewFiltersButtons />
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button size="sm" variant="cta" icon={FilterIcon}>
|
<Button size="sm" variant="cta" icon={FilterIcon}>
|
||||||
@@ -240,8 +245,9 @@ export default function OverviewMetrics() {
|
|||||||
setMetric(index);
|
setMetric(index);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Suspense fallback={<MetricCardLoading />}>
|
||||||
<Chart hideID {...report} />
|
<Chart hideID {...report} />
|
||||||
|
</Suspense>
|
||||||
{/* add active border */}
|
{/* add active border */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -256,14 +262,18 @@ export default function OverviewMetrics() {
|
|||||||
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart hideID {...selectedMetric} chartType="linear" />
|
<Chart hideID {...selectedMetric} chartType="linear" />
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
<OverviewTopSources />
|
<OverviewTopSources />
|
||||||
<OverviewTopPages />
|
<OverviewTopPages />
|
||||||
<OverviewTopDevices />
|
<OverviewTopDevices />
|
||||||
<OverviewTopGeo />
|
|
||||||
<OverviewTopEvents />
|
<OverviewTopEvents />
|
||||||
|
<div className="col-span-6">
|
||||||
|
<OverviewTopGeo />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetContent className="!max-w-lg w-full" side="left">
|
<SheetContent className="!max-w-lg w-full" side="left">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { Suspense, useEffect } from 'react';
|
||||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||||
@@ -16,11 +17,9 @@ import {
|
|||||||
} from '@/components/report/reportSlice';
|
} from '@/components/report/reportSlice';
|
||||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import type { IServiceReport } from '@/server/services/reports.service';
|
import type { IServiceReport } from '@/server/services/reports.service';
|
||||||
import { timeRanges } from '@/utils/constants';
|
|
||||||
import { GanttChartSquareIcon } from 'lucide-react';
|
import { GanttChartSquareIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface ReportEditorProps {
|
interface ReportEditorProps {
|
||||||
@@ -73,7 +72,11 @@ export default function ReportEditor({
|
|||||||
</div>
|
</div>
|
||||||
</StickyBelowHeader>
|
</StickyBelowHeader>
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{report.ready && <Chart {...report} editMode />}
|
{report.ready && (
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
|
<Chart {...report} editMode />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SheetContent className="!max-w-lg w-full" side="left">
|
<SheetContent className="!max-w-lg w-full" side="left">
|
||||||
<ReportSidebar />
|
<ReportSidebar />
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { api, handleError } from '@/app/_trpc/client';
|
import { api, handleError } from '@/app/_trpc/client';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||||
import type { getOrganizationBySlug } from '@/server/services/organization.service';
|
import type { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const validator = z.object({
|
const validator = z.object({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { api } from '@/app/_trpc/client';
|
import { api } from '@/app/_trpc/client';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { zInviteUser } from '@/utils/validation';
|
import { zInviteUser } from '@/utils/validation';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { SendIcon } from 'lucide-react';
|
import { SendIcon } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zInviteUser>;
|
type IForm = z.infer<typeof zInviteUser>;
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { api, handleError } from '@/app/_trpc/client';
|
import { api, handleError } from '@/app/_trpc/client';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
|
||||||
import type { getUserById } from '@/server/services/user.service';
|
import type { getUserById } from '@/server/services/user.service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const validator = z.object({
|
const validator = z.object({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Toast } from '@/components/ui/use-toast';
|
import type { Toast } from '@/components/ui/use-toast';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import type { AppRouter } from '@/server/api/root';
|
import type { AppRouter } from '@/server/api/root';
|
||||||
import type { TRPCClientErrorBase } from '@trpc/react-query';
|
import type { TRPCClientErrorBase } from '@trpc/react-query';
|
||||||
import { createTRPCReact } from '@trpc/react-query';
|
import { createTRPCReact } from '@trpc/react-query';
|
||||||
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const api = createTRPCReact<AppRouter>({});
|
export const api = createTRPCReact<AppRouter>({});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import Providers from './providers';
|
|||||||
|
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
export const metadata = {};
|
export const metadata = {
|
||||||
|
title: 'Overview - Openpanel.dev',
|
||||||
|
};
|
||||||
|
|
||||||
export const viewport = {
|
export const viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import { BoxSelectIcon } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface FullPageEmptyStateProps {
|
interface FullPageEmptyStateProps {
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FullPageEmptyState({
|
export function FullPageEmptyState({
|
||||||
icon: Icon,
|
icon: Icon = BoxSelectIcon,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}: FullPageEmptyStateProps) {
|
}: FullPageEmptyStateProps) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-4 border-b border-border [&_.title]:font-medium',
|
'p-4 border-b border-border [&_.title]:font-medium [&_.title]:whitespace-nowrap',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '../Widget';
|
import { Widget, WidgetBody } from '../Widget';
|
||||||
@@ -172,6 +174,7 @@ export default function OverviewTopDevices() {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart
|
<Chart
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
@@ -192,6 +195,7 @@ export default function OverviewTopDevices() {
|
|||||||
// }
|
// }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '../Widget';
|
import { Widget, WidgetBody } from '../Widget';
|
||||||
@@ -67,7 +69,9 @@ export default function OverviewTopEvents() {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart hideID {...widget.chart} previous={false} />
|
<Chart hideID {...widget.chart} previous={false} />
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '../Widget';
|
import { Widget, WidgetBody } from '../Widget';
|
||||||
@@ -144,6 +146,7 @@ export default function OverviewTopGeo() {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart
|
<Chart
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
@@ -164,6 +167,7 @@ export default function OverviewTopGeo() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '../Widget';
|
import { Widget, WidgetBody } from '../Widget';
|
||||||
@@ -115,6 +117,7 @@ export default function OverviewTopPages() {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart
|
<Chart
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
@@ -123,6 +126,7 @@ export default function OverviewTopPages() {
|
|||||||
setPage(item.name);
|
setPage(item.name);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Chart } from '@/components/report/chart';
|
import { Chart } from '@/components/report/chart';
|
||||||
import type { IChartInput } from '@/types';
|
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '../Widget';
|
import { Widget, WidgetBody } from '../Widget';
|
||||||
@@ -211,6 +212,7 @@ export default function OverviewTopSources() {
|
|||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
<Chart
|
<Chart
|
||||||
hideID
|
hideID
|
||||||
{...widget.chart}
|
{...widget.chart}
|
||||||
@@ -238,6 +240,7 @@ export default function OverviewTopSources() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import type { WidgetHeadProps } from '../Widget';
|
import type { WidgetHeadProps } from '../Widget';
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { api, handleError } from '@/app/_trpc/client';
|
import { api, handleError } from '@/app/_trpc/client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { SaveIcon } from 'lucide-react';
|
import { SaveIcon } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { resetDirty } from './reportSlice';
|
import { resetDirty } from './reportSlice';
|
||||||
|
|
||||||
|
|||||||
31
apps/web/src/components/report/chart/ChartEmpty.tsx
Normal file
31
apps/web/src/components/report/chart/ChartEmpty.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
import { useChartContext } from './ChartProvider';
|
||||||
|
import { MetricCardEmpty } from './MetricCard';
|
||||||
|
|
||||||
|
export function ChartEmpty() {
|
||||||
|
const { editMode, chartType } = useChartContext();
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
return (
|
||||||
|
<FullPageEmptyState title="No data">
|
||||||
|
We could not find any data for selected events and filter.
|
||||||
|
</FullPageEmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'metric') {
|
||||||
|
return <MetricCardEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'aspect-video w-full max-h-[400px] flex justify-center items-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/web/src/components/report/chart/ChartLoading.tsx
Normal file
15
apps/web/src/components/report/chart/ChartLoading.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
interface ChartLoadingProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export function ChartLoading({ className }: ChartLoadingProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'aspect-video w-full bg-slate-200 animate-pulse rounded max-h-[400px] min-h-[200px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { Suspense, useEffect, useRef } from 'react';
|
||||||
import { useInViewport } from 'react-in-viewport';
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
|
||||||
import type { ReportChartProps } from '.';
|
import type { ReportChartProps } from '.';
|
||||||
import { Chart } from '.';
|
import { Chart } from '.';
|
||||||
|
import { ChartLoading } from './ChartLoading';
|
||||||
import type { ChartContextType } from './ChartProvider';
|
import type { ChartContextType } from './ChartProvider';
|
||||||
|
|
||||||
export function LazyChart(props: ReportChartProps & ChartContextType) {
|
export function LazyChart(props: ReportChartProps & ChartContextType) {
|
||||||
@@ -22,11 +23,13 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
|
<Suspense fallback={<ChartLoading />}>
|
||||||
{once.current || inViewport ? (
|
{once.current || inViewport ? (
|
||||||
<Chart {...props} editMode={false} />
|
<Chart {...props} editMode={false} />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-64 w-full bg-gray-200 animate-pulse rounded" />
|
<ChartLoading />
|
||||||
)}
|
)}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function MetricCard({
|
|||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden h-24"
|
||||||
key={serie.name}
|
key={serie.name}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
|
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
|
||||||
@@ -79,3 +79,22 @@ export function MetricCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MetricCardEmpty() {
|
||||||
|
return (
|
||||||
|
<div className="border border-border p-4 rounded-md bg-white h-24">
|
||||||
|
<div className="flex items-center justify-center h-full text-slate-600">
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCardLoading() {
|
||||||
|
return (
|
||||||
|
<div className="h-24 p-4 py-5 flex flex-col bg-white border border-border rounded-md">
|
||||||
|
<div className="bg-slate-200 rounded animate-pulse h-4 w-1/2"></div>
|
||||||
|
<div className="bg-slate-200 rounded animate-pulse h-6 w-1/5 mt-auto"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import type { IChartData } from '@/app/_trpc/client';
|
import type { IChartData } from '@/app/_trpc/client';
|
||||||
import { AutoSizer } from '@/components/AutoSizer';
|
import { AutoSizer } from '@/components/AutoSizer';
|
||||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||||
@@ -48,7 +49,7 @@ export function ReportAreaChart({
|
|||||||
{({ width }) => (
|
{({ width }) => (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
width={width}
|
width={width}
|
||||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||||
data={rechartData}
|
data={rechartData}
|
||||||
>
|
>
|
||||||
<Tooltip content={<ReportChartTooltip />} />
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
@@ -69,18 +70,41 @@ export function ReportAreaChart({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{series.map((serie) => {
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={serie.name}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={`color${color}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
></stop>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<Area
|
<Area
|
||||||
key={serie.name}
|
key={serie.name}
|
||||||
type={lineType}
|
type={lineType}
|
||||||
isAnimationActive={false}
|
isAnimationActive={true}
|
||||||
strokeWidth={0}
|
strokeWidth={2}
|
||||||
dataKey={`${serie.index}:count`}
|
dataKey={`${serie.index}:count`}
|
||||||
stroke={getChartColor(serie.index)}
|
stroke={color}
|
||||||
fill={getChartColor(serie.index)}
|
fill={`url(#color${color})`}
|
||||||
stackId={'1'}
|
stackId={'1'}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
/>
|
/>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
||||||
import { ColorSquare } from '@/components/ColorSquare';
|
import { ColorSquare } from '@/components/ColorSquare';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,153 +18,121 @@ import {
|
|||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import {
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import type { SortingState } from '@tanstack/react-table';
|
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
|
||||||
|
|
||||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||||
import { useChartContext } from './ChartProvider';
|
import { useChartContext } from './ChartProvider';
|
||||||
|
|
||||||
const columnHelper =
|
|
||||||
createColumnHelper<RouterOutputs['chart']['chart']['series'][number]>();
|
|
||||||
|
|
||||||
interface ReportBarChartProps {
|
interface ReportBarChartProps {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||||
const { editMode, metric, unit, onClick } = useChartContext();
|
const { editMode, metric, unit, onClick } = useChartContext();
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const maxCount = Math.max(
|
|
||||||
...data.series.map((serie) => serie.metrics[metric])
|
|
||||||
);
|
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const table = useReactTable({
|
const series = useMemo(
|
||||||
data: useMemo(
|
|
||||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||||
[editMode, data]
|
[data]
|
||||||
),
|
|
||||||
columns: useMemo(() => {
|
|
||||||
return [
|
|
||||||
columnHelper.accessor((row) => row.name, {
|
|
||||||
id: 'label',
|
|
||||||
header: () => 'Label',
|
|
||||||
cell(info) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ColorSquare>{info.row.original.event.id}</ColorSquare>
|
|
||||||
<Tooltip delayDuration={200}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="text-ellipsis overflow-hidden">
|
|
||||||
{info.getValue()}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{info.getValue()}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||||
}),
|
|
||||||
columnHelper.accessor((row) => row.metrics[metric], {
|
|
||||||
id: 'totalCount',
|
|
||||||
cell: (info) => (
|
|
||||||
<div className="flex gap-4 w-full">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<div
|
|
||||||
className="top-0 absolute shine h-[20px] rounded-full"
|
|
||||||
style={{
|
|
||||||
width: (info.getValue() / maxCount) * 100 + '%',
|
|
||||||
background: getChartColor(info.row.index),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="font-bold">
|
|
||||||
{number.format(info.getValue())}
|
|
||||||
{unit}
|
|
||||||
</div>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
{...info.row.original.metrics.previous[metric]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
header: () => 'Count',
|
|
||||||
enableSorting: true,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [maxCount, number]),
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
|
||||||
overflow={editMode}
|
|
||||||
className={cn('table-fixed', editMode ? '' : 'mini')}
|
|
||||||
>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
{...{
|
|
||||||
colSpan: header.colSpan,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
{...{
|
className={cn(
|
||||||
className: cn(
|
'flex flex-col w-full divide-y text-xs',
|
||||||
'flex items-center gap-2',
|
editMode &&
|
||||||
header.column.getCanSort() && 'cursor-pointer select-none'
|
'text-base bg-white border border-border rounded-md p-4 pt-2'
|
||||||
),
|
|
||||||
onClick: header.column.getToggleSortingHandler(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
)}
|
||||||
{{
|
|
||||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
|
||||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
|
||||||
}[header.column.getIsSorted() as string] ?? null}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
{...(onClick
|
|
||||||
? {
|
|
||||||
onClick() {
|
|
||||||
onClick(row.original);
|
|
||||||
},
|
|
||||||
className: 'cursor-pointer',
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{editMode && (
|
||||||
<TableCell key={cell.id}>
|
<div className="-m-4 -mb-px flex justify-between font-medium p-4 pt-5 border-b border-border font-medium text-muted-foreground">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
<div>Event</div>
|
||||||
</TableCell>
|
<div>Count</div>
|
||||||
))}
|
</div>
|
||||||
</TableRow>
|
)}
|
||||||
))}
|
{series.map((serie, index) => {
|
||||||
</TableBody>
|
return (
|
||||||
</Table>
|
<div
|
||||||
|
key={serie.name}
|
||||||
|
className="py-2 flex flex-1 w-full gap-4 items-center"
|
||||||
|
>
|
||||||
|
<div className="flex-1 break-all">{serie.name}</div>
|
||||||
|
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
|
||||||
|
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
||||||
|
<div className="font-bold">
|
||||||
|
{number.format(serie.metrics.sum)}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
color={getChartColor(index)}
|
||||||
|
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
|
||||||
|
value={(serie.metrics.sum / maxCount) * 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <Table
|
||||||
|
// overflow={editMode}
|
||||||
|
// className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||||
|
// >
|
||||||
|
// <TableHeader>
|
||||||
|
// {table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
// <TableRow key={headerGroup.id}>
|
||||||
|
// {headerGroup.headers.map((header) => (
|
||||||
|
// <TableHead
|
||||||
|
// key={header.id}
|
||||||
|
// {...{
|
||||||
|
// colSpan: header.colSpan,
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <div
|
||||||
|
// {...{
|
||||||
|
// className: cn(
|
||||||
|
// 'flex items-center gap-2',
|
||||||
|
// header.column.getCanSort() && 'cursor-pointer select-none'
|
||||||
|
// ),
|
||||||
|
// onClick: header.column.getToggleSortingHandler(),
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {flexRender(
|
||||||
|
// header.column.columnDef.header,
|
||||||
|
// header.getContext()
|
||||||
|
// )}
|
||||||
|
// {{
|
||||||
|
// asc: <ChevronUp className="ml-auto" size={14} />,
|
||||||
|
// desc: <ChevronDown className="ml-auto" size={14} />,
|
||||||
|
// }[header.column.getIsSorted() as string] ?? null}
|
||||||
|
// </div>
|
||||||
|
// </TableHead>
|
||||||
|
// ))}
|
||||||
|
// </TableRow>
|
||||||
|
// ))}
|
||||||
|
// </TableHeader>
|
||||||
|
// <TableBody>
|
||||||
|
// {table.getRowModel().rows.map((row) => (
|
||||||
|
// <TableRow
|
||||||
|
// key={row.id}
|
||||||
|
// {...(onClick
|
||||||
|
// ? {
|
||||||
|
// onClick() {
|
||||||
|
// onClick(row.original);
|
||||||
|
// },
|
||||||
|
// className: 'cursor-pointer',
|
||||||
|
// }
|
||||||
|
// : {})}
|
||||||
|
// >
|
||||||
|
// {row.getVisibleCells().map((cell) => (
|
||||||
|
// <TableCell key={cell.id}>
|
||||||
|
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
// </TableCell>
|
||||||
|
// ))}
|
||||||
|
// </TableRow>
|
||||||
|
// ))}
|
||||||
|
// </TableBody>
|
||||||
|
// </Table>
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function ReportChartTooltip({
|
|||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
}: ReportLineChartTooltipProps) {
|
}: ReportLineChartTooltipProps) {
|
||||||
const { previous, unit } = useChartContext();
|
const { unit } = useChartContext();
|
||||||
const getLabel = useMappings();
|
const getLabel = useMappings();
|
||||||
const interval = useSelector((state) => state.report.interval);
|
const interval = useSelector((state) => state.report.interval);
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
@@ -57,11 +57,6 @@ export function ReportChartTooltip({
|
|||||||
{index === 0 && data.date && (
|
{index === 0 && data.date && (
|
||||||
<div className="flex justify-between gap-8">
|
<div className="flex justify-between gap-8">
|
||||||
<div>{formatDate(new Date(data.date))}</div>
|
<div>{formatDate(new Date(data.date))}</div>
|
||||||
{/* {previous && data.previous?.date && (
|
|
||||||
<div className="text-slate-400 italic">
|
|
||||||
{formatDate(new Date(data.previous.date))}
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function ReportHistogramChart({
|
|||||||
{({ width }) => (
|
{({ width }) => (
|
||||||
<BarChart
|
<BarChart
|
||||||
width={width}
|
width={width}
|
||||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||||
data={rechartData}
|
data={rechartData}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function ReportLineChart({
|
|||||||
{({ width }) => (
|
{({ width }) => (
|
||||||
<LineChart
|
<LineChart
|
||||||
width={width}
|
width={width}
|
||||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||||
data={rechartData}
|
data={rechartData}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
@@ -80,7 +80,7 @@ export function ReportLineChart({
|
|||||||
type={lineType}
|
type={lineType}
|
||||||
key={serie.name}
|
key={serie.name}
|
||||||
name={serie.name}
|
name={serie.name}
|
||||||
isAnimationActive={false}
|
isAnimationActive={true}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dataKey={`${serie.index}:count`}
|
dataKey={`${serie.index}:count`}
|
||||||
stroke={getChartColor(serie.index)}
|
stroke={getChartColor(serie.index)}
|
||||||
@@ -90,7 +90,7 @@ export function ReportLineChart({
|
|||||||
type={lineType}
|
type={lineType}
|
||||||
key={`${serie.name}:prev`}
|
key={`${serie.name}:prev`}
|
||||||
name={`${serie.name}:prev`}
|
name={`${serie.name}:prev`}
|
||||||
isAnimationActive={false}
|
isAnimationActive={true}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeDasharray={'6 6'}
|
strokeDasharray={'6 6'}
|
||||||
|
|||||||
@@ -39,19 +39,16 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
|||||||
>
|
>
|
||||||
<AutoSizer disableHeight>
|
<AutoSizer disableHeight>
|
||||||
{({ width }) => {
|
{({ width }) => {
|
||||||
const height = Math.min(Math.max(width * 0.5, 250), 400);
|
const height = Math.min(Math.max(width * 0.5625, 250), 400);
|
||||||
return (
|
return (
|
||||||
<PieChart
|
<PieChart width={width} height={height}>
|
||||||
width={width}
|
|
||||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
|
||||||
>
|
|
||||||
<Tooltip content={<ReportChartTooltip />} />
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
<Pie
|
<Pie
|
||||||
dataKey={'count'}
|
dataKey={'count'}
|
||||||
data={pieData}
|
data={pieData}
|
||||||
innerRadius={height / 4}
|
innerRadius={height / 4}
|
||||||
outerRadius={height / 2.5}
|
outerRadius={height / 2.5}
|
||||||
isAnimationActive={false}
|
isAnimationActive={true}
|
||||||
label={renderLabel}
|
label={renderLabel}
|
||||||
>
|
>
|
||||||
{pieData.map((item) => {
|
{pieData.map((item) => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { round } from '@/utils/math';
|
import { round } from '@/utils/math';
|
||||||
|
|
||||||
export function getYAxisWidth(value: number) {
|
export function getYAxisWidth(value: number) {
|
||||||
return round(value, 0).toString().length * 7.5 + 7.5;
|
if (!isFinite(value)) {
|
||||||
|
return 7.8 + 7.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(value, 0).toString().length * 7.8 + 7.8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAppParams } from '@/hooks/useAppParams';
|
|||||||
import type { IChartInput } from '@/types';
|
import type { IChartInput } from '@/types';
|
||||||
|
|
||||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||||
|
import { ChartEmpty } from './ChartEmpty';
|
||||||
import { withChartProivder } from './ChartProvider';
|
import { withChartProivder } from './ChartProvider';
|
||||||
import { ReportAreaChart } from './ReportAreaChart';
|
import { ReportAreaChart } from './ReportAreaChart';
|
||||||
import { ReportBarChart } from './ReportBarChart';
|
import { ReportBarChart } from './ReportBarChart';
|
||||||
@@ -36,12 +37,7 @@ export const Chart = memo(
|
|||||||
initialData,
|
initialData,
|
||||||
}: ReportChartProps) {
|
}: ReportChartProps) {
|
||||||
const params = useAppParams();
|
const params = useAppParams();
|
||||||
const hasEmptyFilters = events.some((event) =>
|
const [data] = api.chart.chart.useSuspenseQuery(
|
||||||
event.filters.some((filter) => filter.value.length === 0)
|
|
||||||
);
|
|
||||||
const enabled = events.length > 0 && !hasEmptyFilters;
|
|
||||||
|
|
||||||
const chart = api.chart.chart.useQuery(
|
|
||||||
{
|
{
|
||||||
// dont send lineType since it does not need to be sent
|
// dont send lineType since it does not need to be sent
|
||||||
lineType: 'monotone',
|
lineType: 'monotone',
|
||||||
@@ -61,104 +57,46 @@ export const Chart = memo(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
enabled,
|
|
||||||
initialData,
|
initialData,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const anyData = Boolean(chart.data?.series?.[0]?.data);
|
if (data.series.length === 0) {
|
||||||
|
return <ChartEmpty />;
|
||||||
if (!enabled) {
|
|
||||||
return (
|
|
||||||
<ChartAnimationContainer>
|
|
||||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
|
||||||
<p className="text-center font-medium">
|
|
||||||
Please select at least one event to see the chart.
|
|
||||||
</p>
|
|
||||||
</ChartAnimationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chart.isLoading) {
|
|
||||||
return (
|
|
||||||
<ChartAnimationContainer>
|
|
||||||
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
|
|
||||||
<p className="text-center font-medium">Loading...</p>
|
|
||||||
</ChartAnimationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chart.isError) {
|
|
||||||
return (
|
|
||||||
<ChartAnimationContainer>
|
|
||||||
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
|
|
||||||
<p className="text-center font-medium">Something went wrong...</p>
|
|
||||||
</ChartAnimationContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chart.isSuccess) {
|
|
||||||
return (
|
|
||||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyData) {
|
|
||||||
return (
|
|
||||||
<ChartAnimationContainer>
|
|
||||||
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
|
|
||||||
<p className="text-center font-medium">No data</p>
|
|
||||||
</ChartAnimationContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'map') {
|
if (chartType === 'map') {
|
||||||
return <ReportMapChart data={chart.data} />;
|
return <ReportMapChart data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'histogram') {
|
if (chartType === 'histogram') {
|
||||||
return <ReportHistogramChart interval={interval} data={chart.data} />;
|
return <ReportHistogramChart interval={interval} data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'bar') {
|
if (chartType === 'bar') {
|
||||||
return <ReportBarChart data={chart.data} />;
|
return <ReportBarChart data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'metric') {
|
if (chartType === 'metric') {
|
||||||
return <ReportMetricChart data={chart.data} />;
|
return <ReportMetricChart data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'pie') {
|
if (chartType === 'pie') {
|
||||||
return <ReportPieChart data={chart.data} />;
|
return <ReportPieChart data={data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'linear') {
|
if (chartType === 'linear') {
|
||||||
return (
|
return (
|
||||||
<ReportLineChart
|
<ReportLineChart lineType={lineType} interval={interval} data={data} />
|
||||||
lineType={lineType}
|
|
||||||
interval={interval}
|
|
||||||
data={chart.data}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'area') {
|
if (chartType === 'area') {
|
||||||
return (
|
return (
|
||||||
<ReportAreaChart
|
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
|
||||||
lineType={lineType}
|
|
||||||
interval={interval}
|
|
||||||
data={chart.data}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <p>Unknown chart type</p>;
|
||||||
<ChartAnimationContainer>
|
|
||||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
|
||||||
<p className="text-center font-medium">
|
|
||||||
Chart type "{chartType}" is not supported yet.
|
|
||||||
</p>
|
|
||||||
</ChartAnimationContainer>
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from '@/components/ui/command';
|
} from '@/components/ui/command';
|
||||||
|
import { ChevronsUpDownIcon } from 'lucide-react';
|
||||||
import { useOnClickOutside } from 'usehooks-ts';
|
import { useOnClickOutside } from 'usehooks-ts';
|
||||||
|
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
@@ -92,6 +93,7 @@ export function ComboboxAdvanced({
|
|||||||
})}
|
})}
|
||||||
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
|
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface ComboboxProps<T> {
|
|||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
size?: ButtonProps['size'];
|
size?: ButtonProps['size'];
|
||||||
label?: string;
|
label?: string;
|
||||||
|
align?: 'start' | 'end' | 'center';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtendedComboboxProps<T> = Omit<
|
export type ExtendedComboboxProps<T> = Omit<
|
||||||
@@ -55,6 +56,7 @@ export function Combobox<T extends string>({
|
|||||||
searchable,
|
searchable,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
size,
|
size,
|
||||||
|
align = 'start',
|
||||||
}: ComboboxProps<T>) {
|
}: ComboboxProps<T>) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
@@ -85,7 +87,7 @@ export function Combobox<T extends string>({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
<PopoverContent className="w-full max-w-md p-0" align={align}>
|
||||||
<Command>
|
<Command>
|
||||||
{searchable === true && (
|
{searchable === true && (
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
|||||||
30
apps/web/src/components/ui/progress.tsx
Normal file
30
apps/web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
>(({ className, value, color, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className={'h-full w-full flex-1 bg-primary transition-all'}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(-${100 - (value || 0)}%)`,
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
29
apps/web/src/components/ui/sonner.tsx
Normal file
29
apps/web/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -1,35 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { useTheme } from 'next-themes';
|
||||||
Toast,
|
import { Toaster as Sonner } from 'sonner';
|
||||||
ToastClose,
|
|
||||||
ToastDescription,
|
|
||||||
ToastProvider,
|
|
||||||
ToastTitle,
|
|
||||||
ToastViewport,
|
|
||||||
} from '@/components/ui/toast';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
|
||||||
|
|
||||||
export function Toaster() {
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
const { toasts } = useToast();
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = 'system' } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<Sonner
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
theme={theme as ToasterProps['theme']}
|
||||||
return (
|
className="toaster group"
|
||||||
<Toast key={id} {...props}>
|
toastOptions={{
|
||||||
<div className="grid gap-1">
|
classNames: {
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
toast:
|
||||||
{description && (
|
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
<ToastDescription>{description}</ToastDescription>
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
)}
|
actionButton:
|
||||||
</div>
|
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
{action}
|
cancelButton:
|
||||||
<ToastClose />
|
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||||
</Toast>
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
export { Toaster };
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,17 +17,6 @@ export const env = createEnv({
|
|||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
NEXTAUTH_SECRET:
|
|
||||||
process.env.NODE_ENV === 'production'
|
|
||||||
? z.string()
|
|
||||||
: z.string().optional(),
|
|
||||||
NEXTAUTH_URL: z.preprocess(
|
|
||||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
|
||||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
|
||||||
(str) => process.env.VERCEL_URL ?? str,
|
|
||||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
|
||||||
process.env.VERCEL ? z.string() : z.string().url()
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,8 +35,6 @@ export const env = createEnv({
|
|||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { clipboard } from '@/utils/clipboard';
|
import { clipboard } from '@/utils/clipboard';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Copy } from 'lucide-react';
|
import { Copy } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
|
|||||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { api, handleError } from '@/app/_trpc/client';
|
|||||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
|
|||||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import type { IClientWithProject } from '@/types';
|
import type { IClientWithProject } from '@/types';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
|
|||||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import type { IServiceDashboardWithProject } from '@/server/services/dashboard.service';
|
import type { IServiceDashboardWithProject } from '@/server/services/dashboard.service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
|
|||||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import type { IProject } from '@/types';
|
import type { IProject } from '@/types';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { toast } from '@/components/ui/use-toast';
|
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
import type { IChartInput } from '@/types';
|
import type { IChartInput } from '@/types';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
|
|||||||
@@ -411,7 +411,12 @@ function getDatesFromRange(range: IChartRange) {
|
|||||||
let days = 1;
|
let days = 1;
|
||||||
|
|
||||||
if (range === '24h') {
|
if (range === '24h') {
|
||||||
days = 1;
|
const startDate = getDaysOldDate(days);
|
||||||
|
const endDate = new Date();
|
||||||
|
return {
|
||||||
|
startDate: startDate.toUTCString(),
|
||||||
|
endDate: endDate.toUTCString(),
|
||||||
|
};
|
||||||
} else if (range === '7d') {
|
} else if (range === '7d') {
|
||||||
days = 7;
|
days = 7;
|
||||||
} else if (range === '14d') {
|
} else if (range === '14d') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function clipboard(value: string | number) {
|
export function clipboard(value: string | number) {
|
||||||
navigator.clipboard.writeText(value.toString());
|
navigator.clipboard.writeText(value.toString());
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ export const round = (num: number, decimals = 2) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const average = (arr: (number | null)[]) => {
|
export const average = (arr: (number | null)[]) => {
|
||||||
const filtered = arr.filter(isNumber);
|
const filtered = arr.filter(
|
||||||
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
|
(n): n is number => isNumber(n) && !Number.isNaN(n) && Number.isFinite(n)
|
||||||
|
);
|
||||||
|
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
|
||||||
|
return Number.isNaN(avg) ? 0 : avg;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sum = (arr: (number | null)[]): number =>
|
export const sum = (arr: (number | null)[]): number =>
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ const colors = [
|
|||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
safelist: [...colors.map((color) => `chart-${color}`)],
|
safelist: [
|
||||||
|
...colors.flatMap((color) =>
|
||||||
|
['text', 'bg'].map((prefix) => `${prefix}-chart-${color}`)
|
||||||
|
),
|
||||||
|
],
|
||||||
content: [
|
content: [
|
||||||
'./pages/**/*.{ts,tsx}',
|
'./pages/**/*.{ts,tsx}',
|
||||||
'./components/**/*.{ts,tsx}',
|
'./components/**/*.{ts,tsx}',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@mixan/db": "workspace:*",
|
"@mixan/db": "workspace:*",
|
||||||
"@mixan/queue": "workspace:*",
|
"@mixan/queue": "workspace:*",
|
||||||
"@mixan/common": "workspace:*",
|
"@mixan/common": "workspace:*",
|
||||||
|
"@mixan/redis": "workspace:*",
|
||||||
"bullmq": "^5.1.1",
|
"bullmq": "^5.1.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"ramda": "^0.29.1"
|
"ramda": "^0.29.1"
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ ENV DATABASE_URL=$DATABASE_URL
|
|||||||
ARG NEXTAUTH_SECRET="secret_sauce"
|
ARG NEXTAUTH_SECRET="secret_sauce"
|
||||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||||
|
|
||||||
ARG NEXTAUTH_URL="http://localhost:3300"
|
|
||||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
|
||||||
|
|
||||||
ARG REDIS_URL="redis://127.0.0.1:6379"
|
ARG REDIS_URL="redis://127.0.0.1:6379"
|
||||||
ENV REDIS_URL=$REDIS_URL
|
ENV REDIS_URL=$REDIS_URL
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ ENV DATABASE_URL=$DATABASE_URL
|
|||||||
ARG NEXTAUTH_SECRET="secret_sauce"
|
ARG NEXTAUTH_SECRET="secret_sauce"
|
||||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||||
|
|
||||||
ARG NEXTAUTH_URL="http://localhost:3300"
|
|
||||||
ENV NEXTAUTH_URL=$NEXTAUTH_URL
|
|
||||||
|
|
||||||
ARG REDIS_URL="redis://127.0.0.1:6379"
|
ARG REDIS_URL="redis://127.0.0.1:6379"
|
||||||
ENV REDIS_URL=$REDIS_URL
|
ENV REDIS_URL=$REDIS_URL
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
|
--build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \
|
||||||
--build-arg NEXTAUTH_SECRET="secret_sauce" \
|
--build-arg NEXTAUTH_SECRET="secret_sauce" \
|
||||||
--build-arg NEXTAUTH_URL="http://localhost:3300" \
|
|
||||||
--build-arg REDIS_URL="redis://127.0.0.1:6379" \
|
--build-arg REDIS_URL="redis://127.0.0.1:6379" \
|
||||||
-t mixan/composed:latest \
|
-t mixan/composed:latest \
|
||||||
-t mixan/composed:1.0 \
|
-t mixan/composed:1.0 \
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ export function toDots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const strip = reject(anyPass([isEmpty, isNil]));
|
export const strip = reject(anyPass([isEmpty, isNil]));
|
||||||
|
|
||||||
|
export function getSafeJson<T>(str: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mixan/common": "workspace:*",
|
"@mixan/common": "workspace:*",
|
||||||
|
"@mixan/redis": "workspace:*",
|
||||||
"@clickhouse/client": "^0.2.9",
|
"@clickhouse/client": "^0.2.9",
|
||||||
"@prisma/client": "^5.1.1",
|
"@prisma/client": "^5.1.1",
|
||||||
"ramda": "^0.29.1"
|
"ramda": "^0.29.1"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
|
||||||
import { toDots } from '@mixan/common';
|
import { toDots } from '@mixan/common';
|
||||||
|
import { redisPub } from '@mixan/redis';
|
||||||
|
|
||||||
import { ch, chQuery, formatClickhouseDate } from '../clickhouse-client';
|
import { ch, chQuery, formatClickhouseDate } from '../clickhouse-client';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export interface IClickhouseEvent {
|
|||||||
referrer: string;
|
referrer: string;
|
||||||
referrer_name: string;
|
referrer_name: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
properties: Record<string, string>;
|
properties: Record<string, string | number | boolean>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
country: string;
|
country: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -91,10 +92,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
delete payload.properties.hash;
|
delete payload.properties.hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ch.insert({
|
const event: IClickhouseEvent = {
|
||||||
table: 'events',
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
profile_id: payload.profileId,
|
profile_id: payload.profileId,
|
||||||
project_id: payload.projectId,
|
project_id: payload.projectId,
|
||||||
@@ -113,8 +111,19 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
|||||||
model: payload.model ?? '',
|
model: payload.model ?? '',
|
||||||
duration: payload.duration,
|
duration: payload.duration,
|
||||||
referrer: payload.referrer ?? '',
|
referrer: payload.referrer ?? '',
|
||||||
},
|
referrer_name: payload.referrerName ?? '',
|
||||||
],
|
};
|
||||||
|
|
||||||
|
const res = await ch.insert({
|
||||||
|
table: 'events',
|
||||||
|
values: [event],
|
||||||
format: 'JSONEachRow',
|
format: 'JSONEachRow',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
redisPub.publish('event', JSON.stringify(transformEvent(event)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
document: event,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function createSqlBuilder() {
|
|||||||
offset: number | undefined;
|
offset: number | undefined;
|
||||||
} = {
|
} = {
|
||||||
where: {},
|
where: {},
|
||||||
from: 'events',
|
from: 'openpanel.events',
|
||||||
select: {},
|
select: {},
|
||||||
groupBy: {},
|
groupBy: {},
|
||||||
orderBy: {},
|
orderBy: {},
|
||||||
@@ -40,8 +40,8 @@ export function createSqlBuilder() {
|
|||||||
getSelect,
|
getSelect,
|
||||||
getGroupBy,
|
getGroupBy,
|
||||||
getOrderBy,
|
getOrderBy,
|
||||||
getSql: () =>
|
getSql: () => {
|
||||||
[
|
const sql = [
|
||||||
getSelect(),
|
getSelect(),
|
||||||
getFrom(),
|
getFrom(),
|
||||||
getWhere(),
|
getWhere(),
|
||||||
@@ -51,6 +51,12 @@ export function createSqlBuilder() {
|
|||||||
getOffset(),
|
getOffset(),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' '),
|
.join(' ');
|
||||||
|
console.log('---');
|
||||||
|
console.log(sql);
|
||||||
|
console.log('---');
|
||||||
|
|
||||||
|
return sql;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
export const redis = new Redis(process.env.REDIS_URL!);
|
export const redis = new Redis(process.env.REDIS_URL!);
|
||||||
|
export const redisSub = new Redis(process.env.REDIS_URL!);
|
||||||
|
export const redisPub = new Redis(process.env.REDIS_URL!);
|
||||||
|
|||||||
210
pnpm-lock.yaml
generated
210
pnpm-lock.yaml
generated
@@ -201,9 +201,15 @@ importers:
|
|||||||
'@mixan/queue':
|
'@mixan/queue':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/queue
|
version: link:../../packages/queue
|
||||||
|
'@mixan/redis':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/redis
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^4.25.2
|
specifier: ^4.25.2
|
||||||
version: 4.25.2
|
version: 4.25.2
|
||||||
|
fastify-sse-v2:
|
||||||
|
specifier: ^3.1.2
|
||||||
|
version: 3.1.2(fastify@4.25.2)
|
||||||
pino:
|
pino:
|
||||||
specifier: ^8.17.2
|
specifier: ^8.17.2
|
||||||
version: 8.17.2
|
version: 8.17.2
|
||||||
@@ -362,6 +368,9 @@ importers:
|
|||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-progress':
|
||||||
|
specifier: ^1.0.3
|
||||||
|
version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@radix-ui/react-scroll-area':
|
'@radix-ui/react-scroll-area':
|
||||||
specifier: ^1.0.5
|
specifier: ^1.0.5
|
||||||
version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
@@ -434,6 +443,9 @@ importers:
|
|||||||
next-auth:
|
next-auth:
|
||||||
specifier: ^4.23.0
|
specifier: ^4.23.0
|
||||||
version: 4.24.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
|
version: 4.24.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.2.1
|
||||||
|
version: 0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
|
||||||
nuqs:
|
nuqs:
|
||||||
specifier: ^1.15.2
|
specifier: ^1.15.2
|
||||||
version: 1.15.2(next@14.0.4)
|
version: 1.15.2(next@14.0.4)
|
||||||
@@ -452,6 +464,9 @@ importers:
|
|||||||
react-animate-height:
|
react-animate-height:
|
||||||
specifier: ^3.2.3
|
specifier: ^3.2.3
|
||||||
version: 3.2.3(react-dom@18.2.0)(react@18.2.0)
|
version: 3.2.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react-animated-numbers:
|
||||||
|
specifier: ^0.18.0
|
||||||
|
version: 0.18.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.2.0
|
specifier: 18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
@@ -485,6 +500,9 @@ importers:
|
|||||||
slugify:
|
slugify:
|
||||||
specifier: ^1.6.6
|
specifier: ^1.6.6
|
||||||
version: 1.6.6
|
version: 1.6.6
|
||||||
|
sonner:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
superjson:
|
superjson:
|
||||||
specifier: ^1.13.1
|
specifier: ^1.13.1
|
||||||
version: 1.13.3
|
version: 1.13.3
|
||||||
@@ -579,6 +597,9 @@ importers:
|
|||||||
'@mixan/queue':
|
'@mixan/queue':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/queue
|
version: link:../../packages/queue
|
||||||
|
'@mixan/redis':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/redis
|
||||||
bullmq:
|
bullmq:
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.1.1
|
version: 5.1.1
|
||||||
@@ -665,6 +686,9 @@ importers:
|
|||||||
'@mixan/common':
|
'@mixan/common':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../common
|
version: link:../common
|
||||||
|
'@mixan/redis':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../redis
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.5.2(prisma@5.5.2)
|
version: 5.5.2(prisma@5.5.2)
|
||||||
@@ -1348,6 +1372,20 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/@emotion/is-prop-valid@0.8.8:
|
||||||
|
resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
|
||||||
|
requiresBuild: true
|
||||||
|
dependencies:
|
||||||
|
'@emotion/memoize': 0.7.4
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@emotion/memoize@0.7.4:
|
||||||
|
resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
|
||||||
|
requiresBuild: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
/@esbuild/android-arm64@0.18.20:
|
/@esbuild/android-arm64@0.18.20:
|
||||||
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2871,6 +2909,28 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.23.9
|
||||||
|
'@radix-ui/react-context': 1.0.1(@types/react@18.2.34)(react@18.2.0)
|
||||||
|
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@types/react': 18.2.34
|
||||||
|
'@types/react-dom': 18.2.14
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0):
|
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
|
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3185,6 +3245,54 @@ packages:
|
|||||||
'@babel/runtime': 7.23.9
|
'@babel/runtime': 7.23.9
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@react-spring/animated@9.7.3(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@react-spring/shared': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/types': 9.7.3
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@react-spring/core@9.7.3(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-IqFdPVf3ZOC1Cx7+M0cXf4odNLxDC+n7IN3MDcVCTIOSBfqEcBebSv+vlY5AhM0zw05PDbjKrNmBpzv/AqpjnQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@react-spring/animated': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/shared': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/types': 9.7.3
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@react-spring/shared@9.7.3(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-NEopD+9S5xYyQ0pGtioacLhL2luflh6HACSSDUZOwLHoxA5eku1UPuqcJqjwSD6luKjjLfiLOspxo43FUHKKSA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@react-spring/types': 9.7.3
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@react-spring/types@9.7.3:
|
||||||
|
resolution: {integrity: sha512-Kpx/fQ/ZFX31OtlqVEFfgaD1ACzul4NksrvIgYfIFq9JpDHFwQkMVZ10tbo0FU/grje4rcL4EIrjekl3kYwgWw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@react-spring/web@9.7.3(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-BXt6BpS9aJL/QdVqEIX9YoUy8CE6TJrU0mNCqSoxdXlIeNcEBWOfIyE6B14ENNsyQKS3wOWkiJfco0tCr/9tUg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
'@react-spring/animated': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/core': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/shared': 9.7.3(react@18.2.0)
|
||||||
|
'@react-spring/types': 9.7.3
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@reduxjs/toolkit@1.9.7(react-redux@8.1.3)(react@18.2.0):
|
/@reduxjs/toolkit@1.9.7(react-redux@8.1.3)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==}
|
resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4575,7 +4683,7 @@ packages:
|
|||||||
/dom-helpers@3.4.0:
|
/dom-helpers@3.4.0:
|
||||||
resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==}
|
resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.23.2
|
'@babel/runtime': 7.23.9
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/dot-case@3.0.4:
|
/dot-case@3.0.4:
|
||||||
@@ -5203,6 +5311,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/fast-fifo@1.3.2:
|
||||||
|
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fast-glob@3.3.1:
|
/fast-glob@3.3.1:
|
||||||
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
@@ -5250,6 +5362,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/fastify-sse-v2@3.1.2(fastify@4.25.2):
|
||||||
|
resolution: {integrity: sha512-eEgxBv04wWtrIupDKw65DtdI8Q4XJA3IGstefkSU8qgDlUlexc7+cixxowxlSGj1pz/HC3QsKGLhna+fb7snOQ==}
|
||||||
|
peerDependencies:
|
||||||
|
fastify: '>=4'
|
||||||
|
dependencies:
|
||||||
|
fastify: 4.25.2
|
||||||
|
fastify-plugin: 4.5.1
|
||||||
|
it-pushable: 1.4.2
|
||||||
|
it-to-stream: 1.0.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fastify@4.25.2:
|
/fastify@4.25.2:
|
||||||
resolution: {integrity: sha512-SywRouGleDHvRh054onj+lEZnbC1sBCLkR0UY3oyJwjD4BdZJUrxBqfkfCaqn74pVCwBaRHGuL3nEWeHbHzAfw==}
|
resolution: {integrity: sha512-SywRouGleDHvRh054onj+lEZnbC1sBCLkR0UY3oyJwjD4BdZJUrxBqfkfCaqn74pVCwBaRHGuL3nEWeHbHzAfw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5377,6 +5500,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/framer-motion@10.18.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0
|
||||||
|
react-dom: ^18.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
tslib: 2.6.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@emotion/is-prop-valid': 0.8.8
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fresh@0.5.2:
|
/fresh@0.5.2:
|
||||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -5445,6 +5586,10 @@ packages:
|
|||||||
hasown: 2.0.0
|
hasown: 2.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/get-iterator@1.0.2:
|
||||||
|
resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/get-nonce@1.0.1:
|
/get-nonce@1.0.1:
|
||||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -5962,6 +6107,23 @@ packages:
|
|||||||
/isexe@2.0.0:
|
/isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
/it-pushable@1.4.2:
|
||||||
|
resolution: {integrity: sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==}
|
||||||
|
dependencies:
|
||||||
|
fast-fifo: 1.3.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/it-to-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==}
|
||||||
|
dependencies:
|
||||||
|
buffer: 6.0.3
|
||||||
|
fast-fifo: 1.3.2
|
||||||
|
get-iterator: 1.0.2
|
||||||
|
p-defer: 3.0.0
|
||||||
|
p-fifo: 1.0.0
|
||||||
|
readable-stream: 3.6.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
/iterator.prototype@1.1.2:
|
/iterator.prototype@1.1.2:
|
||||||
resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==}
|
resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6411,6 +6573,18 @@ packages:
|
|||||||
uuid: 8.3.2
|
uuid: 8.3.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/next-themes@0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==}
|
||||||
|
peerDependencies:
|
||||||
|
next: '*'
|
||||||
|
react: '*'
|
||||||
|
react-dom: '*'
|
||||||
|
dependencies:
|
||||||
|
next: 14.0.4(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/next@13.4.19(react-dom@18.2.0)(react@18.2.0):
|
/next@13.4.19(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==}
|
resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==}
|
||||||
engines: {node: '>=16.8.0'}
|
engines: {node: '>=16.8.0'}
|
||||||
@@ -6706,6 +6880,18 @@ packages:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
/p-defer@3.0.0:
|
||||||
|
resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/p-fifo@1.0.0:
|
||||||
|
resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==}
|
||||||
|
dependencies:
|
||||||
|
fast-fifo: 1.3.2
|
||||||
|
p-defer: 3.0.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/p-limit@3.1.0:
|
/p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -7089,6 +7275,18 @@ packages:
|
|||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-animated-numbers@0.18.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-o/4ho2p5B3FfXpjDpwjYwNq6fTCbHvuQbrHRRJ+SZ3g0+OVssFVIrQBTJ16pVbWXOkjtw61wgCcbJN2VZmuKzg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
'@react-spring/web': 9.7.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
framer-motion: 10.18.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-dom@18.2.0(react@18.2.0):
|
/react-dom@18.2.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7782,6 +7980,16 @@ packages:
|
|||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/sonner@1.4.0(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-nvkTsIuOmi9e5Wz5If8ldasJjZNVfwiXYijBi2dbijvTQnQppvMcXTFNxL/NUFWlI2yJ1JX7TREDsg+gYm9WyA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0
|
||||||
|
react-dom: ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/source-map-js@1.0.2:
|
/source-map-js@1.0.2:
|
||||||
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|||||||
Reference in New Issue
Block a user