mirror of
https://github.com/AderKonstantin/aderktech-chronark.com-.git
synced 2025-06-08 13:48:42 +03:00
feat: display page views
This commit is contained in:
parent
f7e8902771
commit
62824dab31
@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ArrowLeft, Github, Twitter } from "lucide-react";
|
import { ArrowLeft, Eye, Github, Twitter } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
project: {
|
project: {
|
||||||
@ -11,9 +10,10 @@ type Props = {
|
|||||||
description: string;
|
description: string;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
views: number;
|
||||||
};
|
};
|
||||||
export const Header: React.FC<Props> = ({ project }) => {
|
export const Header: React.FC<Props> = ({ project, views }) => {
|
||||||
const pathname = usePathname();
|
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
const [isIntersecting, setIntersecting] = useState(true);
|
const [isIntersecting, setIntersecting] = useState(true);
|
||||||
|
|
||||||
@ -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="container flex flex-row-reverse items-center justify-between p-6 mx-auto">
|
||||||
<div className="flex justify-between gap-8">
|
<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_">
|
<Link target="_blank" href="https://twitter/chronark_">
|
||||||
<Twitter
|
<Twitter
|
||||||
className={`w-6 h-6 duration-200 hover:font-medium ${
|
className={`w-6 h-6 duration-200 hover:font-medium ${
|
||||||
@ -96,6 +109,7 @@ export const Header: React.FC<Props> = ({ project }) => {
|
|||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-10 max-w-2xl lg:mx-0 lg:max-w-none">
|
<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">
|
<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) => (
|
{links.map((link) => (
|
||||||
|
@ -4,6 +4,9 @@ import { Mdx } from "@/app/components/mdx";
|
|||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
import "./mdx.css";
|
import "./mdx.css";
|
||||||
import { ReportView } from "./view";
|
import { ReportView } from "./view";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: {
|
params: {
|
||||||
@ -11,6 +14,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const redis = Redis.fromEnv();
|
||||||
|
|
||||||
export async function generateStaticParams(): Promise<Props["params"][]> {
|
export async function generateStaticParams(): Promise<Props["params"][]> {
|
||||||
return allProjects
|
return allProjects
|
||||||
.filter((p) => p.published)
|
.filter((p) => p.published)
|
||||||
@ -27,9 +32,12 @@ export default async function PostPage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const views =
|
||||||
|
(await redis.get<number>(["pageviews", "projects", slug].join(":"))) ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-50 min-h-screen">
|
<div className="bg-zinc-50 min-h-screen">
|
||||||
<Header project={project} />
|
<Header project={project} views={views} />
|
||||||
<ReportView slug={project.slug} />
|
<ReportView slug={project.slug} />
|
||||||
|
|
||||||
<article className="px-4 py-12 mx-auto prose prose-zinc prose-quoteless">
|
<article className="px-4 py-12 mx-auto prose prose-zinc prose-quoteless">
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
import type { Project } from "@/.contentlayer/generated";
|
import type { Project } from "@/.contentlayer/generated";
|
||||||
import Link from "next/link";
|
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 (
|
return (
|
||||||
<Link href={`/projects/${project.slug}`}>
|
<Link href={`/projects/${project.slug}`}>
|
||||||
<article className="p-4 md:p-8">
|
<article className="p-4 md:p-8">
|
||||||
|
<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">
|
<span className="text-xs duration-1000 text-zinc-200 group-hover:text-white group-hover:border-zinc-200 drop-shadow-orange">
|
||||||
{project.date ? (
|
{project.date ? (
|
||||||
<time dateTime={new Date(project.date).toISOString()}>
|
<time dateTime={new Date(project.date).toISOString()}>
|
||||||
@ -15,6 +23,11 @@ export const Article = ({ project }: { project: Project }) => {
|
|||||||
<span>SOON</span>
|
<span>SOON</span>
|
||||||
)}
|
)}
|
||||||
</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">
|
<h2 className="z-20 text-xl font-medium duration-1000 lg:text-3xl text-zinc-200 group-hover:text-white font-display">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -4,8 +4,22 @@ import { allProjects } from "contentlayer/generated";
|
|||||||
import { Navigation } from "../components/nav";
|
import { Navigation } from "../components/nav";
|
||||||
import { Card } from "../components/card";
|
import { Card } from "../components/card";
|
||||||
import { Article } from "./article";
|
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(
|
const featured = allProjects.find(
|
||||||
(project) => project.slug === "planetfall",
|
(project) => project.slug === "planetfall",
|
||||||
)!;
|
)!;
|
||||||
@ -42,7 +56,8 @@ export default function ProjectsPage() {
|
|||||||
<div className="grid grid-cols-1 gap-8 mx-auto lg:grid-cols-2 ">
|
<div className="grid grid-cols-1 gap-8 mx-auto lg:grid-cols-2 ">
|
||||||
<Card>
|
<Card>
|
||||||
<Link href={`/projects/${featured.slug}`}>
|
<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">
|
<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">
|
<div className="text-xs text-zinc-100">
|
||||||
{featured.date ? (
|
{featured.date ? (
|
||||||
<time dateTime={new Date(featured.date).toISOString()}>
|
<time dateTime={new Date(featured.date).toISOString()}>
|
||||||
@ -54,6 +69,14 @@ export default function ProjectsPage() {
|
|||||||
<span>SOON</span>
|
<span>SOON</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<h2
|
||||||
id="featured-post"
|
id="featured-post"
|
||||||
className="mt-4 text-3xl font-bold text-zinc-100 group-hover:text-white sm:text-4xl font-display"
|
className="mt-4 text-3xl font-bold text-zinc-100 group-hover:text-white sm:text-4xl font-display"
|
||||||
@ -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 ">
|
<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) => (
|
{[top2, top3].map((project) => (
|
||||||
<Card key={project.slug}>
|
<Card key={project.slug}>
|
||||||
<Article project={project} />
|
<Article project={project} views={views[project.slug] ?? 0} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -91,7 +114,7 @@ export default function ProjectsPage() {
|
|||||||
.filter((_, i) => i % 3 === 0)
|
.filter((_, i) => i % 3 === 0)
|
||||||
.map((project) => (
|
.map((project) => (
|
||||||
<Card key={project.slug}>
|
<Card key={project.slug}>
|
||||||
<Article project={project} />
|
<Article project={project} views={views[project.slug] ?? 0} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -100,7 +123,7 @@ export default function ProjectsPage() {
|
|||||||
.filter((_, i) => i % 3 === 1)
|
.filter((_, i) => i % 3 === 1)
|
||||||
.map((project) => (
|
.map((project) => (
|
||||||
<Card key={project.slug}>
|
<Card key={project.slug}>
|
||||||
<Article project={project} />
|
<Article project={project} views={views[project.slug] ?? 0} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +132,7 @@ export default function ProjectsPage() {
|
|||||||
.filter((_, i) => i % 3 === 2)
|
.filter((_, i) => i % 3 === 2)
|
||||||
.map((project) => (
|
.map((project) => (
|
||||||
<Card key={project.slug}>
|
<Card key={project.slug}>
|
||||||
<Article project={project} />
|
<Article project={project} views={views[project.slug] ?? 0} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,21 +19,25 @@ export default async function incr(req: NextRequest): Promise<NextResponse> {
|
|||||||
if ("slug" in body) {
|
if ("slug" in body) {
|
||||||
slug = body.slug;
|
slug = body.slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return new NextResponse("Slug not found", { status: 400 });
|
return new NextResponse("Slug not found", { status: 400 });
|
||||||
}
|
}
|
||||||
const identifier = req.ip;
|
const ip = req.ip;
|
||||||
if (identifier) {
|
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
|
// deduplicate the ip for each slug
|
||||||
const isNew = await redis.set(
|
const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
|
||||||
["deduplicate", identifier, slug].join(":"),
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
nx: true,
|
nx: true,
|
||||||
ex: 24 * 60 * 60,
|
ex: 24 * 60 * 60,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
new NextResponse(null, { status: 202 });
|
new NextResponse(null, { status: 202 });
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user