feat: analytics

This commit is contained in:
Andreas Thomas 2023-03-26 13:26:13 +02:00
parent d13531c222
commit c3676d2b8d
No known key found for this signature in database
14 changed files with 240 additions and 256 deletions

View File

@ -5,93 +5,97 @@ import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug"; import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeAutolinkHeadings from "rehype-autolink-headings";
var computedFields = { var computedFields = {
path: { path: {
type: "string", type: "string",
resolve: (doc) => `/${doc._raw.flattenedPath}`, resolve: (doc) => `/${doc._raw.flattenedPath}`
}, },
slug: { slug: {
type: "string", type: "string",
resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"), resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/")
}, }
}; };
var Project = defineDocumentType(() => ({ var Project = defineDocumentType(() => ({
name: "Project", name: "Project",
filePathPattern: "./projects/**/*.mdx", filePathPattern: "./projects/**/*.mdx",
contentType: "mdx", contentType: "mdx",
fields: { fields: {
published: { published: {
type: "boolean", type: "boolean"
}, },
title: { title: {
type: "string", type: "string",
required: true, required: true
}, },
description: { description: {
type: "string", type: "string",
required: true, required: true
}, },
date: { date: {
type: "date", type: "date"
}, },
url: { url: {
type: "string", type: "string"
}, },
repository: { repository: {
type: "string", type: "string"
}, }
}, },
computedFields, computedFields
})); }));
var Page = defineDocumentType(() => ({ var Page = defineDocumentType(() => ({
name: "Page", name: "Page",
filePathPattern: "pages/**/*.mdx", filePathPattern: "pages/**/*.mdx",
contentType: "mdx", contentType: "mdx",
fields: { fields: {
title: { title: {
type: "string", type: "string",
required: true, required: true
}, },
description: { description: {
type: "string", type: "string"
}, }
}, },
computedFields, computedFields
})); }));
var contentlayer_config_default = makeSource({ var contentlayer_config_default = makeSource({
contentDirPath: "./content", contentDirPath: "./content",
documentTypes: [Page, Project], documentTypes: [Page, Project],
mdx: { mdx: {
remarkPlugins: [remarkGfm], remarkPlugins: [remarkGfm],
rehypePlugins: [ rehypePlugins: [
rehypeSlug, rehypeSlug,
[ [
rehypePrettyCode, rehypePrettyCode,
{ {
theme: "github-dark", theme: "github-dark",
onVisitLine(node) { onVisitLine(node) {
if (node.children.length === 0) { if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }]; node.children = [{ type: "text", value: " " }];
} }
}, },
onVisitHighlightedLine(node) { onVisitHighlightedLine(node) {
node.properties.className.push("line--highlighted"); node.properties.className.push("line--highlighted");
}, },
onVisitHighlightedWord(node) { onVisitHighlightedWord(node) {
node.properties.className = ["word--highlighted"]; node.properties.className = ["word--highlighted"];
}, }
}, }
], ],
[ [
rehypeAutolinkHeadings, rehypeAutolinkHeadings,
{ {
properties: { properties: {
className: ["subheading-anchor"], className: ["subheading-anchor"],
ariaLabel: "Link to section", ariaLabel: "Link to section"
}, }
}, }
], ]
], ]
}, }
}); });
export { Page, Project, contentlayer_config_default as default }; export {
Page,
Project,
contentlayer_config_default as default
};
//# sourceMappingURL=compiled-contentlayer-config-AAEZAM7W.mjs.map //# sourceMappingURL=compiled-contentlayer-config-AAEZAM7W.mjs.map

View File

@ -1,3 +1,5 @@
// NOTE This file is auto-generated by Contentlayer // NOTE This file is auto-generated by Contentlayer
export const allPages = [];
export const allPages = []

View File

