feat: add url stats
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't have any stats yet.
|
||||
</EmptyPlaceholder.Description>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-down rounded-t-none">
|
||||
<DailyPVUVChart data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+15
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user