gui: work in progress
This commit is contained in:
@@ -15,6 +15,11 @@ const config = {
|
||||
// Feel free to reconfigure them to your own preference.
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
|
||||
16
apps/web/components.json
Normal file
16
apps/web/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utils/cn"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
"@mixan/types": "^0.0.2-alpha",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@t3-oss/env-nextjs": "^0.7.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@trpc/client": "^10.37.1",
|
||||
@@ -21,18 +27,28 @@
|
||||
"@trpc/react-query": "^10.37.1",
|
||||
"@trpc/server": "^10.37.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"lucide-react": "^0.286.0",
|
||||
"next": "^13.5.4",
|
||||
"next-auth": "^4.23.0",
|
||||
"ramda": "^0.29.1",
|
||||
"random-animal-name": "^0.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "^8.1.3",
|
||||
"recharts": "^2.8.0",
|
||||
"superjson": "^1.13.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/eslint": "^8.44.2",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
|
||||
124
apps/web/src/components/ReportFilterPicker.tsx
Normal file
124
apps/web/src/components/ReportFilterPicker.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Cloud,
|
||||
CreditCard,
|
||||
Github,
|
||||
Keyboard,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useState } from "react"
|
||||
|
||||
export function ReportFilterPicker() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Open</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Billing</span>
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Keyboard className="mr-2 h-4 w-4" />
|
||||
<span>Keyboard shortcuts</span>
|
||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
<span>Team</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
<span>Invite users</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
<span>Email</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
<span>Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
<span>More...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>New Team</span>
|
||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>GitHub</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<LifeBuoy className="mr-2 h-4 w-4" />
|
||||
<span>Support</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
<span>API</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
90
apps/web/src/components/report/chart/ReportLineChart.tsx
Normal file
90
apps/web/src/components/report/chart/ReportLineChart.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ReportLineChartTooltip } from "./ReportLineChartTooltop";
|
||||
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
||||
import {
|
||||
type IChartBreakdown,
|
||||
type IChartEvent,
|
||||
type IInterval,
|
||||
} from "@/types";
|
||||
import { getChartColor } from "@/utils/theme";
|
||||
import { ReportTable } from "./ReportTable";
|
||||
|
||||
export function ReportLineChart({
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
events,
|
||||
breakdowns,
|
||||
}: {
|
||||
interval: IInterval;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
events: IChartEvent[];
|
||||
breakdowns: IChartBreakdown[];
|
||||
}) {
|
||||
const chart = api.chartMeta.chart.useQuery(
|
||||
{
|
||||
interval,
|
||||
chartType: "linear",
|
||||
startDate,
|
||||
endDate,
|
||||
events,
|
||||
breakdowns,
|
||||
},
|
||||
{
|
||||
enabled: events.length > 0,
|
||||
},
|
||||
);
|
||||
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
return (
|
||||
<>
|
||||
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
||||
<>
|
||||
<LineChart width={800} height={400}>
|
||||
<Legend />
|
||||
<YAxis dataKey={"count"}></YAxis>
|
||||
<Tooltip content={<ReportLineChartTooltip />} />
|
||||
{/* <Tooltip /> */}
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(m: Date) => {
|
||||
return formatDate(m);
|
||||
}}
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
{chart.data?.series.slice(0, 5).map((serie, index) => {
|
||||
const key = serie.name;
|
||||
const strokeColor = getChartColor(index)
|
||||
return (
|
||||
<Line
|
||||
type="monotone"
|
||||
key={key}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey="count"
|
||||
stroke={strokeColor}
|
||||
data={serie.data}
|
||||
name={serie.name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
<ReportTable data={chart.data} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type IToolTipProps } from "@/types";
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
color: string;
|
||||
value: number;
|
||||
payload: {
|
||||
date: Date;
|
||||
count: number;
|
||||
label: string;
|
||||
};
|
||||
}>
|
||||
|
||||
export function ReportLineChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
if (!active || !payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const limit = 3;
|
||||
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
|
||||
{visible.map((item) => {
|
||||
return (
|
||||
<div key={item.payload.label} className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: item.color }}
|
||||
></div>
|
||||
<div className="flex flex-col">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{item.payload.label}
|
||||
</div>
|
||||
<div>{item.payload.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground">and {hidden.length} more...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/report/chart/ReportTable.tsx
Normal file
72
apps/web/src/components/report/chart/ReportTable.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import { type RouterOutputs } from "@/utils/api";
|
||||
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
||||
import { useSelector } from "@/redux";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { getChartColor } from "@/utils/theme";
|
||||
|
||||
export function ReportTable({
|
||||
data,
|
||||
}: {
|
||||
data: RouterOutputs["chartMeta"]["chart"];
|
||||
}) {
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0">
|
||||
{/* Labels */}
|
||||
<div>
|
||||
<div className="font-medium">Name</div>
|
||||
{data.series.map((serie, index) => {
|
||||
const checked = index < 5;
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className="max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
style={checked ? {
|
||||
background: getChartColor(index),
|
||||
borderColor: getChartColor(index),
|
||||
} : undefined}
|
||||
checked={checked}
|
||||
/>
|
||||
{serie.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ScrollView for all values */}
|
||||
<div className="min-w-0 overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex">
|
||||
{data.series[0]?.data.map((serie, index) => (
|
||||
<div
|
||||
key={serie.date.toString()}
|
||||
className="min-w-[80px] text-right font-medium"
|
||||
>
|
||||
{formatDate(serie.date)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
{data.series.map((serie, index) => {
|
||||
return (
|
||||
<div className="flex" key={serie.name}>
|
||||
{serie.data.map((item) => {
|
||||
return (
|
||||
<div key={item.date} className="min-w-[80px] text-right">
|
||||
{item.count}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/web/src/components/report/reportSlice.ts
Normal file
132
apps/web/src/components/report/reportSlice.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type IChartBreakdown,
|
||||
type IChartEvent,
|
||||
type IInterval,
|
||||
} from "@/types";
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
type InitialState = {
|
||||
events: IChartEvent[];
|
||||
breakdowns: IChartBreakdown[];
|
||||
interval: IInterval;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
// First approach: define the initial state using that type
|
||||
const initialState: InitialState = {
|
||||
startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
||||
endDate: new Date(),
|
||||
interval: "day",
|
||||
breakdowns: [
|
||||
{
|
||||
id: "A",
|
||||
name: "properties.params.title",
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: "A",
|
||||
displayName: "screen_view (0)",
|
||||
name: "screen_view",
|
||||
filters: [
|
||||
{
|
||||
id: "1",
|
||||
name: "properties.route",
|
||||
value: "RecipeDetails",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const;
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
name: "counter",
|
||||
initialState,
|
||||
reducers: {
|
||||
// Events
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
|
||||
state.events.push({
|
||||
id: IDS[state.events.length]!,
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: string;
|
||||
}>,
|
||||
) => {
|
||||
state.events = state.events.filter(
|
||||
(event) => event.id !== action.payload.id,
|
||||
);
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
||||
state.events = state.events.map((event) => {
|
||||
if (event.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
return event;
|
||||
});
|
||||
},
|
||||
|
||||
// Breakdowns
|
||||
addBreakdown: (
|
||||
state,
|
||||
action: PayloadAction<Omit<IChartBreakdown, "id">>,
|
||||
) => {
|
||||
state.breakdowns.push({
|
||||
id: IDS[state.breakdowns.length]!,
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
removeBreakdown: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
id: string;
|
||||
}>,
|
||||
) => {
|
||||
state.breakdowns = state.breakdowns.filter(
|
||||
(event) => event.id !== action.payload.id,
|
||||
);
|
||||
},
|
||||
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
|
||||
state.breakdowns = state.breakdowns.map((breakdown) => {
|
||||
if (breakdown.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
return breakdown;
|
||||
});
|
||||
},
|
||||
|
||||
// Interval
|
||||
changeInterval: (state, action: PayloadAction<IInterval>) => {
|
||||
state.interval = action.payload;
|
||||
},
|
||||
|
||||
// Date range
|
||||
changeStartDate: (state, action: PayloadAction<Date>) => {
|
||||
state.startDate = action.payload;
|
||||
},
|
||||
|
||||
// Date range
|
||||
changeEndDate: (state, action: PayloadAction<Date>) => {
|
||||
state.endDate = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
addEvent,
|
||||
removeEvent,
|
||||
changeEvent,
|
||||
addBreakdown,
|
||||
removeBreakdown,
|
||||
changeBreakdown,
|
||||
changeInterval,
|
||||
} = reportSlice.actions;
|
||||
|
||||
export default reportSlice.reducer;
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export type ReportBreakdownMoreProps = {
|
||||
onClick: (action: 'remove') => void
|
||||
}
|
||||
|
||||
export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
77
apps/web/src/components/report/sidebar/ReportBreakdowns.tsx
Normal file
77
apps/web/src/components/report/sidebar/ReportBreakdowns.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import { addBreakdown, changeBreakdown, removeBreakdown } from "../reportSlice";
|
||||
import { type ReportEventMoreProps } from "./ReportEventMore";
|
||||
import { type IChartBreakdown } from "@/types";
|
||||
import { ReportBreakdownMore } from "./ReportBreakdownMore";
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chartMeta.properties.useQuery();
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
}));
|
||||
|
||||
const handleMore = (breakdown: IChartBreakdown) => {
|
||||
const callback: ReportEventMoreProps["onClick"] = (action) => {
|
||||
switch (action) {
|
||||
case "remove": {
|
||||
return dispatch(removeBreakdown(breakdown));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Breakdown</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedBreakdowns.map((item, index) => {
|
||||
return (
|
||||
<div key={item.name} className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 p-2 px-4">
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white">
|
||||
{index}
|
||||
</div>
|
||||
<Combobox
|
||||
value={item.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeBreakdown({
|
||||
...item,
|
||||
name: value,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={propertiesCombobox}
|
||||
placeholder="Select..."
|
||||
/>
|
||||
<ReportBreakdownMore onClick={handleMore(item)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedBreakdowns.length === 0 && (
|
||||
<Combobox
|
||||
value={""}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
addBreakdown({
|
||||
name: value,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={propertiesCombobox}
|
||||
placeholder="Select breakdown"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
apps/web/src/components/report/sidebar/ReportEventFilters.tsx
Normal file
181
apps/web/src/components/report/sidebar/ReportEventFilters.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { type IChartEvent } from "@/types";
|
||||
import {
|
||||
CreditCard,
|
||||
SlidersHorizontal,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { type Dispatch } from "react";
|
||||
import { RenderDots } from "@/components/ui/RenderDots";
|
||||
import { useDispatch } from "@/redux";
|
||||
import { changeEvent } from "../reportSlice";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ReportEventFiltersProps = {
|
||||
event: IChartEvent;
|
||||
isCreating: boolean;
|
||||
setIsCreating: Dispatch<boolean>;
|
||||
};
|
||||
|
||||
export function ReportEventFilters({
|
||||
event,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
}: ReportEventFiltersProps) {
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chartMeta.properties.useQuery(
|
||||
{
|
||||
event: event.name,
|
||||
},
|
||||
{
|
||||
enabled: !!event.name,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col divide-y bg-slate-50">
|
||||
{event.filters.map((filter) => {
|
||||
return <Filter key={filter.name} filter={filter} event={event} />;
|
||||
})}
|
||||
|
||||
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
|
||||
<CommandGroup heading="Properties">
|
||||
{propertiesQuery.data?.map((item) => (
|
||||
<CommandItem
|
||||
key={item}
|
||||
onSelect={() => {
|
||||
setIsCreating(false);
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: [
|
||||
...event.filters,
|
||||
{
|
||||
id: (event.filters.length + 1).toString(),
|
||||
name: item,
|
||||
value: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<RenderDots className="text-sm">{item}</RenderDots>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterProps = {
|
||||
event: IChartEvent;
|
||||
filter: IChartEvent["filters"][number];
|
||||
};
|
||||
|
||||
function Filter({ filter, event }: FilterProps) {
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chartMeta.values.useQuery({
|
||||
event: event.name,
|
||||
property: filter.name,
|
||||
});
|
||||
|
||||
const valuesCombobox =
|
||||
potentialValues.data?.values?.map((item) => ({
|
||||
value: item,
|
||||
label: item,
|
||||
})) ?? [];
|
||||
|
||||
const removeFilter = () => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.filter((item) => item.id !== filter.id),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const changeFilter = (value: string) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === filter.id) {
|
||||
return {
|
||||
...item,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter.name}
|
||||
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-emerald-600 text-xs font-medium text-white">
|
||||
<SlidersHorizontal size={10} />
|
||||
</div>
|
||||
<RenderDots className="text-sm">{filter.name}</RenderDots>
|
||||
<Button variant="ghost" size="sm" onClick={removeFilter}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* <ComboboxMulti items={valuesCombobox} selected={[]} setSelected={(fn) => {
|
||||
return fn(filter.value)
|
||||
//
|
||||
}} /> */}
|
||||
<Combobox
|
||||
items={valuesCombobox}
|
||||
value={filter.value}
|
||||
placeholder="Select value"
|
||||
onChange={changeFilter}
|
||||
/>
|
||||
{/* <Input
|
||||
value={filter.value}
|
||||
onChange={(e) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === filter.id) {
|
||||
return {
|
||||
...item,
|
||||
value: e.currentTarget.value,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/report/sidebar/ReportEventMore.tsx
Normal file
68
apps/web/src/components/report/sidebar/ReportEventMore.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from "react"
|
||||
import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
const labels = [
|
||||
"feature",
|
||||
"bug",
|
||||
"enhancement",
|
||||
"documentation",
|
||||
"design",
|
||||
"question",
|
||||
"maintenance",
|
||||
]
|
||||
|
||||
export type ReportEventMoreProps = {
|
||||
onClick: (action: 'createFilter' | 'remove') => void
|
||||
}
|
||||
|
||||
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onClick('createFilter')}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Add filter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
85
apps/web/src/components/report/sidebar/ReportEvents.tsx
Normal file
85
apps/web/src/components/report/sidebar/ReportEvents.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import { addEvent, changeEvent, removeEvent } from "../reportSlice";
|
||||
import { ReportEventFilters } from "./ReportEventFilters";
|
||||
import { useState } from "react";
|
||||
import { ReportEventMore, type ReportEventMoreProps } from "./ReportEventMore";
|
||||
import { type IChartEvent } from "@/types";
|
||||
|
||||
export function ReportEvents() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const eventsQuery = api.chartMeta.events.useQuery();
|
||||
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
}));
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const callback: ReportEventMoreProps["onClick"] = (action) => {
|
||||
switch (action) {
|
||||
case "createFilter": {
|
||||
return setIsCreating(true);
|
||||
}
|
||||
case "remove": {
|
||||
return dispatch(removeEvent(event));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Events</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
return (
|
||||
<div key={event.name} className="border rounded-lg">
|
||||
<div className="flex gap-2 items-center p-2 px-4">
|
||||
<div className="flex-shrink-0 bg-purple-500 w-5 h-5 rounded text-xs flex items-center justify-center text-white font-medium">{index}</div>
|
||||
<Combobox
|
||||
value={event.name}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventsCombobox}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</div>
|
||||
<ReportEventFilters
|
||||
{...{ isCreating, setIsCreating, event }}
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Combobox
|
||||
value={""}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
addEvent({
|
||||
displayName: `${value} (${selectedEvents.length})`,
|
||||
name: value,
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
items={eventsCombobox}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/web/src/components/report/sidebar/ReportSidebar.tsx
Normal file
11
apps/web/src/components/report/sidebar/ReportSidebar.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ReportEvents } from "./ReportEvents";
|
||||
import { ReportBreakdowns } from "./ReportBreakdowns";
|
||||
|
||||
export function ReportSidebar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ReportEvents />
|
||||
<ReportBreakdowns />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/ui/RenderDots.tsx
Normal file
21
apps/web/src/components/ui/RenderDots.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/utils/cn";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface RenderDotsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function RenderDots({ children, className, ...props }: RenderDotsProps) {
|
||||
return (
|
||||
<div {...props} className={cn('flex gap-1', className)}>
|
||||
{children.split(".").map((str, index) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1" key={str + index}>
|
||||
{index !== 0 && <ChevronRight className="flex-shrink-0 !w-3 !h-3 top-[0.5px] relative" />}
|
||||
<span>{str}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
apps/web/src/components/ui/button.tsx
Normal file
56
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
28
apps/web/src/components/ui/checkbox.tsx
Normal file
28
apps/web/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
118
apps/web/src/components/ui/combobox-multi.tsx
Normal file
118
apps/web/src/components/ui/combobox-multi.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
type Framework = Record<"value" | "label", string>;
|
||||
|
||||
type ComboboxMultiProps = {
|
||||
selected: Framework[];
|
||||
setSelected: React.Dispatch<React.SetStateAction<Framework[]>>;
|
||||
items: Framework[];
|
||||
}
|
||||
|
||||
export function ComboboxMulti({ items, selected, setSelected, ...props }: ComboboxMultiProps) {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
|
||||
const handleUnselect = React.useCallback((framework: Framework) => {
|
||||
setSelected(prev => prev.filter(s => s.value !== framework.value));
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current
|
||||
if (input) {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (input.value === "") {
|
||||
setSelected(prev => {
|
||||
const newSelected = [...prev];
|
||||
newSelected.pop();
|
||||
return newSelected;
|
||||
})
|
||||
}
|
||||
}
|
||||
// This is not a default behaviour of the <input /> field
|
||||
if (e.key === "Escape") {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectables = items.filter(framework => !selected.includes(framework));
|
||||
|
||||
return (
|
||||
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-white">
|
||||
<div
|
||||
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{selected.map((framework) => {
|
||||
return (
|
||||
<Badge key={framework.value} variant="secondary">
|
||||
{framework.label}
|
||||
<button
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(framework);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(framework)}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Select frameworks..."
|
||||
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
{open && selectables.length > 0 ?
|
||||
<div className="absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{selectables.map((framework) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={framework.value}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value) => {
|
||||
setInputValue("")
|
||||
setSelected(prev => [...prev, framework])
|
||||
}}
|
||||
className={"cursor-pointer"}
|
||||
>
|
||||
{framework.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
</Command >
|
||||
)
|
||||
}
|
||||
84
apps/web/src/components/ui/combobox.tsx
Normal file
84
apps/web/src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
type ComboboxProps = {
|
||||
placeholder: string;
|
||||
items: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function Combobox({
|
||||
placeholder,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
function find(value: string) {
|
||||
return items.find(
|
||||
(item) => item.value.toLowerCase() === value.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full min-w-0 justify-between"
|
||||
>
|
||||
{value ? find(value)?.label ?? "No match" : placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full min-w-0 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search item..." />
|
||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
onSelect={(currentValue) => {
|
||||
const value = find(currentValue)?.value ?? "";
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 flex-shrink-0",
|
||||
value === item.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
153
apps/web/src/components/ui/command.tsx
Normal file
153
apps/web/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
type CommandDialogProps = DialogProps
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
120
apps/web/src/components/ui/dialog.tsx
Normal file
120
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
198
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
198
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
24
apps/web/src/components/ui/input.tsx
Normal file
24
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
29
apps/web/src/components/ui/popover.tsx
Normal file
29
apps/web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
32
apps/web/src/components/ui/radio-group.tsx
Normal file
32
apps/web/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils/cn"
|
||||
|
||||
export type RadioGroupProps = React.InputHTMLAttributes<HTMLDivElement>
|
||||
export type RadioGroupItemProps = React.InputHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-full divide-x rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const RadioGroupItem = React.forwardRef<HTMLButtonElement, RadioGroupItemProps>(({className, ...props}, ref) => {
|
||||
return (
|
||||
<button {...props} className={cn('flex-1 hover:bg-slate-100 transition-colors font-medium', className)} type="button" ref={ref} />
|
||||
)
|
||||
})
|
||||
|
||||
RadioGroup.displayName = "RadioGroup"
|
||||
RadioGroupItem.displayName = "RadioGroupItem"
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
27
apps/web/src/hooks/useFormatDateInterval.ts
Normal file
27
apps/web/src/hooks/useFormatDateInterval.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type IInterval } from "@/types";
|
||||
|
||||
|
||||
export function formatDateInterval(interval: IInterval, date: Date): string {
|
||||
if (interval === "hour") {
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
if (interval === "month") {
|
||||
return new Intl.DateTimeFormat("en-GB", { month: "short" }).format(date);
|
||||
}
|
||||
|
||||
if (interval === "day") {
|
||||
return new Intl.DateTimeFormat("en-GB", { weekday: "short" }).format(
|
||||
date,
|
||||
);
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function useFormatDateInterval(interval: IInterval) {
|
||||
return (date: Date | string) => formatDateInterval(interval, typeof date === "string" ? new Date(date) : date);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { type AppType } from "next/app";
|
||||
import store from "@/redux";
|
||||
import { Provider as ReduxProvider } from "react-redux";
|
||||
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -12,7 +14,9 @@ const MyApp: AppType<{ session: Session | null }> = ({
|
||||
}) => {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Component {...pageProps} />
|
||||
<ReduxProvider store={store}>
|
||||
<Component {...pageProps} />
|
||||
</ReduxProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateSdkRequest } from '@/server/auth'
|
||||
import { db } from '@/server/db'
|
||||
import { createError, handleError } from '@/server/exceptions'
|
||||
import { EventPayload } from '@mixan/types'
|
||||
import { type EventPayload } from '@mixan/types'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateSdkRequest } from "@/server/auth";
|
||||
import { db } from "@/server/db";
|
||||
import { createError, handleError } from "@/server/exceptions";
|
||||
import { tickProfileProperty } from "@/services/profile.service";
|
||||
import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types";
|
||||
import { type ProfileIncrementPayload } from "@mixan/types";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateSdkRequest } from "@/server/auth";
|
||||
import { db } from "@/server/db";
|
||||
import { createError, handleError } from "@/server/exceptions";
|
||||
import { tickProfileProperty } from "@/services/profile.service";
|
||||
import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types";
|
||||
import { type ProfileIncrementPayload } from "@mixan/types";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { validateSdkRequest } from "@/server/auth";
|
||||
import { db } from "@/server/db";
|
||||
import { createError, handleError } from "@/server/exceptions";
|
||||
import { getProfile } from "@/services/profile.service";
|
||||
import { ProfilePayload } from "@mixan/types";
|
||||
import { type ProfilePayload } from "@mixan/types";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
interface Request extends NextApiRequest {
|
||||
@@ -34,9 +34,9 @@ export default async function handler(req: Request, res: NextApiResponse) {
|
||||
avatar: body.avatar,
|
||||
properties: {
|
||||
...(typeof profile.properties === "object"
|
||||
? profile.properties || {}
|
||||
? profile.properties ?? {}
|
||||
: {}),
|
||||
...(body.properties || {}),
|
||||
...(body.properties ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function handler(
|
||||
last_name: null,
|
||||
avatar: null,
|
||||
properties: {
|
||||
...(properties || {}),
|
||||
...(properties ?? {}),
|
||||
},
|
||||
project_id: projectId,
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { db } from "@/server/db";
|
||||
import { handleError } from "@/server/exceptions";
|
||||
import { hashPassword } from "@/services/hash.service";
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
export default async function (req: NextApiRequest, res: NextApiResponse) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const counts = await db.$transaction([
|
||||
db.organization.count(),
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar";
|
||||
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useDispatch, useSelector } from "@/redux";
|
||||
import { changeInterval } from "@/components/report/reportSlice";
|
||||
import { type IInterval } from "@/types";
|
||||
|
||||
export default function Home() {
|
||||
const hello = api.example.hello.useQuery({ text: "from tRPC" });
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const events = useSelector((state) => state.report.events);
|
||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const startDate = useSelector((state) => state.report.startDate);
|
||||
const endDate = useSelector((state) => state.report.endDate);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -14,67 +24,56 @@ export default function Home() {
|
||||
<meta name="description" content="Generated by create-t3-app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main className=" flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
|
||||
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
|
||||
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
|
||||
</h1>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
|
||||
<Link
|
||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
|
||||
href="https://create.t3.gg/en/usage/first-steps"
|
||||
target="_blank"
|
||||
>
|
||||
<h3 className="text-2xl font-bold">First Steps →</h3>
|
||||
<div className="text-lg">
|
||||
Just the basics - Everything you need to know to set up your
|
||||
database and authentication.
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
|
||||
href="https://create.t3.gg/en/introduction"
|
||||
target="_blank"
|
||||
>
|
||||
<h3 className="text-2xl font-bold">Documentation →</h3>
|
||||
<div className="text-lg">
|
||||
Learn more about Create T3 App, the libraries it uses, and how
|
||||
to deploy it.
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-2xl text-white">
|
||||
{hello.data ? hello.data.greeting : "Loading tRPC query..."}
|
||||
</p>
|
||||
<AuthShowcase />
|
||||
<main className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
||||
<div>
|
||||
<ReportSidebar />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex gap-4">
|
||||
<RadioGroup>
|
||||
<RadioGroupItem>7 days</RadioGroupItem>
|
||||
<RadioGroupItem>14 days</RadioGroupItem>
|
||||
<RadioGroupItem>1 month</RadioGroupItem>
|
||||
<RadioGroupItem>3 month</RadioGroupItem>
|
||||
<RadioGroupItem>6 month</RadioGroupItem>
|
||||
<RadioGroupItem>1 year</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<div className="w-full max-w-[200px]">
|
||||
<Combobox
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value as IInterval));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
label: "Hour",
|
||||
value: "hour",
|
||||
},
|
||||
{
|
||||
label: "Day",
|
||||
value: "day",
|
||||
},
|
||||
{
|
||||
label: "Month",
|
||||
value: "month",
|
||||
},
|
||||
]}
|
||||
></Combobox>
|
||||
</div>
|
||||
</div>
|
||||
{startDate && endDate && (
|
||||
<ReportLineChart
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
events={events}
|
||||
breakdowns={breakdowns}
|
||||
interval={interval}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthShowcase() {
|
||||
const { data: sessionData } = useSession();
|
||||
|
||||
const { data: secretMessage } = api.example.getSecretMessage.useQuery(
|
||||
undefined, // no input
|
||||
{ enabled: sessionData?.user !== undefined }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<p className="text-center text-2xl text-white">
|
||||
{sessionData && <span>Logged in as {sessionData.user?.name}</span>}
|
||||
{secretMessage && <span> - {secretMessage}</span>}
|
||||
</p>
|
||||
<button
|
||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
|
||||
onClick={sessionData ? () => void signOut() : () => void signIn()}
|
||||
>
|
||||
{sessionData ? "Sign out" : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
apps/web/src/redux/index.ts
Normal file
18
apps/web/src/redux/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import reportSlice from '@/components/report/reportSlice'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { type TypedUseSelectorHook, useDispatch as useBaseDispatch, useSelector as useBaseSelector } from 'react-redux'
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
report: reportSlice
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
export const useDispatch: () => AppDispatch = useBaseDispatch
|
||||
export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector
|
||||
|
||||
|
||||
export default store
|
||||
@@ -1,5 +1,6 @@
|
||||
import { exampleRouter } from "@/server/api/routers/example";
|
||||
import { createTRPCRouter } from "@/server/api/trpc";
|
||||
import { chartMetaRouter } from "./routers/chartMeta";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -8,6 +9,7 @@ import { createTRPCRouter } from "@/server/api/trpc";
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter,
|
||||
chartMeta: chartMetaRouter
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
352
apps/web/src/server/api/routers/chartMeta.ts
Normal file
352
apps/web/src/server/api/routers/chartMeta.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import { map, path, pipe, sort, uniq } from "ramda";
|
||||
import { toDots } from "@/utils/object";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
zChartBreakdowns,
|
||||
zChartEvents,
|
||||
zChartType,
|
||||
zTimeInterval,
|
||||
} from "@/utils/validation";
|
||||
import { type IChartBreakdown, type IChartEvent } from "@/types";
|
||||
|
||||
type ResultItem = {
|
||||
label: string | null;
|
||||
count: number;
|
||||
date: string;
|
||||
};
|
||||
|
||||
function propertyNameToSql(name: string) {
|
||||
if (name.includes(".")) {
|
||||
return name
|
||||
.split(".")
|
||||
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
||||
.join("->");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function getChartData({
|
||||
chartType,
|
||||
event,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
chartType: string;
|
||||
event: IChartEvent;
|
||||
breakdowns: IChartBreakdown[];
|
||||
interval: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) {
|
||||
const select = [`count(*)::int as count`];
|
||||
const where = [];
|
||||
const groupBy = [];
|
||||
const orderBy = [];
|
||||
|
||||
switch (chartType) {
|
||||
case "bar": {
|
||||
orderBy.push("count DESC");
|
||||
break;
|
||||
}
|
||||
case "linear": {
|
||||
select.push(`date_trunc('${interval}', "createdAt") as date`);
|
||||
groupBy.push("date");
|
||||
orderBy.push("date");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (event) {
|
||||
const { name, filters } = event;
|
||||
where.push(`name = '${name}'`);
|
||||
if (filters.length > 0) {
|
||||
filters.forEach((filter) => {
|
||||
const { name, value } = filter;
|
||||
if (name.includes(".")) {
|
||||
where.push(`${propertyNameToSql(name)} = '"${value}"'`);
|
||||
} else {
|
||||
where.push(`${name} = '${value}'`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (breakdowns.length) {
|
||||
const breakdown = breakdowns[0];
|
||||
if (breakdown) {
|
||||
select.push(`${propertyNameToSql(breakdown.name)} as label`);
|
||||
groupBy.push(`label`);
|
||||
}
|
||||
} else {
|
||||
if (event.name) {
|
||||
select.push(`'${event.name}' as label`);
|
||||
}
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
where.push(`"createdAt" >= '${startDate.toISOString()}'`);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
where.push(`"createdAt" <= '${endDate.toISOString()}'`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT ${select.join(", ")}
|
||||
FROM events
|
||||
WHERE ${where.join(" AND ")}
|
||||
GROUP BY ${groupBy.join(", ")}
|
||||
ORDER BY ${orderBy.join(", ")}
|
||||
`;
|
||||
console.log(sql);
|
||||
|
||||
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||
const series = result.reduce(
|
||||
(acc, item) => {
|
||||
const label = item.label?.trim() ?? event.displayName;
|
||||
if (label) {
|
||||
if (acc[label]) {
|
||||
acc[label]?.push(item);
|
||||
} else {
|
||||
acc[label] = [item];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
};
|
||||
},
|
||||
{} as Record<string, ResultItem[]>,
|
||||
);
|
||||
|
||||
return Object.keys(series).map((key) => {
|
||||
return {
|
||||
name: breakdowns.length ? key ?? "break a leg" : event.displayName,
|
||||
data: fillEmptySpotsInTimeline(
|
||||
series[key] ?? [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
).map((item) => {
|
||||
return {
|
||||
...item,
|
||||
label: breakdowns.length ? key ?? "break a leg" : event.displayName,
|
||||
date: new Date(item.date).toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const chartMetaRouter = createTRPCRouter({
|
||||
events: protectedProcedure
|
||||
// .input(z.object())
|
||||
.query(async ({ input }) => {
|
||||
const events = await db.event.findMany({
|
||||
take: 500,
|
||||
distinct: ["name"],
|
||||
});
|
||||
|
||||
return events;
|
||||
}),
|
||||
|
||||
properties: protectedProcedure
|
||||
.input(z.object({ event: z.string() }).optional())
|
||||
.query(async ({ input }) => {
|
||||
const events = await db.event.findMany({
|
||||
take: 500,
|
||||
where: {
|
||||
...(input?.event
|
||||
? {
|
||||
name: input.event,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
const properties = events.reduce((acc, event) => {
|
||||
const properties = event as Record<string, unknown>;
|
||||
const dotNotation = toDots(properties);
|
||||
return [...acc, ...Object.keys(dotNotation)];
|
||||
}, [] as string[]);
|
||||
|
||||
return pipe(
|
||||
sort<string>((a, b) => a.length - b.length),
|
||||
uniq,
|
||||
)(properties);
|
||||
}),
|
||||
|
||||
values: protectedProcedure
|
||||
.input(z.object({ event: z.string(), property: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const events = await db.event.findMany({
|
||||
where: {
|
||||
name: input.event,
|
||||
properties: {
|
||||
path: input.property.split(".").slice(1),
|
||||
not: Prisma.DbNull,
|
||||
},
|
||||
createdAt: {
|
||||
// Take last 30 days
|
||||
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const values = uniq(
|
||||
map(path(input.property.split(".")), events),
|
||||
) as string[];
|
||||
|
||||
return {
|
||||
types: uniq(
|
||||
values.map((value) =>
|
||||
Array.isArray(value) ? "array" : typeof value,
|
||||
),
|
||||
),
|
||||
values,
|
||||
};
|
||||
}),
|
||||
|
||||
chart: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.date().nullish(),
|
||||
endDate: z.date().nullish(),
|
||||
chartType: zChartType,
|
||||
interval: zTimeInterval,
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
}),
|
||||
)
|
||||
.query(
|
||||
async ({
|
||||
input: { chartType, events, breakdowns, interval, ...input },
|
||||
}) => {
|
||||
const startDate = input.startDate ?? new Date();
|
||||
const endDate = input.endDate ?? new Date();
|
||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||
for (const event of events) {
|
||||
series.push(
|
||||
...(await getChartData({
|
||||
chartType,
|
||||
event,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
series: series.sort((a, b) => {
|
||||
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
|
||||
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
|
||||
return sumB - sumA;
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
function fillEmptySpotsInTimeline(
|
||||
items: ResultItem[],
|
||||
interval: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
) {
|
||||
const result = [];
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(2, 0, 0, 0);
|
||||
const modifiedEndDate = new Date(endDate);
|
||||
modifiedEndDate.setHours(2, 0, 0, 0);
|
||||
|
||||
while (currentDate.getTime() <= modifiedEndDate.getTime()) {
|
||||
const getYear = (date: Date) => date.getFullYear();
|
||||
const getMonth = (date: Date) => date.getMonth();
|
||||
const getDay = (date: Date) => date.getDate();
|
||||
const getHour = (date: Date) => date.getHours();
|
||||
const getMinute = (date: Date) => date.getMinutes();
|
||||
|
||||
const item = items.find((item) => {
|
||||
const date = new Date(item.date);
|
||||
|
||||
if (interval === "month") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate)
|
||||
);
|
||||
}
|
||||
if (interval === "day") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate)
|
||||
);
|
||||
}
|
||||
if (interval === "hour") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate) &&
|
||||
getHour(date) === getHour(currentDate)
|
||||
);
|
||||
}
|
||||
if (interval === "minute") {
|
||||
return (
|
||||
getYear(date) === getYear(currentDate) &&
|
||||
getMonth(date) === getMonth(currentDate) &&
|
||||
getDay(date) === getDay(currentDate) &&
|
||||
getHour(date) === getHour(currentDate) &&
|
||||
getMinute(date) === getMinute(currentDate)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (item) {
|
||||
result.push(item);
|
||||
} else {
|
||||
result.push({
|
||||
date: currentDate.toISOString(),
|
||||
count: 0,
|
||||
label: null,
|
||||
});
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case "day": {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
break;
|
||||
}
|
||||
case "hour": {
|
||||
currentDate.setHours(currentDate.getHours() + 1);
|
||||
break;
|
||||
}
|
||||
case "minute": {
|
||||
currentDate.setMinutes(currentDate.getMinutes() + 1);
|
||||
break;
|
||||
}
|
||||
case "month": {
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sort(function (a, b) {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
}, result);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export const exampleRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getAll: publicProcedure.query(({ ctx }) => {
|
||||
return ctx.db.example.findMany();
|
||||
getAll: publicProcedure.query(() => {
|
||||
return []
|
||||
}),
|
||||
|
||||
getSecretMessage: protectedProcedure.query(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { NextApiRequest, type GetServerSidePropsContext } from "next";
|
||||
import { type NextApiRequest, type GetServerSidePropsContext } from "next";
|
||||
import {
|
||||
getServerSession,
|
||||
type DefaultSession,
|
||||
@@ -39,7 +38,7 @@ declare module "next-auth" {
|
||||
*/
|
||||
export const authOptions: NextAuthOptions = {
|
||||
callbacks: {
|
||||
session: ({ session, user, token }) => ({
|
||||
session: ({ session, token }) => ({
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
MixanIssue,
|
||||
MixanErrorResponse
|
||||
type MixanIssue,
|
||||
type MixanErrorResponse
|
||||
} from '@mixan/types'
|
||||
import { NextApiResponse } from 'next'
|
||||
import { type NextApiResponse } from 'next'
|
||||
|
||||
export class HttpError extends Error {
|
||||
public status: number
|
||||
@@ -13,7 +13,7 @@ export class HttpError extends Error {
|
||||
super(message instanceof Error ? message.message : message)
|
||||
this.status = status
|
||||
this.message = message instanceof Error ? message.message : message
|
||||
this.issues = issues || []
|
||||
this.issues = issues ?? []
|
||||
}
|
||||
|
||||
toJson(): MixanErrorResponse {
|
||||
@@ -31,7 +31,7 @@ export function createIssues(arr: Array<MixanIssue>) {
|
||||
throw new HttpError(400, 'Issues', arr)
|
||||
}
|
||||
|
||||
export function createError(status = 500, error: unknown | Error | string) {
|
||||
export function createError(status = 500, error: unknown) {
|
||||
if(error instanceof Error || typeof error === 'string') {
|
||||
return new HttpError(status, error)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function createError(status = 500, error: unknown | Error | string) {
|
||||
return new HttpError(500, 'Unexpected error occured')
|
||||
}
|
||||
|
||||
export function handleError(res: NextApiResponse, error: Error | HttpError | unknown) {
|
||||
export function handleError(res: NextApiResponse, error: unknown) {
|
||||
if(error instanceof HttpError) {
|
||||
return res.status(error.status).json(error.toJson())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function tickProfileProperty({
|
||||
}
|
||||
|
||||
const properties = (
|
||||
typeof profile.properties === 'object' ? profile.properties || {} : {}
|
||||
typeof profile.properties === 'object' ? profile.properties ?? {} : {}
|
||||
) as Record<string, number>
|
||||
const value = name in properties ? properties[name] : 0
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function tickProfileProperty({
|
||||
}
|
||||
|
||||
if (typeof tick !== 'number') {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
13
apps/web/src/types/index.ts
Normal file
13
apps/web/src/types/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { type zTimeInterval, type zChartBreakdown, type zChartEvent } from "@/utils/validation";
|
||||
import { type TooltipProps } from "recharts";
|
||||
import { type z } from "zod";
|
||||
|
||||
export type IChartEvent = z.infer<typeof zChartEvent>
|
||||
export type IChartEventFilter = IChartEvent['filters'][number]
|
||||
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
|
||||
export type IInterval = z.infer<typeof zTimeInterval>
|
||||
|
||||
|
||||
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
||||
payload?: Array<T>
|
||||
}
|
||||
6
apps/web/src/utils/cn.ts
Normal file
6
apps/web/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
0
apps/web/src/utils/date.ts
Normal file
0
apps/web/src/utils/date.ts
Normal file
16
apps/web/src/utils/object.ts
Normal file
16
apps/web/src/utils/object.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
export function toDots(obj: Record<string, unknown>, path = ''): Record<string, number | string | boolean> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if(typeof value === 'object' && value !== null) {
|
||||
return {
|
||||
...acc,
|
||||
...toDots(value as Record<string, unknown>, `${path}${key}.`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[`${path}${key}`]: value,
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
13
apps/web/src/utils/theme.ts
Normal file
13
apps/web/src/utils/theme.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import resolveConfig from "tailwindcss/resolveConfig";
|
||||
import tailwinConfig from "../../tailwind.config.js";
|
||||
const config = resolveConfig(tailwinConfig);
|
||||
|
||||
export const theme = config.theme as any;
|
||||
|
||||
export function getChartColor(index: number): string {
|
||||
const chartColors: string[] = Object.keys(theme?.colors ?? {})
|
||||
.filter((key) => key.startsWith("chart-"))
|
||||
.map((key) => theme.colors[key] as string);
|
||||
|
||||
return chartColors[index % chartColors.length]!;
|
||||
}
|
||||
25
apps/web/src/utils/validation.ts
Normal file
25
apps/web/src/utils/validation.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zChartEvent = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.string(),
|
||||
filters: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export const zChartBreakdown = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const zChartEvents = z.array(zChartEvent);
|
||||
export const zChartBreakdowns = z.array(zChartBreakdown);
|
||||
|
||||
export const zChartType = z.enum(["bar", "linear"]);
|
||||
|
||||
export const zTimeInterval = z.enum(["day", "hour", "month"]);
|
||||
100
apps/web/tailwind.config.js
Normal file
100
apps/web/tailwind.config.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const colors = [
|
||||
"#7856ff",
|
||||
"#ff7557",
|
||||
"#7fe1d8",
|
||||
"#f8bc3c",
|
||||
"#b3596e",
|
||||
"#72bef4",
|
||||
"#ffb27a",
|
||||
"#0f7ea0",
|
||||
"#3ba974",
|
||||
"#febbb2",
|
||||
"#cb80dc",
|
||||
"#5cb7af",
|
||||
];
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
safelist: [
|
||||
...colors.map((color) => `chart-${color}`),
|
||||
],
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
...colors.reduce((acc, color, index) => {
|
||||
return {
|
||||
...acc,
|
||||
[`chart-${index}`]: color,
|
||||
};
|
||||
}, {}),
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
@@ -3,7 +3,26 @@ import { type Config } from "tailwindcss";
|
||||
export default {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
'chart-0': '',
|
||||
'chart-1': '',
|
||||
'chart-2': '',
|
||||
'chart-3': '',
|
||||
'chart-4': '',
|
||||
'chart-5': '',
|
||||
'chart-6': '',
|
||||
'chart-7': '',
|
||||
'chart-8': '',
|
||||
'chart-9': '',
|
||||
'chart-10': '',
|
||||
'chart-11': '',
|
||||
'chart-12': '',
|
||||
'chart-13': '',
|
||||
'chart-14': '',
|
||||
'chart-15': '',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
||||
@@ -63,7 +63,8 @@ export type MixanErrorResponse = {
|
||||
status: 'error'
|
||||
code: number
|
||||
message: string
|
||||
issues: Array<MixanIssue>
|
||||
issues?: Array<MixanIssue>
|
||||
stack?: string
|
||||
}
|
||||
|
||||
export type MixanResponse<T> = {
|
||||
|
||||
Reference in New Issue
Block a user