@ -1,65 +1,19 @@
// NOTE This file is auto-generated by Contentlayer // NOTE This file is auto-generated by Contentlayer
import projects__accessMdx from "./projects__access.mdx.json" assert { import projects__accessMdx from './projects__access.mdx.json' assert { type: 'json' }
type: "json", import projects__envshareMdx from './projects__envshare.mdx.json' assert { type: 'json' }
}; import projects__planetfallMdx from './projects__planetfall.mdx.json' assert { type: 'json' }
import projects__envshareMdx from "./projects__envshare.mdx.json" assert { import projects__qstashMdx from './projects__qstash.mdx.json' assert { type: 'json' }
type: "json", import projects__terraformProviderVercelMdx from './projects__terraform-provider-vercel.mdx.json' assert { type: 'json' }
}; import projects__upstashAuthAnalyticsMdx from './projects__upstash-auth-analytics.mdx.json' assert { type: 'json' }
import projects__planetfallMdx from "./projects__planetfall.mdx.json" assert { import projects__upstashCliMdx from './projects__upstash-cli.mdx.json' assert { type: 'json' }
type: "json", import projects__upstashCoreAnalyticsMdx from './projects__upstash-core-analytics.mdx.json' assert { type: 'json' }
}; import projects__upstashEdgeFlagsMdx from './projects__upstash-edge-flags.mdx.json' assert { type: 'json' }
import projects__qstashMdx from "./projects__qstash.mdx.json" assert { import projects__upstashKafkaMdx from './projects__upstash-kafka.mdx.json' assert { type: 'json' }
type: "json", import projects__upstashQstashSdkMdx from './projects__upstash-qstash-sdk.mdx.json' assert { type: 'json' }
}; import projects__upstashRatelimitMdx from './projects__upstash-ratelimit.mdx.json' assert { type: 'json' }
import projects__terraformProviderVercelMdx from "./projects__terraform-provider-vercel.mdx.json" assert { import projects__upstashReactUiMdx from './projects__upstash-react-ui.mdx.json' assert { type: 'json' }
type: "json", import projects__upstashRedisMdx from './projects__upstash-redis.mdx.json' assert { type: 'json' }
}; import projects__upstashWebAnalyticsMdx from './projects__upstash-web-analytics.mdx.json' assert { type: 'json' }
import projects__upstashAuthAnalyticsMdx from "./projects__upstash-auth-analytics.mdx.json" assert {
type: "json",
};
import projects__upstashCliMdx from "./projects__upstash-cli.mdx.json" assert {
type: "json",
};
import projects__upstashCoreAnalyticsMdx from "./projects__upstash-core-analytics.mdx.json" assert {
type: "json",
};
import projects__upstashEdgeFlagsMdx from "./projects__upstash-edge-flags.mdx.json" assert {
type: "json",
};
import projects__upstashKafkaMdx from "./projects__upstash-kafka.mdx.json" assert {
type: "json",
};
import projects__upstashQstashSdkMdx from "./projects__upstash-qstash-sdk.mdx.json" assert {
type: "json",
};
import projects__upstashRatelimitMdx from "./projects__upstash-ratelimit.mdx.json" assert {
type: "json",
};
import projects__upstashReactUiMdx from "./projects__upstash-react-ui.mdx.json" assert {
type: "json",
};
import projects__upstashRedisMdx from "./projects__upstash-redis.mdx.json" assert {
type: "json",
};
import projects__upstashWebAnalyticsMdx from "./projects__upstash-web-analytics.mdx.json" assert {
type: "json",
};
export const allProjects = [ export const allProjects = [projects__accessMdx, projects__envshareMdx, projects__planetfallMdx, projects__qstashMdx, projects__terraformProviderVercelMdx, projects__upstashAuthAnalyticsMdx, projects__upstashCliMdx, projects__upstashCoreAnalyticsMdx, projects__upstashEdgeFlagsMdx, projects__upstashKafkaMdx, projects__upstashQstashSdkMdx, projects__upstashRatelimitMdx, projects__upstashReactUiMdx, projects__upstashRedisMdx, projects__upstashWebAnalyticsMdx]
projects__accessMdx,
projects__envshareMdx,
projects__planetfallMdx,
projects__qstashMdx,
projects__terraformProviderVercelMdx,
projects__upstashAuthAnalyticsMdx,
projects__upstashCliMdx,
projects__upstashCoreAnalyticsMdx,
projects__upstashEdgeFlagsMdx,
projects__upstashKafkaMdx,
projects__upstashQstashSdkMdx,
projects__upstashRatelimitMdx,
projects__upstashReactUiMdx,
projects__upstashRedisMdx,
projects__upstashWebAnalyticsMdx,
];

