From 62824dab31df96e2bd45a184d71be0a3dea5e94a Mon Sep 17 00:00:00 2001 From: Andreas Thomas Date: Thu, 30 Mar 2023 11:46:31 +0200 Subject: [PATCH] feat: display page views --- app/projects/[slug]/header.tsx | 22 +++++++++++--- app/projects/[slug]/page.tsx | 10 ++++++- app/projects/article.tsx | 37 +++++++++++++++-------- app/projects/page.tsx | 55 ++++++++++++++++++++++++---------- pages/api/incr.ts | 26 +++++++++------- 5 files changed, 106 insertions(+), 44 deletions(-) diff --git a/app/projects/[slug]/header.tsx b/app/projects/[slug]/header.tsx index c5f601d..7bb91cb 100644 --- a/app/projects/[slug]/header.tsx +++ b/app/projects/[slug]/header.tsx @@ -1,8 +1,7 @@ "use client"; -import { ArrowLeft, Github, Twitter } from "lucide-react"; +import { ArrowLeft, Eye, Github, Twitter } from "lucide-react"; import Link from "next/link"; import React, { useEffect, useRef, useState } from "react"; -import { usePathname } from "next/navigation"; type Props = { project: { @@ -11,9 +10,10 @@ type Props = { description: string; repository?: string; }; + + views: number; }; -export const Header: React.FC = ({ project }) => { - const pathname = usePathname(); +export const Header: React.FC = ({ project, views }) => { const ref = useRef(null); const [isIntersecting, setIntersecting] = useState(true); @@ -54,6 +54,19 @@ export const Header: React.FC = ({ project }) => { >
+ + {" "} + {Intl.NumberFormat("en-US", { notation: "compact" }).format( + views, + )} + = ({ project }) => { {project.description}

+
{links.map((link) => ( diff --git a/app/projects/[slug]/page.tsx b/app/projects/[slug]/page.tsx index 48c8b4e..ac84624 100644 --- a/app/projects/[slug]/page.tsx +++ b/app/projects/[slug]/page.tsx @@ -4,6 +4,9 @@ import { Mdx } from "@/app/components/mdx"; import { Header } from "./header"; import "./mdx.css"; import { ReportView } from "./view"; +import { Redis } from "@upstash/redis"; + +export const revalidate = 60; type Props = { params: { @@ -11,6 +14,8 @@ type Props = { }; }; +const redis = Redis.fromEnv(); + export async function generateStaticParams(): Promise { return allProjects .filter((p) => p.published) @@ -27,9 +32,12 @@ export default async function PostPage({ params }: Props) { notFound(); } + const views = + (await redis.get(["pageviews", "projects", slug].join(":"))) ?? 0; + return (
-
+
diff --git a/app/projects/article.tsx b/app/projects/article.tsx index 706a237..ae11ce5 100644 --- a/app/projects/article.tsx +++ b/app/projects/article.tsx @@ -1,20 +1,33 @@ import type { Project } from "@/.contentlayer/generated"; import Link from "next/link"; -export const Article = ({ project }: { project: Project }) => { +import { Eye, View } from "lucide-react"; + +type Props = { + project: Project; + views: number; +}; + +export const Article: React.FC = ({ project, views }) => { return (
- - {project.date ? ( - - ) : ( - SOON - )} - +
+ + {project.date ? ( + + ) : ( + SOON + )} + + + {" "} + {Intl.NumberFormat("en-US", { notation: "compact" }).format(views)} + +

{project.title}

diff --git a/app/projects/page.tsx b/app/projects/page.tsx index f855fff..100ee64 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -4,8 +4,22 @@ import { allProjects } from "contentlayer/generated"; import { Navigation } from "../components/nav"; import { Card } from "../components/card"; import { Article } from "./article"; +import { Redis } from "@upstash/redis"; +import { Eye } from "lucide-react"; + +const redis = Redis.fromEnv(); + +export const revalidate = 60; +export default async function ProjectsPage() { + const views = ( + await redis.mget( + ...allProjects.map((p) => ["pageviews", "projects", p.slug].join(":")), + ) + ).reduce((acc, v, i) => { + acc[allProjects[i].slug] = v ?? 0; + return acc; + }, {} as Record); -export default function ProjectsPage() { const featured = allProjects.find( (project) => project.slug === "planetfall", )!; @@ -42,18 +56,27 @@ export default function ProjectsPage() {
-
-
- {featured.date ? ( - - ) : ( - SOON - )} +
+
+
+ {featured.date ? ( + + ) : ( + SOON + )} +
+ + {" "} + {Intl.NumberFormat("en-US", { notation: "compact" }).format( + views[featured.slug] ?? 0, + )} +
+

{[top2, top3].map((project) => ( -
+
))}

@@ -91,7 +114,7 @@ export default function ProjectsPage() { .filter((_, i) => i % 3 === 0) .map((project) => ( -
+
))}
@@ -100,7 +123,7 @@ export default function ProjectsPage() { .filter((_, i) => i % 3 === 1) .map((project) => ( -
+
))}
@@ -109,7 +132,7 @@ export default function ProjectsPage() { .filter((_, i) => i % 3 === 2) .map((project) => ( -
+
))}
diff --git a/pages/api/incr.ts b/pages/api/incr.ts index d427401..73d519f 100644 --- a/pages/api/incr.ts +++ b/pages/api/incr.ts @@ -19,21 +19,25 @@ export default async function incr(req: NextRequest): Promise { if ("slug" in body) { slug = body.slug; } - if (!slug) { return new NextResponse("Slug not found", { status: 400 }); } - const identifier = req.ip; - if (identifier) { - // deduplicate the ip for each slug - const isNew = await redis.set( - ["deduplicate", identifier, slug].join(":"), - true, - { - nx: true, - ex: 24 * 60 * 60, - }, + const ip = req.ip; + if (ip) { + // Hash the IP in order to not store it directly in your db. + const buf = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(ip), ); + const hash = Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // deduplicate the ip for each slug + const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, { + nx: true, + ex: 24 * 60 * 60, + }); if (!isNew) { new NextResponse(null, { status: 202 }); }