mirror of
https://github.com/AderKonstantin/aderktech-chronark.com-.git
synced 2025-06-08 05:38:41 +03:00
feat: display page views
This commit is contained in:
parent
f7e8902771
commit
62824dab31
@ -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<Props> = ({ project }) => {
|
||||
const pathname = usePathname();
|
||||
export const Header: React.FC<Props> = ({ project, views }) => {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
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="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 ${
|
||||
@ -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) => (
|
||||
|
@ -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<Props["params"][]> {
|
||||
return allProjects
|
||||
.filter((p) => p.published)
|
||||
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
)!;
|
||||
@ -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"
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
@ -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 });
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user