View File

@ -1,10 +1,11 @@
// NOTE This file is auto-generated by Contentlayer // NOTE This file is auto-generated by Contentlayer
import { Page, Project, DocumentTypes } from "./types"; import { Page, Project, DocumentTypes } from './types'
export * from "./types"; export * from './types'
export declare const allPages: Page[]; export declare const allPages: Page[]
export declare const allProjects: Project[]; export declare const allProjects: Project[]
export declare const allDocuments: DocumentTypes[]
export declare const allDocuments: DocumentTypes[];

View File

@ -1,12 +1,12 @@
// NOTE This file is auto-generated by Contentlayer // NOTE This file is auto-generated by Contentlayer
export { isType } from "contentlayer/client"; export { isType } from 'contentlayer/client'
// NOTE During development Contentlayer imports from `.mjs` files to improve HMR speeds. // NOTE During development Contentlayer imports from `.mjs` files to improve HMR speeds.
// During (production) builds Contentlayer it imports from `.json` files to improve build performance. // During (production) builds Contentlayer it imports from `.json` files to improve build performance.
import allPages from "./Page/_index.json" assert { type: "json" }; import { allPages } from './Page/_index.mjs'
import allProjects from "./Project/_index.json" assert { type: "json" }; import { allProjects } from './Project/_index.mjs'
export { allPages, allProjects }; export { allPages, allProjects }
export const allDocuments = [...allPages, ...allProjects]; export const allDocuments = [...allPages, ...allProjects]

View File

