From 97369c4bdf61dc95e9030f592b041893aff88278 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Sun, 14 May 2023 17:51:10 +0200 Subject: [PATCH] feat: add redis and view counter --- .env | 2 ++ app/api/incr/route.tsx | 36 ++++++++++++++++++++++ app/post/[slug]/content.tsx | 6 ++++ app/post/[slug]/page.tsx | 28 +++++++++++++++-- contentlayer.config.ts | 2 +- package-lock.json | 61 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 7 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 .env create mode 100644 app/api/incr/route.tsx diff --git a/.env b/.env new file mode 100644 index 0000000..d0785c1 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file diff --git a/app/api/incr/route.tsx b/app/api/incr/route.tsx new file mode 100644 index 0000000..c474fbf --- /dev/null +++ b/app/api/incr/route.tsx @@ -0,0 +1,36 @@ +import { Redis } from "@upstash/redis"; +import { NextRequest, NextResponse } from "next/server"; + +const redis = Redis.fromEnv(); + +export const runtime = "edge"; + +export async function GET(request: NextRequest) { + const ip = request.ip; + const searchParams = request.nextUrl.searchParams; + const hasSlug = searchParams.has("slug"); + const slug = hasSlug ? searchParams.get("slug") : null; + + if (ip) { + 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(""); + + const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, { + nx: true, + ex: 24 * 60 * 60, // 1d + }); + + if (!isNew) { + new NextResponse("Already Increased Counter", { status: 200 }); + } + } + + await redis.incr(["pageviews", "posts", slug].join(":")); + return new NextResponse("Increased Counter", { status: 202 }); +} diff --git a/app/post/[slug]/content.tsx b/app/post/[slug]/content.tsx index 92eadd6..7fbbcb9 100644 --- a/app/post/[slug]/content.tsx +++ b/app/post/[slug]/content.tsx @@ -1,11 +1,17 @@ "use client"; +import React from "react"; import { Post } from "@/.contentlayer/generated"; import { components } from "@/lib/mdx"; import { useMDXComponent } from "next-contentlayer/hooks"; export function Content({ post }: { post: Post }) { const MDXContent = useMDXComponent(post.body.code); + + React.useEffect(() => { + fetch(`/api/incr?slug=${post.slug}`); + }, [post.slug]); + return (
diff --git a/app/post/[slug]/page.tsx b/app/post/[slug]/page.tsx index e00ea6b..379a401 100644 --- a/app/post/[slug]/page.tsx +++ b/app/post/[slug]/page.tsx @@ -3,6 +3,9 @@ import { notFound } from "next/navigation"; import { Content } from "./content"; import { Github } from "lucide-react"; import { Link } from "@/components/mdx/link"; +import { Redis } from "@upstash/redis"; + +const redis = Redis.fromEnv(); function formatDate(date: Date) { return new Intl.DateTimeFormat("en-US", { @@ -11,11 +14,23 @@ function formatDate(date: Date) { }).format(date); } -export default function CraftPage({ params }: { params: { slug: string } }) { +function formatNumber(value: number) { + return new Intl.NumberFormat("en-US").format(value); +} + +export default async function CraftPage({ + params, +}: { + params: { slug: string }; +}) { const post = allPosts.find((c) => c.url === `/post/${params.slug}`); if (!post) { notFound(); } + + const views = + (await redis.get(["pageviews", "posts", post.slug].join(":"))) ?? 0; + return (
@@ -37,8 +52,15 @@ export default function CraftPage({ params }: { params: { slug: string } }) {
-
- Back +
+
+ Back +
+
+

+ {formatNumber(views)} views +

+
); diff --git a/contentlayer.config.ts b/contentlayer.config.ts index cd6f9be..66da4b4 100644 --- a/contentlayer.config.ts +++ b/contentlayer.config.ts @@ -36,7 +36,7 @@ export const Post = defineDocumentType(() => ({ computedFields: { slug: { type: "string", - resolve: (post) => `${post._raw.sourceFileName}`, + resolve: (post) => `${post._raw.flattenedPath}`, }, url: { type: "string", diff --git a/package-lock.json b/package-lock.json index dd2a89f..8892ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/node": "20.1.1", "@types/react": "18.2.6", "@types/react-dom": "18.2.4", + "@upstash/redis": "^1.20.6", "@vercel/og": "^0.5.4", "autoprefixer": "10.4.14", "class-variance-authority": "^0.6.0", @@ -2358,6 +2359,14 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@upstash/redis": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.20.6.tgz", + "integrity": "sha512-q1izaYEUsq/WiXNOjf4oOjFZe8fIeBSZN8d5cEyOD4nem+zxc4jccieorQQrNlEahKPE1ZYLzVEkMODRUfch2g==", + "dependencies": { + "isomorphic-fetch": "^3.0.0" + } + }, "node_modules/@vercel/og": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.5.4.tgz", @@ -5405,6 +5414,34 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/isomorphic-fetch/node_modules/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/jiti": { "version": "1.18.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", @@ -8247,6 +8284,11 @@ "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -8698,6 +8740,25 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9ac271f..07cac15 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/node": "20.1.1", "@types/react": "18.2.6", "@types/react-dom": "18.2.4", + "@upstash/redis": "^1.20.6", "@vercel/og": "^0.5.4", "autoprefixer": "10.4.14", "class-variance-authority": "^0.6.0",