Skip to content

Commit

Permalink
feat: display page views
Browse files Browse the repository at this point in the history
  • Loading branch information
chronark committed Mar 30, 2023
1 parent f7e8902 commit 62824da
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 44 deletions.
22 changes: 18 additions & 4 deletions app/projects/[slug]/header.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -11,9 +10,10 @@ type Props = {
description: string;
repository?: string;
};

views: number;
};
export const Header: React.FC<Props> = ({ project }) => {
const pathname = usePathname();
export const Header: React.FC<Props> = ({ project, views }) => {
const ref = useRef<HTMLElement>(null);
const [isIntersecting, setIntersecting] = useState(true);

Expand Down Expand Up @@ -54,6 +54,19 @@ export const Header: React.FC<Props> = ({ project }) => {
>
<div className="container flex flex-row-reverse items-center justify-between p-6 mx-auto">
<div className="flex justify-between gap-8">
<span
title="View counter for this page"
className={`duration-200 hover:font-medium flex items-center gap-1 ${
isIntersecting
? " text-zinc-400 hover:text-zinc-100"
: "text-zinc-600 hover:text-zinc-900"
} `}
>
<Eye className="w-5 h-5" />{" "}
{Intl.NumberFormat("en-US", { notation: "compact" }).format(
views,
)}
</span>
<Link target="_blank" href="https://twitter/chronark_">
<Twitter
className={`w-6 h-6 duration-200 hover:font-medium ${
Expand Down Expand Up @@ -96,6 +109,7 @@ export const Header: React.FC<Props> = ({ project }) => {
{project.description}
</p>
</div>

<div className="mx-auto mt-10 max-w-2xl lg:mx-0 lg:max-w-none">
<div className="grid grid-cols-1 gap-y-6 gap-x-8 text-base font-semibold leading-7 text-white sm:grid-cols-2 md:flex lg:gap-x-10">
{links.map((link) => (
Expand Down
10 changes: 9 additions & 1 deletion app/projects/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ 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: {
slug: string;
};
};

const redis = Redis.fromEnv();

export async function generateStaticParams(): Promise<Props["params"][]> {
return allProjects
.filter((p) => p.published)
Expand All @@ -27,9 +32,12 @@ export default async function PostPage({ params }: Props) {
notFound();
}

const views =
(await redis.get<number>(["pageviews", "projects", slug].join(":"))) ?? 0;

return (
<div className="bg-zinc-50 min-h-screen">
<Header project={project} />
<Header project={project} views={views} />
<ReportView slug={project.slug} />

<article className="px-4 py-12 mx-auto prose prose-zinc prose-quoteless">
Expand Down
37 changes: 25 additions & 12 deletions app/projects/article.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ project, views }) => {
return (
<Link href={`/projects/${project.slug}`}>
<article className="p-4 md:p-8">
<span className="text-xs duration-1000 text-zinc-200 group-hover:text-white group-hover:border-zinc-200 drop-shadow-orange">
{project.date ? (
<time dateTime={new Date(project.date).toISOString()}>
{Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
new Date(project.date),
)}
</time>
) : (
<span>SOON</span>
)}
</span>
<div className="flex justify-between gap-2 items-center">
<span className="text-xs duration-1000 text-zinc-200 group-hover:text-white group-hover:border-zinc-200 drop-shadow-orange">
{project.date ? (
<time dateTime={new Date(project.date).toISOString()}>
{Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
new Date(project.date),
)}
</time>
) : (
<span>SOON</span>
)}
</span>
<span className="text-zinc-500 text-xs flex items-center gap-1">
<Eye className="w-4 h-4" />{" "}
{Intl.NumberFormat("en-US", { notation: "compact" }).format(views)}
</span>
</div>
<h2 className="z-20 text-xl font-medium duration-1000 lg:text-3xl text-zinc-200 group-hover:text-white font-display">
{project.title}
</h2>
Expand Down
55 changes: 39 additions & 16 deletions app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>(
...allProjects.map((p) => ["pageviews", "projects", p.slug].join(":")),
)
).reduce((acc, v, i) => {
acc[allProjects[i].slug] = v ?? 0;
return acc;
}, {} as Record<string, number>);

export default function ProjectsPage() {
const featured = allProjects.find(
(project) => project.slug === "planetfall",
)!;
Expand Down Expand Up @@ -42,18 +56,27 @@ export default function ProjectsPage() {
<div className="grid grid-cols-1 gap-8 mx-auto lg:grid-cols-2 ">
<Card>
<Link href={`/projects/${featured.slug}`}>
<article className="relative h-full w-full max-w-2xl mx-auto lg:mx-0 lg:max-w-lg p-4 md:p-8">
<div className="text-xs text-zinc-100">
{featured.date ? (
<time dateTime={new Date(featured.date).toISOString()}>
{Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
}).format(new Date(featured.date))}
</time>
) : (
<span>SOON</span>
)}
<article className="relative h-full w-full p-4 md:p-8">
<div className="flex justify-between gap-2 items-center">
<div className="text-xs text-zinc-100">
{featured.date ? (
<time dateTime={new Date(featured.date).toISOString()}>
{Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
}).format(new Date(featured.date))}
</time>
) : (
<span>SOON</span>
)}
</div>
<span className="text-zinc-500 text-xs flex items-center gap-1">
<Eye className="w-4 h-4" />{" "}
{Intl.NumberFormat("en-US", { notation: "compact" }).format(
views[featured.slug] ?? 0,
)}
</span>
</div>

<h2
id="featured-post"
className="mt-4 text-3xl font-bold text-zinc-100 group-hover:text-white sm:text-4xl font-display"
Expand All @@ -78,7 +101,7 @@ export default function ProjectsPage() {
<div className="flex flex-col w-full gap-8 mx-auto border-t border-gray-900/10 lg:mx-0 lg:border-t-0 ">
{[top2, top3].map((project) => (
<Card key={project.slug}>
<Article project={project} />
<Article project={project} views={views[project.slug] ?? 0} />
</Card>
))}
</div>
Expand All @@ -91,7 +114,7 @@ export default function ProjectsPage() {
.filter((_, i) => i % 3 === 0)
.map((project) => (
<Card key={project.slug}>
<Article project={project} />
<Article project={project} views={views[project.slug] ?? 0} />
</Card>
))}
</div>
Expand All @@ -100,7 +123,7 @@ export default function ProjectsPage() {
.filter((_, i) => i % 3 === 1)
.map((project) => (
<Card key={project.slug}>
<Article project={project} />
<Article project={project} views={views[project.slug] ?? 0} />
</Card>
))}
</div>
Expand All @@ -109,7 +132,7 @@ export default function ProjectsPage() {
.filter((_, i) => i % 3 === 2)
.map((project) => (
<Card key={project.slug}>
<Article project={project} />
<Article project={project} views={views[project.slug] ?? 0} />
</Card>
))}
</div>
Expand Down
26 changes: 15 additions & 11 deletions pages/api/incr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,25 @@ export default async function incr(req: NextRequest): Promise<NextResponse> {
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 });
}
Expand Down

0 comments on commit 62824da

Please sign in to comment.