@ -1,78 +1,79 @@
// NOTE This file is auto-generated by Contentlayer // NOTE This file is auto-generated by Contentlayer
import type { import type { Markdown, MDX, ImageFieldData, IsoDateTimeString } from 'contentlayer/core'
Markdown, import * as Local from 'contentlayer/source-files'
MDX,
ImageFieldData,
IsoDateTimeString,
} from "contentlayer/core";
import * as Local from "contentlayer/source-files";
export { isType } from "contentlayer/client"; export { isType } from 'contentlayer/client'
export type { Markdown, MDX, ImageFieldData, IsoDateTimeString }; export type { Markdown, MDX, ImageFieldData, IsoDateTimeString }
/** Document types */ /** Document types */
export type Page = { export type Page = {
/** File path relative to `contentDirPath` */ /** File path relative to `contentDirPath` */
_id: string; _id: string
_raw: Local.RawDocumentData; _raw: Local.RawDocumentData
type: "Page"; type: 'Page'
title: string; title: string
description?: string | undefined; description?: string | undefined
/** MDX file body */ /** MDX file body */
body: MDX; body: MDX
path: string; path: string
slug: string; slug: string
}; }
export type Project = { export type Project = {
/** File path relative to `contentDirPath` */ /** File path relative to `contentDirPath` */
_id: string; _id: string
_raw: Local.RawDocumentData; _raw: Local.RawDocumentData
type: "Project"; type: 'Project'
published?: boolean | undefined; published?: boolean | undefined
title: string; title: string
description: string; description: string
date?: IsoDateTimeString | undefined; date?: IsoDateTimeString | undefined
url?: string | undefined; url?: string | undefined
repository?: string | undefined; repository?: string | undefined
/** MDX file body */ /** MDX file body */
body: MDX; body: MDX
path: string; path: string
slug: string; slug: string
}; }
/** Nested types */ /** Nested types */
/** Helper types */ /** Helper types */
export type AllTypes = DocumentTypes | NestedTypes; export type AllTypes = DocumentTypes | NestedTypes
export type AllTypeNames = DocumentTypeNames | NestedTypeNames; export type AllTypeNames = DocumentTypeNames | NestedTypeNames
export type DocumentTypes = Page | Project; export type DocumentTypes = Page | Project
export type DocumentTypeNames = "Page" | "Project"; export type DocumentTypeNames = 'Page' | 'Project'
export type NestedTypes = never
export type NestedTypeNames = never
export type NestedTypes = never;
export type NestedTypeNames = never;
export interface ContentlayerGenTypes { export interface ContentlayerGenTypes {
documentTypes: DocumentTypes; documentTypes: DocumentTypes
documentTypeMap: DocumentTypeMap; documentTypeMap: DocumentTypeMap
documentTypeNames: DocumentTypeNames; documentTypeNames: DocumentTypeNames
nestedTypes: NestedTypes; nestedTypes: NestedTypes
nestedTypeMap: NestedTypeMap; nestedTypeMap: NestedTypeMap
nestedTypeNames: NestedTypeNames; nestedTypeNames: NestedTypeNames
allTypeNames: AllTypeNames; allTypeNames: AllTypeNames
} }
declare global { declare global {
interface ContentlayerGen extends ContentlayerGenTypes {} interface ContentlayerGen extends ContentlayerGenTypes {}
} }
export type DocumentTypeMap = { export type DocumentTypeMap = {
Page: Page; Page: Page
Project: Project; Project: Project
}; }
export type NestedTypeMap = {}; export type NestedTypeMap = {
}

View File

