mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-12 16:06:38 +00:00
Merge branch 'dev' into feat/login-page-customization
This commit is contained in:
@@ -11,7 +11,6 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
|
||||
@@ -7,209 +7,220 @@ import { Calendar } from "@app/components/ui/calendar";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export interface DateTimeValue {
|
||||
date?: Date;
|
||||
time?: string;
|
||||
date?: Date;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
label?: string;
|
||||
value?: DateTimeValue;
|
||||
onChange?: (value: DateTimeValue) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
label?: string;
|
||||
value?: DateTimeValue;
|
||||
onChange?: (value: DateTimeValue) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
|
||||
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalDate, setInternalDate] = useState<Date | undefined>(
|
||||
value?.date
|
||||
);
|
||||
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
||||
|
||||
// Sync internal state with external value prop
|
||||
useEffect(() => {
|
||||
setInternalDate(value?.date);
|
||||
setInternalTime(value?.time || "");
|
||||
}, [value?.date, value?.time]);
|
||||
// Sync internal state with external value prop
|
||||
useEffect(() => {
|
||||
setInternalDate(value?.date);
|
||||
setInternalTime(value?.time || "");
|
||||
}, [value?.date, value?.time]);
|
||||
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setInternalDate(date);
|
||||
const newValue = { date, time: internalTime };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setInternalDate(date);
|
||||
const newValue = { date, time: internalTime };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const time = event.target.value;
|
||||
setInternalTime(time);
|
||||
const newValue = { date: internalDate, time };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const time = event.target.value;
|
||||
setInternalTime(time);
|
||||
const newValue = { date: internalDate, time };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (!internalDate) return placeholder;
|
||||
|
||||
const dateStr = internalDate.toLocaleDateString();
|
||||
if (!showTime || !internalTime) return dateStr;
|
||||
|
||||
// Parse time and format in local timezone
|
||||
const [hours, minutes, seconds] = internalTime.split(':');
|
||||
const timeDate = new Date();
|
||||
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
|
||||
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
};
|
||||
const getDisplayText = () => {
|
||||
if (!internalDate) return placeholder;
|
||||
|
||||
const hasValue = internalDate || (showTime && internalTime);
|
||||
const dateStr = internalDate.toLocaleDateString();
|
||||
if (!showTime || !internalTime) return dateStr;
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4", className)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<Label htmlFor="date-picker">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id="date-picker"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
showTime ? "w-48" : "w-32",
|
||||
!hasValue && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
{showTime ? (
|
||||
<div className="flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
if (!showTime) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className="flex-grow w-[250px]"
|
||||
/>
|
||||
<div className="p-3 border-l">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="time-input" className="text-sm font-medium">
|
||||
Time
|
||||
</Label>
|
||||
<Input
|
||||
id="time-input"
|
||||
type="time"
|
||||
step="1"
|
||||
value={internalTime}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
// Parse time and format in local timezone
|
||||
const [hours, minutes, seconds] = internalTime.split(":");
|
||||
const timeDate = new Date();
|
||||
timeDate.setHours(
|
||||
parseInt(hours, 10),
|
||||
parseInt(minutes, 10),
|
||||
parseInt(seconds || "0", 10)
|
||||
);
|
||||
const timeStr = timeDate.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
};
|
||||
|
||||
const hasValue = internalDate || (showTime && internalTime);
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4", className)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && <Label htmlFor="date-picker">{label}</Label>}
|
||||
<div className="flex gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id="date-picker"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
showTime ? "w-48" : "w-32",
|
||||
!hasValue && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto overflow-hidden p-0"
|
||||
align="start"
|
||||
>
|
||||
{showTime ? (
|
||||
<div className="flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
if (!showTime) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className="grow w-[250px]"
|
||||
/>
|
||||
<div className="p-3 border-l">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label
|
||||
htmlFor="time-input"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Time
|
||||
</Label>
|
||||
<Input
|
||||
id="time-input"
|
||||
type="time"
|
||||
step="1"
|
||||
value={internalTime}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
startValue?: DateTimeValue;
|
||||
endValue?: DateTimeValue;
|
||||
onStartChange?: (value: DateTimeValue) => void;
|
||||
onEndChange?: (value: DateTimeValue) => void;
|
||||
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
startValue?: DateTimeValue;
|
||||
endValue?: DateTimeValue;
|
||||
onStartChange?: (value: DateTimeValue) => void;
|
||||
onEndChange?: (value: DateTimeValue) => void;
|
||||
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
// startLabel = "From",
|
||||
// endLabel = "To",
|
||||
startValue,
|
||||
endValue,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onRangeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
// startLabel = "From",
|
||||
// endLabel = "To",
|
||||
startValue,
|
||||
endValue,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onRangeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true
|
||||
}: DateRangePickerProps) {
|
||||
const handleStartChange = (value: DateTimeValue) => {
|
||||
onStartChange?.(value);
|
||||
if (onRangeChange && endValue) {
|
||||
onRangeChange(value, endValue);
|
||||
}
|
||||
};
|
||||
const handleStartChange = (value: DateTimeValue) => {
|
||||
onStartChange?.(value);
|
||||
if (onRangeChange && endValue) {
|
||||
onRangeChange(value, endValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndChange = (value: DateTimeValue) => {
|
||||
onEndChange?.(value);
|
||||
if (onRangeChange && startValue) {
|
||||
onRangeChange(startValue, value);
|
||||
}
|
||||
};
|
||||
const handleEndChange = (value: DateTimeValue) => {
|
||||
onEndChange?.(value);
|
||||
if (onRangeChange && startValue) {
|
||||
onRangeChange(startValue, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4 items-center", className)}>
|
||||
<DateTimePicker
|
||||
label="Start"
|
||||
value={startValue}
|
||||
onChange={handleStartChange}
|
||||
placeholder="Start date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="End"
|
||||
value={endValue}
|
||||
onChange={handleEndChange}
|
||||
placeholder="End date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex gap-4 items-center", className)}>
|
||||
<DateTimePicker
|
||||
label="Start"
|
||||
value={startValue}
|
||||
onChange={handleStartChange}
|
||||
placeholder="Start date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="End"
|
||||
value={endValue}
|
||||
onChange={handleEndChange}
|
||||
placeholder="End date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ type HealthCheckConfig = {
|
||||
hcFollowRedirects: boolean;
|
||||
hcMode: string;
|
||||
hcUnhealthyInterval: number;
|
||||
hcTlsServerName: string;
|
||||
};
|
||||
|
||||
type HealthCheckDialogProps = {
|
||||
@@ -93,7 +94,8 @@ export default function HealthCheckDialog({
|
||||
hcPort: z.number().positive().gt(0).lte(65535),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.int().positive().min(5)
|
||||
hcUnhealthyInterval: z.int().positive().min(5),
|
||||
hcTlsServerName: z.string()
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof healthCheckSchema>>({
|
||||
@@ -129,7 +131,8 @@ export default function HealthCheckDialog({
|
||||
hcPort: initialConfig?.hcPort,
|
||||
hcFollowRedirects: initialConfig?.hcFollowRedirects,
|
||||
hcMode: initialConfig?.hcMode,
|
||||
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval
|
||||
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval,
|
||||
hcTlsServerName: initialConfig?.hcTlsServerName ?? ""
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
@@ -531,6 +534,37 @@ export default function HealthCheckDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/*TLS Server Name (SNI)*/}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("tlsServerName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleFieldChange(
|
||||
"hcTlsServerName",
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"tlsServerNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function InfoSections({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
|
||||
className={`grid md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start grid-cols-1`}
|
||||
style={{
|
||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||
// value of a CSS variable at runtime and tailwind will just reuse that value
|
||||
|
||||
541
src/components/LogAnalyticsData.tsx
Normal file
541
src/components/LogAnalyticsData.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import {
|
||||
logAnalyticsFiltersSchema,
|
||||
logQueries,
|
||||
resourceQueries
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
|
||||
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./ui/select";
|
||||
import { Label } from "./ui/label";
|
||||
import { Separator } from "./ui/separator";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "./InfoSection";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig
|
||||
} from "./ui/chart";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
|
||||
export type AnalyticsContentProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const path = usePathname();
|
||||
const t = useTranslations();
|
||||
|
||||
const filters = logAnalyticsFiltersSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const isEmptySearchParams =
|
||||
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
|
||||
|
||||
const env = useEnvContext();
|
||||
const [api] = useState(() => createApiClient(env));
|
||||
const router = useRouter();
|
||||
|
||||
const dateRange = {
|
||||
startDate: filters.timeStart ? new Date(filters.timeStart) : undefined,
|
||||
endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined
|
||||
};
|
||||
|
||||
const { data: resources = [], isFetching: isFetchingResources } = useQuery(
|
||||
resourceQueries.listNamesPerOrg(props.orgId, api)
|
||||
);
|
||||
|
||||
const {
|
||||
data: stats,
|
||||
isFetching: isFetchingAnalytics,
|
||||
refetch: refreshAnalytics,
|
||||
isLoading: isLoadingAnalytics // only `true` when there is no data yet
|
||||
} = useQuery(
|
||||
logQueries.requestAnalytics({
|
||||
orgId: props.orgId,
|
||||
api,
|
||||
filters
|
||||
})
|
||||
);
|
||||
|
||||
const percentBlocked = stats
|
||||
? new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 2
|
||||
}).format((stats.totalBlocked / stats.totalRequests) * 100)
|
||||
: null;
|
||||
const totalRequests = stats
|
||||
? new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 0
|
||||
}).format(stats.totalRequests)
|
||||
: null;
|
||||
|
||||
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
|
||||
const newSearch = new URLSearchParams(searchParams);
|
||||
const timeRegex =
|
||||
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
|
||||
|
||||
if (start.date) {
|
||||
const startDate = new Date(start.date);
|
||||
if (start.time) {
|
||||
const time = timeRegex.exec(start.time);
|
||||
const groups = time?.groups ?? {};
|
||||
startDate.setHours(Number(groups.hours));
|
||||
startDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
startDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
newSearch.set("timeStart", startDate.toISOString());
|
||||
}
|
||||
if (end.date) {
|
||||
const endDate = new Date(end.date);
|
||||
|
||||
if (end.time) {
|
||||
const time = timeRegex.exec(end.time);
|
||||
const groups = time?.groups ?? {};
|
||||
endDate.setHours(Number(groups.hours));
|
||||
endDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
endDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
|
||||
console.log({
|
||||
endDate
|
||||
});
|
||||
newSearch.set("timeEnd", endDate.toISOString());
|
||||
}
|
||||
router.replace(`${path}?${newSearch.toString()}`);
|
||||
}
|
||||
function getDateTime(date: Date) {
|
||||
return `${date.getHours()}:${date.getMinutes()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card className="">
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-start lg:items-end sm:justify-between sm:space-y-0 pb-4">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full sm:mr-2 gap-2">
|
||||
<DateRangePicker
|
||||
startValue={{
|
||||
date: dateRange.startDate,
|
||||
time: dateRange.startDate
|
||||
? getDateTime(dateRange.startDate)
|
||||
: undefined
|
||||
}}
|
||||
endValue={{
|
||||
date: dateRange.endDate,
|
||||
time: dateRange.endDate
|
||||
? getDateTime(dateRange.endDate)
|
||||
: undefined
|
||||
}}
|
||||
onRangeChange={handleTimeRangeUpdate}
|
||||
className="flex-wrap gap-2"
|
||||
/>
|
||||
|
||||
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col items-start gap-2 w-48">
|
||||
<Label htmlFor="resourceId">
|
||||
{t("filterByResource")}
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(newValue) => {
|
||||
const newSearch = new URLSearchParams(
|
||||
searchParams
|
||||
);
|
||||
newSearch.delete("resourceId");
|
||||
if (newValue !== "all") {
|
||||
newSearch.set(
|
||||
"resourceId",
|
||||
newValue
|
||||
);
|
||||
}
|
||||
|
||||
router.replace(
|
||||
`${path}?${newSearch.toString()}`
|
||||
);
|
||||
}}
|
||||
value={
|
||||
filters.resourceId?.toString() ?? "all"
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="resourceId"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("selectResource")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full">
|
||||
{resources.map((resource) => (
|
||||
<SelectItem
|
||||
key={resource.resourceId}
|
||||
value={resource.resourceId.toString()}
|
||||
>
|
||||
{resource.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="all">
|
||||
All resources
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!isEmptySearchParams && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
router.replace(path);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refreshAnalytics()}
|
||||
disabled={isFetchingAnalytics}
|
||||
className=" relative top-6 lg:static gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"size-4",
|
||||
isFetchingAnalytics && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="text-muted-foreground">
|
||||
{t("totalRequests")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{totalRequests ?? "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="text-muted-foreground">
|
||||
{t("totalBlocked")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>{stats?.totalBlocked ?? "--"}</span>
|
||||
(
|
||||
<span>{percentBlocked ?? "--"}</span>
|
||||
<span className="text-muted-foreground">%</span>
|
||||
)
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full h-full flex flex-col gap-8">
|
||||
<CardHeader>
|
||||
<h3 className="font-medium">{t("requestsByDay")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RequestChart
|
||||
data={stats?.requestsPerDay ?? []}
|
||||
isLoading={isLoadingAnalytics}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card className="w-full h-full">
|
||||
<CardHeader>
|
||||
<h3 className="font-medium">
|
||||
{t("requestsByCountry")}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorldMap
|
||||
data={stats?.requestsPerCountry ?? []}
|
||||
label={{
|
||||
singular: "request",
|
||||
plural: "requests"
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full h-full">
|
||||
<CardHeader>
|
||||
<h3 className="font-medium">{t("topCountries")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
<TopCountriesList
|
||||
countries={stats?.requestsPerCountry ?? []}
|
||||
total={stats?.totalRequests ?? 0}
|
||||
isLoading={isLoadingAnalytics}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RequestChartProps = {
|
||||
data: {
|
||||
day: string;
|
||||
allowedCount: number;
|
||||
blockedCount: number;
|
||||
totalCount: number;
|
||||
}[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function RequestChart(props: RequestChartProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
|
||||
const chartConfig = {
|
||||
day: {
|
||||
label: t("requestsByDay")
|
||||
},
|
||||
blockedCount: {
|
||||
label: t("blocked"),
|
||||
color: "var(--chart-5)"
|
||||
},
|
||||
allowedCount: {
|
||||
label: t("allowed"),
|
||||
color: "var(--chart-2)"
|
||||
}
|
||||
} satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full h-80"
|
||||
>
|
||||
<LineChart accessibilityLayer data={props.data}>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
labelFormatter={(value, payload) => {
|
||||
const formattedDate = new Date(
|
||||
payload[0].payload.day
|
||||
).toLocaleDateString(navigator.language, {
|
||||
dateStyle: "medium"
|
||||
});
|
||||
return formattedDate;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[
|
||||
0,
|
||||
Math.max(...props.data.map((datum) => datum.totalCount))
|
||||
]}
|
||||
allowDataOverflow
|
||||
type="number"
|
||||
tickFormatter={(value) => {
|
||||
return numberFormatter.format(value);
|
||||
}}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString(
|
||||
navigator.language,
|
||||
{
|
||||
dateStyle: "medium"
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
dataKey="allowedCount"
|
||||
stroke="var(--color-allowedCount)"
|
||||
strokeWidth={2}
|
||||
fill="transparent"
|
||||
radius={4}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="blockedCount"
|
||||
stroke="var(--color-blockedCount)"
|
||||
strokeWidth={2}
|
||||
fill="transparent"
|
||||
radius={4}
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
||||
type TopCountriesListProps = {
|
||||
countries: {
|
||||
code: string;
|
||||
count: number;
|
||||
}[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function TopCountriesList(props: TopCountriesListProps) {
|
||||
const t = useTranslations();
|
||||
const displayNames = new Intl.DisplayNames(navigator.language, {
|
||||
type: "region",
|
||||
fallback: "code"
|
||||
});
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
const percentFormatter = new Intl.NumberFormat(navigator.language, {
|
||||
maximumFractionDigits: 0,
|
||||
style: "percent"
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
{props.countries.length > 0 && (
|
||||
<div className="grid grid-cols-7 text-sm text-muted-foreground font-medium h-4">
|
||||
<div className="col-span-5">{t("countries")}</div>
|
||||
<div className="text-end">{t("total")}</div>
|
||||
<div className="text-end">%</div>
|
||||
</div>
|
||||
)}
|
||||
{/* `aspect-475/335` is the same aspect ratio as the world map component */}
|
||||
<ol className="w-full overflow-auto grid gap-1 aspect-475/335">
|
||||
{props.countries.length === 0 && (
|
||||
<div className="flex items-center justify-center size-full text-muted-foreground font-mono gap-1">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin" />{" "}
|
||||
{t("loading")}
|
||||
</>
|
||||
) : (
|
||||
t("noData")
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.countries.map((country) => {
|
||||
const percent = country.count / props.total;
|
||||
return (
|
||||
<li
|
||||
key={country.code}
|
||||
className="grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
|
||||
)}
|
||||
style={{
|
||||
width: `${percent * 100}%`
|
||||
}}
|
||||
/>
|
||||
<div className="col-span-5 px-2 py-1 relative z-1">
|
||||
<span className="inline-flex gap-2 items-center">
|
||||
{countryCodeToFlagEmoji(country.code)}{" "}
|
||||
{displayNames.of(country.code)}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="text-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="inline">
|
||||
{numberFormatter.format(
|
||||
country.count
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<strong>
|
||||
{Intl.NumberFormat(
|
||||
navigator.language
|
||||
).format(country.count)}
|
||||
</strong>{" "}
|
||||
{country.count === 1
|
||||
? t("request")
|
||||
: t("requests")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="text-end">
|
||||
{percentFormatter.format(percent)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,7 +162,7 @@ export function SidebarNav({
|
||||
<div key={section.heading} className="mb-2">
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{section.heading}
|
||||
{t(section.heading)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -92,7 +92,8 @@ const formSchema = z
|
||||
message:
|
||||
"You must agree to the terms of service and privacy policy"
|
||||
}
|
||||
)
|
||||
),
|
||||
marketingEmailConsent: z.boolean().optional()
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
@@ -123,7 +124,8 @@ export default function SignupForm({
|
||||
email: emailParam || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
agreeToTerms: false,
|
||||
marketingEmailConsent: false
|
||||
},
|
||||
mode: "onChange" // Enable real-time validation
|
||||
});
|
||||
@@ -135,7 +137,7 @@ export default function SignupForm({
|
||||
passwordValue === confirmPasswordValue;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
const { email, password, marketingEmailConsent } = values;
|
||||
|
||||
setLoading(true);
|
||||
const res = await api
|
||||
@@ -144,7 +146,8 @@ export default function SignupForm({
|
||||
password,
|
||||
inviteId,
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt
|
||||
termsAcceptedTimestamp: termsAgreedAt,
|
||||
marketingEmailConsent: build === "saas" ? marketingEmailConsent : undefined
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
@@ -489,56 +492,78 @@ export default function SignupForm({
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t("signUpTerms.and")}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t("signUpTerms.and")}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="marketingEmailConsent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpMarketing.keepMeInTheLoop")}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
||||
27
src/components/TailwindIndicator.tsx
Normal file
27
src/components/TailwindIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
export function TailwindIndicator() {
|
||||
const [mediaSize, setMediaSize] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
const listener = () => setMediaSize(window.innerWidth);
|
||||
window.addEventListener("resize", listener);
|
||||
|
||||
listener();
|
||||
return () => {
|
||||
window.removeEventListener("resize", listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-16 left-5 z-9999999 flex h-6 items-center justify-center gap-2 rounded-full bg-primary p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden">sm</div>
|
||||
<div className="hidden md:block lg:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block xxl:hidden">2xl</div>| {mediaSize}
|
||||
px
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/components/WorldMap.tsx
Normal file
275
src/components/WorldMap.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Inspired from plausible: https://github.com/plausible/analytics/blob/1df08a25b4a536c9cc1e03855ddcfeac1d1cf6e5/assets/js/dashboard/stats/locations/map.tsx
|
||||
*/
|
||||
import { cn } from "@app/lib/cn";
|
||||
import worldJson from "visionscarto-world-atlas/world/110m.json";
|
||||
import * as topojson from "topojson-client";
|
||||
import * as d3 from "d3";
|
||||
import { useRef, type ComponentRef, useState, useEffect, useMemo } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { COUNTRY_CODE_LIST } from "@app/lib/countryCodeList";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CountryData = {
|
||||
alpha_3: string;
|
||||
name: string;
|
||||
count: number;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type WorldMapProps = {
|
||||
data: Pick<CountryData, "code" | "count">[];
|
||||
label: {
|
||||
singular: string;
|
||||
plural: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function WorldMap({ data, label }: WorldMapProps) {
|
||||
const svgRef = useRef<ComponentRef<"svg">>(null);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
hoveredCountryAlpha3Code: string | null;
|
||||
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||
const { theme, systemTheme } = useTheme();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return;
|
||||
const svg = drawInteractiveCountries(svgRef.current, setTooltip);
|
||||
|
||||
return () => {
|
||||
svg.selectAll("*").remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const displayNames = new Intl.DisplayNames(navigator.language, {
|
||||
type: "region",
|
||||
fallback: "code"
|
||||
});
|
||||
|
||||
const maxValue = Math.max(...data.map((item) => item.count));
|
||||
const dataByCountryCode = useMemo(() => {
|
||||
const byCountryCode = new Map<string, CountryData>();
|
||||
for (const country of data) {
|
||||
const countryISOData = COUNTRY_CODE_LIST[country.code];
|
||||
|
||||
if (countryISOData) {
|
||||
byCountryCode.set(countryISOData.alpha3, {
|
||||
...country,
|
||||
name: displayNames.of(country.code)!,
|
||||
alpha_3: countryISOData.alpha3
|
||||
});
|
||||
}
|
||||
}
|
||||
return byCountryCode;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current) {
|
||||
const palette =
|
||||
colorScales[theme ?? "light"] ??
|
||||
colorScales[systemTheme ?? "light"];
|
||||
|
||||
const getColorForValue = d3
|
||||
.scaleLinear<string>()
|
||||
.domain([0, maxValue])
|
||||
.range(palette);
|
||||
|
||||
colorInCountriesWithValues(
|
||||
svgRef.current,
|
||||
getColorForValue,
|
||||
dataByCountryCode
|
||||
);
|
||||
}
|
||||
}, [theme, systemTheme, maxValue, dataByCountryCode]);
|
||||
|
||||
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
|
||||
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-4 w-full relative">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{!!hoveredCountryData && (
|
||||
<MapTooltip
|
||||
x={tooltip.x}
|
||||
y={tooltip.y}
|
||||
name={hoveredCountryData.name}
|
||||
value={Intl.NumberFormat(navigator.language).format(
|
||||
hoveredCountryData.count
|
||||
)}
|
||||
label={
|
||||
hoveredCountryData.count === 1
|
||||
? t(label.singular)
|
||||
: t(label.plural)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MapTooltipProps {
|
||||
name: string;
|
||||
value: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function MapTooltip({ name, value, label, x, y }: MapTooltipProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-50 p-2 translate-x-2 translate-y-2",
|
||||
"pointer-events-none rounded-sm",
|
||||
"bg-white dark:bg-popover shadow border border-border"
|
||||
)}
|
||||
style={{
|
||||
left: x,
|
||||
top: y
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{name}</div>
|
||||
<strong className="text-primary">{value}</strong> {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = 475;
|
||||
const height = 335;
|
||||
const sharedCountryClass = cn("transition-colors");
|
||||
|
||||
const colorScales: Record<string, [string, string]> = {
|
||||
dark: ["#4F4444", "#f36117"],
|
||||
light: ["#FFF5F3", "#f36117"]
|
||||
};
|
||||
|
||||
const countryClass = cn(
|
||||
sharedCountryClass,
|
||||
"stroke-1",
|
||||
"fill-[#fafafa]",
|
||||
"stroke-[#E7DADA]",
|
||||
"dark:fill-[#323236]",
|
||||
"dark:stroke-[#18181b]"
|
||||
);
|
||||
|
||||
const highlightedCountryClass = cn(
|
||||
sharedCountryClass,
|
||||
"stroke-2",
|
||||
"fill-[#f4f4f5]",
|
||||
"stroke-[#f36117]",
|
||||
"dark:fill-[#3f3f46]"
|
||||
);
|
||||
|
||||
function setupProjetionPath() {
|
||||
const projection = d3
|
||||
.geoMercator()
|
||||
.scale(75)
|
||||
.translate([width / 2, height / 1.5]);
|
||||
|
||||
const path = d3.geoPath().projection(projection);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** @returns the d3 selected svg element */
|
||||
function drawInteractiveCountries(
|
||||
element: SVGSVGElement,
|
||||
setTooltip: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
x: number;
|
||||
y: number;
|
||||
hoveredCountryAlpha3Code: string | null;
|
||||
}>
|
||||
>
|
||||
) {
|
||||
const path = setupProjetionPath();
|
||||
const data = parseWorldTopoJsonToGeoJsonFeatures();
|
||||
const svg = d3.select(element);
|
||||
|
||||
svg.selectAll("path")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("class", countryClass)
|
||||
.attr("d", path as never)
|
||||
|
||||
.on("mouseover", function (event, country) {
|
||||
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
|
||||
setTooltip({
|
||||
x,
|
||||
y,
|
||||
hoveredCountryAlpha3Code: country.properties.a3
|
||||
});
|
||||
// brings country to front
|
||||
this.parentNode?.appendChild(this);
|
||||
d3.select(this).attr("class", highlightedCountryClass);
|
||||
})
|
||||
|
||||
.on("mousemove", function (event) {
|
||||
const [x, y] = d3.pointer(event, svg.node()?.parentNode);
|
||||
setTooltip((currentState) => ({ ...currentState, x, y }));
|
||||
})
|
||||
|
||||
.on("mouseout", function () {
|
||||
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||
d3.select(this).attr("class", countryClass);
|
||||
});
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
type WorldJsonCountryData = { properties: { name: string; a3: string } };
|
||||
|
||||
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
|
||||
const collection = topojson.feature(
|
||||
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
|
||||
worldJson,
|
||||
worldJson.objects.countries
|
||||
);
|
||||
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
|
||||
return collection.features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to color the countries
|
||||
* @returns the svg elements represeting countries
|
||||
*/
|
||||
function colorInCountriesWithValues(
|
||||
element: SVGSVGElement,
|
||||
getColorForValue: d3.ScaleLinear<string, string, never>,
|
||||
dataByCountryCode: Map<string, CountryData>
|
||||
) {
|
||||
function getCountryByCountryPath(countryPath: unknown) {
|
||||
return dataByCountryCode.get(
|
||||
(countryPath as unknown as WorldJsonCountryData).properties.a3
|
||||
);
|
||||
}
|
||||
|
||||
const svg = d3.select(element);
|
||||
|
||||
return svg
|
||||
.selectAll("path")
|
||||
.style("fill", (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath);
|
||||
if (!country?.count) {
|
||||
return null;
|
||||
}
|
||||
return getColorForValue(country.count);
|
||||
})
|
||||
.style("cursor", (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath);
|
||||
if (!country?.count) {
|
||||
return null;
|
||||
}
|
||||
return "pointer";
|
||||
});
|
||||
}
|
||||
420
src/components/ui/chart.tsx
Normal file
420
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n")
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
);
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
|
||||
const itemConfig = getPayloadConfigFromPayload(
|
||||
config,
|
||||
item,
|
||||
key
|
||||
);
|
||||
const indicatorColor =
|
||||
color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter &&
|
||||
item?.value !== undefined &&
|
||||
item.name ? (
|
||||
formatter(
|
||||
item.value,
|
||||
item.name,
|
||||
item,
|
||||
index,
|
||||
item.payload
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5":
|
||||
indicator ===
|
||||
"dot",
|
||||
"w-1":
|
||||
indicator ===
|
||||
"line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator ===
|
||||
"dashed",
|
||||
"my-0.5":
|
||||
nestLabel &&
|
||||
indicator ===
|
||||
"dashed"
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg":
|
||||
indicatorColor,
|
||||
"--color-border":
|
||||
indicatorColor
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none gap-2",
|
||||
nestLabel
|
||||
? "items-end"
|
||||
: "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel
|
||||
? tooltipLabel
|
||||
: null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ||
|
||||
item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{!isNaN(
|
||||
item.value as number
|
||||
)
|
||||
? new Intl.NumberFormat(
|
||||
navigator.language,
|
||||
{
|
||||
maximumFractionDigits: 0
|
||||
}
|
||||
).format(
|
||||
item.value as number
|
||||
)
|
||||
: item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(
|
||||
config,
|
||||
item,
|
||||
key
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle
|
||||
};
|
||||
Reference in New Issue
Block a user