feat: add admin charts
This commit is contained in:
@@ -1,15 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { getAllUsersCount } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { InteractiveBarChart } from "@/components/charts/interactive-bar-chart";
|
||||
import { DashboardInfoCard } from "@/components/dashboard/dashboard-info-card";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import InfoCard from "@/components/dashboard/info-card";
|
||||
import TransactionsList from "@/components/dashboard/transactions-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Admin – WR.DO",
|
||||
@@ -43,15 +41,17 @@ export default async function AdminPage() {
|
||||
title="DNS Records"
|
||||
count={record_count}
|
||||
link="/admin/records"
|
||||
icon="globeLock"
|
||||
/>
|
||||
<DashboardInfoCard
|
||||
userId={user.id}
|
||||
title="Short URLs"
|
||||
count={url_count}
|
||||
link="/admin/urls"
|
||||
icon="link"
|
||||
/>
|
||||
</div>
|
||||
<TransactionsList />
|
||||
<InteractiveBarChart />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export default function ChartsLoading() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Charts" text="List of charts by shadcn-ui." />
|
||||
<Skeleton className="size-full rounded-lg" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { AreaChartStacked } from "@/components/charts/area-chart-stacked";
|
||||
import { BarChartMixed } from "@/components/charts/bar-chart-mixed";
|
||||
import { InteractiveBarChart } from "@/components/charts/interactive-bar-chart";
|
||||
import { LineChartMultiple } from "@/components/charts/line-chart-multiple";
|
||||
import { RadarChartSimple } from "@/components/charts/radar-chart-simple";
|
||||
import { RadialChartGrid } from "@/components/charts/radial-chart-grid";
|
||||
import { RadialShapeChart } from "@/components/charts/radial-shape-chart";
|
||||
import { RadialStackedChart } from "@/components/charts/radial-stacked-chart";
|
||||
import { RadialTextChart } from "@/components/charts/radial-text-chart";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Charts – WR.DO",
|
||||
description: "List of charts by shadcn-ui",
|
||||
});
|
||||
|
||||
export default function ChartsPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="Charts" text="List of charts by shadcn-ui." />
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-4">
|
||||
<RadialTextChart />
|
||||
<AreaChartStacked />
|
||||
<BarChartMixed />
|
||||
<RadarChartSimple />
|
||||
</div>
|
||||
|
||||
<InteractiveBarChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-4">
|
||||
<RadialChartGrid />
|
||||
<RadialShapeChart />
|
||||
<LineChartMultiple />
|
||||
<RadialStackedChart />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export default async function DashboardPage() {
|
||||
count={record_count}
|
||||
total={siteConfig.freeQuota.record}
|
||||
link="/dashboard/records"
|
||||
icon="globeLock"
|
||||
/>
|
||||
<DashboardInfoCard
|
||||
userId={user.id}
|
||||
@@ -50,6 +51,7 @@ export default async function DashboardPage() {
|
||||
count={url_count}
|
||||
total={siteConfig.freeQuota.url}
|
||||
link="/dashboard/urls"
|
||||
icon="link"
|
||||
/>
|
||||
</div>
|
||||
<UserRecordsList
|
||||
|
||||
@@ -79,8 +79,6 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
console.log("base", action);
|
||||
|
||||
const { data, error, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: UserRecordFormData[];
|
||||
@@ -107,7 +105,15 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
<div className="grid gap-2">
|
||||
<CardTitle>DNS Records</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
Your DNS Records
|
||||
See{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline"
|
||||
href="/docs/examples/vercel"
|
||||
>
|
||||
examples
|
||||
</Link>{" "}
|
||||
about how to use it.
|
||||
</CardDescription>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: threeMonthsAgo,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
const records = await prisma.userRecord.findMany({
|
||||
where: {
|
||||
created_on: {
|
||||
gte: threeMonthsAgo,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
created_on: "desc",
|
||||
},
|
||||
select: {
|
||||
created_on: true,
|
||||
},
|
||||
});
|
||||
const urls = await prisma.userUrl.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: threeMonthsAgo,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userCountByDate: { [date: string]: number } = {};
|
||||
const recordCountByDate: { [date: string]: number } = {};
|
||||
const urlCountByDate: { [date: string]: number } = {};
|
||||
|
||||
users.forEach((user) => {
|
||||
const date = user.createdAt!.toISOString().split("T")[0];
|
||||
userCountByDate[date] = (userCountByDate[date] || 0) + 1;
|
||||
});
|
||||
records.forEach((record) => {
|
||||
const date = record.created_on!.toISOString().split("T")[0];
|
||||
recordCountByDate[date] = (recordCountByDate[date] || 0) + 1;
|
||||
});
|
||||
urls.forEach((url) => {
|
||||
const date = url.createdAt.toISOString().split("T")[0];
|
||||
urlCountByDate[date] = (urlCountByDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const allDates = Array.from(
|
||||
new Set([
|
||||
...Object.keys(userCountByDate),
|
||||
...Object.keys(recordCountByDate),
|
||||
...Object.keys(urlCountByDate),
|
||||
]),
|
||||
);
|
||||
const combinedData = allDates.map((date) => ({
|
||||
date,
|
||||
records: recordCountByDate[date] || 0,
|
||||
urls: urlCountByDate[date] || 0,
|
||||
users: userCountByDate[date] || 0,
|
||||
}));
|
||||
|
||||
const total = {
|
||||
records: combinedData.reduce((acc, curr) => acc + curr.records, 0),
|
||||
urls: combinedData.reduce((acc, curr) => acc + curr.urls, 0),
|
||||
users: combinedData.reduce((acc, curr) => acc + curr.users, 0),
|
||||
};
|
||||
|
||||
return Response.json({ list: combinedData.reverse(), total });
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
statusText: error.statusText || "Server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,137 +19,52 @@ import {
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
];
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: "Page Views",
|
||||
label: "Create",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
records: {
|
||||
label: "Records",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
urls: {
|
||||
label: "URLs",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
users: {
|
||||
label: "Users",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function InteractiveBarChart() {
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("desktop");
|
||||
React.useState<keyof typeof chartConfig>("users");
|
||||
|
||||
const total = React.useMemo(
|
||||
() => ({
|
||||
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
|
||||
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { data, isLoading } = useSWR<{
|
||||
list: [{ records: number; urls: number; users: number; date: string }];
|
||||
total: { records: number; urls: number; users: number };
|
||||
}>(`api/admin`, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (isLoading) return <Skeleton className="size-full rounded-lg" />;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Bar Chart - Interactive</CardTitle>
|
||||
<CardTitle>Data Increase</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
Showing total data for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{["desktop", "mobile"].map((key) => {
|
||||
{["users", "records", "urls"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
return (
|
||||
<button
|
||||
@@ -160,7 +77,7 @@ export function InteractiveBarChart() {
|
||||
{chartConfig[chart].label}
|
||||
</span>
|
||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||
{total[key as keyof typeof total].toLocaleString()}
|
||||
{data.total[key as keyof typeof data.total].toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -174,7 +91,7 @@ export function InteractiveBarChart() {
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
data={data.list}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
|
||||
@@ -12,16 +12,16 @@ export async function DashboardInfoCard({
|
||||
total,
|
||||
count,
|
||||
link,
|
||||
// icon = "user",
|
||||
icon = "users",
|
||||
}: {
|
||||
userId: string;
|
||||
title: string;
|
||||
total?: number;
|
||||
count: number;
|
||||
link: string;
|
||||
// icon: keyof typeof Icons;
|
||||
icon?: keyof typeof Icons;
|
||||
}) {
|
||||
// const iconCpn = Icons[icon];
|
||||
const Icon = Icons[icon || "arrowRight"];
|
||||
return (
|
||||
<Card className="grids group bg-gray-50/70 backdrop-blur-lg dark:bg-primary-foreground">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -33,8 +33,7 @@ export async function DashboardInfoCard({
|
||||
{title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
{/* {icon && <iconCpn />} */}
|
||||
<LinkIcon className="size-4 text-muted-foreground" />
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{[-1, undefined].includes(count) ? (
|
||||
|
||||
@@ -29,25 +29,6 @@ interface DashboardSidebarProps {
|
||||
export function DashboardSidebar({ links }: DashboardSidebarProps) {
|
||||
const path = usePathname();
|
||||
|
||||
// NOTE: Use this if you want save in local storage -- Credits: Hosna Qasmei
|
||||
//
|
||||
// const [isSidebarExpanded, setIsSidebarExpanded] = useState(() => {
|
||||
// if (typeof window !== "undefined") {
|
||||
// const saved = window.localStorage.getItem("sidebarExpanded");
|
||||
// return saved !== null ? JSON.parse(saved) : true;
|
||||
// }
|
||||
// return true;
|
||||
// });
|
||||
|
||||
// useEffect(() => {
|
||||
// if (typeof window !== "undefined") {
|
||||
// window.localStorage.setItem(
|
||||
// "sidebarExpanded",
|
||||
// JSON.stringify(isSidebarExpanded),
|
||||
// );
|
||||
// }
|
||||
// }, [isSidebarExpanded]);
|
||||
|
||||
const { isTablet } = useMediaQuery();
|
||||
const [isSidebarExpanded, setIsSidebarExpanded] = useState(!isTablet);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SunMedium,
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -119,6 +120,7 @@ export const Icons = {
|
||||
</svg>
|
||||
),
|
||||
user: User,
|
||||
users: Users,
|
||||
warning: AlertTriangle,
|
||||
globeLock: GlobeLock,
|
||||
link: Link,
|
||||
|
||||
+4
-4
@@ -24,20 +24,20 @@ export const sidebarLinks: SidebarNavItem[] = [
|
||||
},
|
||||
{
|
||||
href: "/admin/users",
|
||||
icon: "user",
|
||||
title: "Users",
|
||||
icon: "users",
|
||||
title: "User List",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/records",
|
||||
icon: "lineChart",
|
||||
title: "Records",
|
||||
title: "Record List",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
{
|
||||
href: "/admin/urls",
|
||||
icon: "post",
|
||||
title: "URLs",
|
||||
title: "URL List",
|
||||
authorizeOnly: UserRole.ADMIN,
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export const expirationTime = (
|
||||
const remainingTime = expirationTime - now;
|
||||
if (remainingTime <= 0) return "Expired";
|
||||
|
||||
const remainingTimeString = ms(remainingTime, { long: false });
|
||||
const remainingTimeString = ms(remainingTime, { long: true });
|
||||
if (timeOnly) {
|
||||
return remainingTimeString;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user