@ -9,8 +9,8 @@ import {
import { MouseEventHandler, PropsWithChildren } from "react"; import { MouseEventHandler, PropsWithChildren } from "react";
export const Card: React.FC<PropsWithChildren> = ({ children }) => { export const Card: React.FC<PropsWithChildren> = ({ children }) => {
const mouseX = useSpring(0, { stiffness: 200, damping: 100 }); const mouseX = useSpring(0, { stiffness: 500, damping: 100 });
const mouseY = useSpring(0, { stiffness: 200, damping: 100 }); const mouseY = useSpring(0, { stiffness: 500, damping: 100 });
function onMouseMove({ currentTarget, clientX, clientY }: any) { function onMouseMove({ currentTarget, clientX, clientY }: any) {
const { left, top } = currentTarget.getBoundingClientRect(); const { left, top } = currentTarget.getBoundingClientRect();
@ -23,7 +23,7 @@ export const Card: React.FC<PropsWithChildren> = ({ children }) => {
return ( return (
<div <div
onMouseMove={onMouseMove} onMouseMove={onMouseMove}
className="overflow-hidden relative duration-700 border rounded-xl hover:bg-zinc-800/30 group md:gap-8 hover:border-zinc-400/50 border-zinc-600 " className="overflow-hidden relative duration-700 border rounded-xl hover:bg-zinc-800/10 group md:gap-8 hover:border-zinc-400/50 border-zinc-600 "
> >
<div className="pointer-events-none"> <div className="pointer-events-none">
<div className="absolute inset-0 z-0 transition duration-1000 [mask-image:linear-gradient(black,transparent)]" /> <div className="absolute inset-0 z-0 transition duration-1000 [mask-image:linear-gradient(black,transparent)]" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -7,10 +7,11 @@ export const metadata: Metadata = {
default: "chronark.com", default: "chronark.com",
template: "%s | chronark.com", template: "%s | chronark.com",
}, },
description: "software engineer at Upstash and founder of planetfall.io", description: "Software engineer at upstash.com and founder of planetfall.io",
openGraph: { openGraph: {
title: "chronark.com", title: "chronark.com",
description: "software engineer at Upstash and founder of planetfall.io", description:
"Software engineer at upstash.com and founder of planetfall.io",
url: "https://chronark.com", url: "https://chronark.com",
siteName: "chronark.com", siteName: "chronark.com",
images: [ images: [

View File

@ -3,6 +3,7 @@ import { allProjects } from "contentlayer/generated";
import { Mdx } from "@/app/components/mdx"; import { Mdx } from "@/app/components/mdx";
import { Header } from "./header"; import { Header } from "./header";
import "./mdx.css"; import "./mdx.css";
import { ReportView } from "./view";
type Props = { type Props = {
params: { params: {
@ -29,6 +30,7 @@ export default async function PostPage({ params }: Props) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header project={project} /> <Header project={project} />
<ReportView slug={project.slug} />
<main className="bg-zinc-50"> <main className="bg-zinc-50">
<article className="px-4 py-12 mx-auto prose prose-zinc"> <article className="px-4 py-12 mx-auto prose prose-zinc">

View File

@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
useEffect(() => {
fetch("/api/incr", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ slug }),
});
}, [slug]);
return null;
};

View File

@ -75,7 +75,7 @@ export default function ProjectsPage() {
</Link> </Link>
</Card> </Card>
<div className="flex flex-col w-full max-w-2xl gap-8 pt-12 mx-auto border-t border-gray-900/10 sm:pt-16 lg:mx-0 lg:max-w-none lg:border-t-0 lg:pt-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} />
@ -85,7 +85,7 @@ export default function ProjectsPage() {
</div> </div>
<div className="hidden w-full h-px md:block bg-zinc-800" /> <div className="hidden w-full h-px md:block bg-zinc-800" />
<div className="grid max-w-2xl grid-cols-1 gap-4 mx-auto lg:mx-0 lg:max-w-none md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 mx-auto lg:mx-0 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{sorted {sorted
.filter((_, i) => i % 3 === 0) .filter((_, i) => i % 3 === 0)

View File

@ -1,41 +0,0 @@
import { Redis } from "@upstash/redis";
import { NextRequest, NextResponse, NextFetchEvent } from "next/server";
const redis = Redis.fromEnv();
export const config = {
runtime: "experimental-edge",
matcher: "/projects/:slug*",
};
export default async function middleware(
req: NextRequest,
evt: NextFetchEvent,
): Promise<NextResponse> {
const path = new URL(req.url).pathname;
console.log({ path });
evt.waitUntil(incrementPageView(req.ip, path));
return NextResponse.next();
}
async function incrementPageView(
identifier: string | undefined,
pathname: string,
): Promise<void> {
if (identifier) {
// deduplicate the ip for each slug
const isNew = await redis.set(
["deduplicate", identifier, pathname].join(":"),
true,
{
nx: true,
ex: 24 * 60 * 60,
},
);
if (!isNew) {
return;
}
}
await redis.incr(["pageviews", pathname].join(":"));
}

43
pages/api/incr.ts Normal file
View File

@ -0,0 +1,43 @@
import { Redis } from "@upstash/redis";
import { NextRequest, NextResponse } from "next/server";
const redis = Redis.fromEnv();
export const config = {
runtime: "edge",
};
export default async function incr(req: NextRequest): Promise<NextResponse> {
if (req.method !== "POST") {
return new NextResponse("use POST", { status: 405 });
}
if (req.headers.get("Content-Type") !== "application/json") {
return new NextResponse("must be json", { status: 400 });
}
const body = await req.json();
let slug: string | undefined = undefined;
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,
},
);
if (!isNew) {
new NextResponse(null, { status: 202 });
}
}
await redis.incr(["pageviews", slug].join(":"));
return new NextResponse(null, { status: 202 });
}