feat: add url stats

This commit is contained in:
oiov
2024-08-02 11:27:44 +08:00
parent d342e7297c
commit 04e04bd5b4
8 changed files with 366 additions and 66 deletions
@@ -0,0 +1,172 @@
"use client";
import * as React from "react";
import { UrlMeta } from "@prisma/client";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
const chartConfig = {
pv: {
label: "Views",
color: "hsl(var(--chart-1))",
},
uv: {
label: "Visitors",
color: "hsl(var(--chart-2))",
},
};
function processUrlMeta(urlMetaArray: UrlMeta[]) {
const dailyData: { [key: string]: { clicks: number; ips: Set<string> } } = {};
urlMetaArray.forEach((meta) => {
const date = new Date(meta.createdAt).toISOString().split("T")[0];
if (!dailyData[date]) {
dailyData[date] = { clicks: 0, ips: new Set<string>() };
}
dailyData[date].clicks += meta.click;
dailyData[date].ips.add(meta.ip);
});
return Object.entries(dailyData).map(([date, data]) => ({
date,
clicks: data.clicks,
ips: Array.from(data.ips),
}));
}
function calculateUVAndPV(logs: UrlMeta[]) {
const uniqueIps = new Set<string>();
let totalClicks = 0;
logs.forEach((log) => {
uniqueIps.add(log.ip);
totalClicks += log.click;
});
return {
uv: uniqueIps.size,
pv: totalClicks,
};
}
export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
const processedData = processUrlMeta(data).map((entry) => ({
date: entry.date,
pv: entry.clicks,
uv: new Set(entry.ips).size,
}));
const dataTotal = calculateUVAndPV(data);
const latestEntry = data[data.length - 1];
const latestDate = new Date(latestEntry.updatedAt).toLocaleString("en-US");
const latestFrom = [
latestEntry.region ? latestEntry.region : "",
latestEntry.country ? `(${latestEntry.country})` : "",
latestEntry.city ? latestEntry.city : "",
]
.filter(Boolean)
.join(" ");
return (
<Card className="rounded-t-none">
<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-2 sm:py-3">
<CardTitle>Daily Stats</CardTitle>
<CardDescription>
Last visitor: {latestDate} from {latestFrom}.
</CardDescription>
</div>
<div className="flex">
{["pv", "uv"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col items-center justify-center gap-1 border-t px-6 py-2 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-3"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none">
{dataTotal[key as keyof typeof dataTotal].toLocaleString()}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[225px] w-full"
>
<BarChart
accessibilityLayer
// width={600}
// height={200}
data={processedData}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}}
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { UrlMeta, User } from "@prisma/client";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { DailyPVUVChart } from "./meta-chart";
export interface UrlMetaProps {
user: Pick<User, "id" | "name">;
action: string;
urlId: string;
}
export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
const { data, error, isLoading } = useSWR<UrlMeta[]>(
`${action}?id=${urlId}`,
fetcher,
);
if (isLoading)
return (
<div className="space-y-2 p-2">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
if (!data) {
return (
<EmptyPlaceholder>
<EmptyPlaceholder.Title>No Stats</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any stats yet.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
}
return (
<div className="animate-fade-down rounded-t-none">
<DailyPVUVChart data={data} />
</div>
);
}
+86 -61
View File
@@ -3,13 +3,12 @@
import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { LineChart, PenLine, RefreshCwIcon } from "lucide-react";
import useSWR, { useSWRConfig } from "swr";
import { siteConfig } from "@/config/site";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { cn, expirationTime, fetcher, timeAgo } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -35,6 +34,8 @@ import { UrlForm } from "@/components/forms/url-form";
import { CopyButton } from "@/components/shared/copy-button";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import UserUrlMetaInfo from "./meta";
export interface UrlListProps {
user: Pick<User, "id" | "name">;
action: string;
@@ -66,7 +67,6 @@ function TableColumnSekleton() {
}
export default function UserUrlsList({ user, action }: UrlListProps) {
const { isMobile } = useMediaQuery();
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
@@ -74,6 +74,8 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isShowStats, setShowStats] = useState(false);
const [selectedUrlId, setSelectedUrlId] = useState("");
const { mutate } = useSWRConfig();
const { data, error, isLoading } = useSWR<{
@@ -176,65 +178,88 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<TableRow
key={short.id}
className="grid animate-fade-in grid-cols-3 items-center animate-in sm:grid-cols-8"
>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`/s/${short.url}`}
target="_blank"
prefetch={false}
>
{short.url}
</Link>
<CopyButton
value={`${siteConfig.url}/s/${short.url}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
<>
<TableRow
key={short.id}
className="grid animate-fade-in grid-cols-3 items-center animate-in sm:grid-cols-8"
>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="line-clamp-2 overflow-hidden overflow-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`/s/${short.url}`}
target="_blank"
prefetch={false}
>
{short.url}
</Link>
<CopyButton
value={`${siteConfig.url}/s/${short.url}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
</TableCell>
<TableCell className="col-span-1 sm:col-span-2">
<Link
className="line-clamp-2 overflow-hidden overflow-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={short.target}
target="_blank"
prefetch={false}
>
{short.target.startsWith("http")
? short.target.split("//")[1]
: short.target}
</Link>
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
<StatusDot status={short.active} />
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center justify-center gap-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p>Edit</p>
<PenLine className="ml-1 size-3" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrlId(short.id!);
if (isShowStats && selectedUrlId !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<LineChart className="ml-1 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrlId === short.id && (
<UserUrlMetaInfo
user={{ id: user.id, name: user.name || "" }}
action="/api/url/meta"
urlId={short.id!}
/>
</TableCell>
<TableCell className="col-span-1 sm:col-span-2">
<Link
className="text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={short.target}
target="_blank"
prefetch={false}
>
{short.target.startsWith("http")
? short.target.split("//")[1]
: short.target}
</Link>
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
<StatusDot status={short.active} />
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden justify-center sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<Button
className="text-sm hover:bg-slate-100"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p>Edit</p>
<PenLine className="ml-1 size-4" />
</Button>
</TableCell>
</TableRow>
)}
</>
))
) : (
<EmptyPlaceholder>
+29
View File
@@ -0,0 +1,29 @@
import { getUserUrlMetaInfo } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const url = new URL(req.url);
const urlId = url.searchParams.get("id");
if (!urlId) {
return Response.json("url id is required", {
status: 400,
statusText: "url id is required",
});
}
const data = await getUserUrlMetaInfo(urlId);
return Response.json(data);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
statusText: error.statusText || "Server error",
});
}
}
+4 -4
View File
@@ -27,15 +27,15 @@ const chartConfig = {
},
records: {
label: "Records",
color: "hsl(var(--chart-1))",
color: "hsl(var(--chart-2))",
},
urls: {
label: "URLs",
color: "hsl(var(--chart-2))",
color: "hsl(var(--chart-1))",
},
users: {
label: "Users",
color: "hsl(var(--chart-2))",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
@@ -87,7 +87,7 @@ export function InteractiveBarChart() {
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
className="aspect-auto h-[200px] w-full"
>
<BarChart
accessibilityLayer
+4
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { geolocation } from "@vercel/functions";
import { auth } from "auth";
import { siteConfig } from "./config/site";
@@ -19,6 +20,9 @@ export default auth(async (req) => {
longitude: "",
};
const data = await fetch(`https://ip.wr.do/api?ip=${ip}`); // http://ip-api.com/json/42.48.83.141
const geoInfo = geolocation(req);
console.log("[geoInfo]", geoInfo, geoInfo?.city);
if (data.ok) {
const geoData = await data.json();
geo = {
+1
View File
@@ -57,6 +57,7 @@
"@t3-oss/env-nextjs": "^0.11.0",
"@typescript-eslint/parser": "^7.16.1",
"@vercel/analytics": "^1.3.1",
"@vercel/functions": "^1.4.0",
"@vercel/og": "^0.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+15 -1
View File
@@ -122,6 +122,9 @@ importers:
'@vercel/analytics':
specifier: ^1.3.1
version: 1.3.1(next@14.2.5(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@vercel/functions':
specifier: ^1.4.0
version: 1.4.0
'@vercel/og':
specifier: ^0.6.2
version: 0.6.2
@@ -2706,6 +2709,15 @@ packages:
react:
optional: true
'@vercel/functions@1.4.0':
resolution: {integrity: sha512-Ln6SpIkms1UJg306X2kbEMyG9ol+mjDr2xx389cvsBxgFyFMI9Bm+LYOG4N3TSik4FI59MECyyc4oz7AIAYmqQ==}
engines: {node: '>= 16'}
peerDependencies:
'@aws-sdk/credential-provider-web-identity': '*'
peerDependenciesMeta:
'@aws-sdk/credential-provider-web-identity':
optional: true
'@vercel/og@0.6.2':
resolution: {integrity: sha512-OTe0KE37F5Y2eTys6eMnfopC+P4qr2ooXUTFyFPTplYSPwowmFk/HLD1FXtbKLjqsIH0SgekcJWad+C5uX4nkg==}
engines: {node: '>=16'}
@@ -8268,7 +8280,7 @@ snapshots:
'@react-aria/i18n': 3.11.1(react@18.3.1)
'@react-aria/interactions': 3.22.1(react@18.3.1)
'@react-aria/ssr': 3.9.5(react@18.3.1)
'@react-aria/utils': 3.24.1(react@18.3.1)
'@react-aria/utils': 3.25.1(react@18.3.1)
'@react-aria/visually-hidden': 3.8.14(react@18.3.1)
'@react-stately/overlays': 3.6.9(react@18.3.1)
'@react-types/button': 3.9.6(react@18.3.1)
@@ -8786,6 +8798,8 @@ snapshots:
next: 14.2.5(@babel/core@7.24.5)(@opentelemetry/api@1.8.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
'@vercel/functions@1.4.0': {}
'@vercel/og@0.6.2':
dependencies:
'@resvg/resvg-wasm': 2.4.0