Added real-time page views

This commit is contained in:
r-freeman 2023-04-10 01:08:52 +01:00
parent 1fabd19a53
commit 51737338b8
13 changed files with 728 additions and 681 deletions

View File

@ -18,8 +18,6 @@ type ArticleLayout = {
slug: string slug: string
} }
const isProd = process.env.NODE_ENV === 'production'
export function ArticleLayout({ export function ArticleLayout({
children, children,
isRssFeed = false, isRssFeed = false,
@ -109,7 +107,7 @@ export function ArticleLayout({
<time dateTime={date}> <time dateTime={date}>
<span>{formatDate(date)}</span> <span>{formatDate(date)}</span>
</time> </time>
<Views slug={slug} shouldUpdateViews={isProd}/> <Views slug={slug} shouldUpdateViews={true}/>
</p> </p>
</header> </header>
<Prose className="mt-8">{children}</Prose> <Prose className="mt-8">{children}</Prose>

View File

@ -1,11 +1,7 @@
import useSWR from 'swr' import {ElementType, useEffect, useState} from 'react'
import {ElementType, useEffect} from 'react'
import fetcher from '@/lib/fetcher' import fetcher from '@/lib/fetcher'
import {numberFormat} from '@/lib/numberFormat' import {numberFormat} from '@/lib/numberFormat'
import {supabase} from '@/lib/supabase'
type ViewsResponse = {
views: string
}
type ViewsProps = { type ViewsProps = {
as?: ElementType as?: ElementType
@ -14,20 +10,52 @@ type ViewsProps = {
shouldUpdateViews?: boolean shouldUpdateViews?: boolean
} }
const updateViews = (slug: string) => fetcher(`/api/views/${slug}`, {method: 'POST'}) const NEXT_PUBLIC_SITE_URL = process.env.NEXT_PUBLIC_SITE_URL
export function Views({as: Component = 'span', slug, className, shouldUpdateViews = true}: ViewsProps) { export function Views({as: Component = 'span', slug, className, shouldUpdateViews = false}: ViewsProps) {
const {data} = useSWR<ViewsResponse>(`/api/views/${slug}`, fetcher, { const [views, setViews] = useState(0)
revalidateOnFocus: false,
revalidateOnMount: true useEffect(() => {
}) const sub = supabase
const views = Number(data?.views) .channel('any')
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'analytics',
filter: `slug=eq.${slug}`
}, payload => {
setViews(payload.new.views)
})
.subscribe();
return () => {
sub.unsubscribe()
}
}, [])
useEffect(() => {
const getViews = async () => {
const {views} = await fetcher(`${NEXT_PUBLIC_SITE_URL}/api/views/${slug}`)
setViews(views)
};
getViews()
}, [])
useEffect(() => { useEffect(() => {
if (shouldUpdateViews) { if (shouldUpdateViews) {
updateViews(slug).then(r => r); const registerView = async () => {
const {views} = await fetcher(`${NEXT_PUBLIC_SITE_URL}/api/views/${slug}`,
{
method: 'POST'
}
)
};
registerView()
} }
}, [slug, shouldUpdateViews]) }, [])
return ( return (
<Component className={className}> <Component className={className}>

View File

@ -7,7 +7,6 @@ import {
} from '@/lib/github' } from '@/lib/github'
import {getAllArticles} from '@/lib/getAllArticles' import {getAllArticles} from '@/lib/getAllArticles'
import {getTopArtist, getTopGenre} from '@/lib/spotify' import {getTopArtist, getTopGenre} from '@/lib/spotify'
import {getViews} from '@/lib/views'
import {getStats} from '@/lib/statsfm' import {getStats} from '@/lib/statsfm'
import {Metric} from '@/types' import {Metric} from '@/types'
@ -21,7 +20,7 @@ export async function getDashboardData() {
const totalStars = await getTotalStars(totalRepos) const totalStars = await getTotalStars(totalRepos)
const totalForks = await getTotalForks(totalRepos) const totalForks = await getTotalForks(totalRepos)
const totalArticles = (await getAllArticles()).length const totalArticles = (await getAllArticles()).length
const totalArticleViews = (await getViews()).views // const totalArticleViews = (await getViews()).views
const topArtist = await getTopArtist() const topArtist = await getTopArtist()
const {genre} = await getTopGenre() const {genre} = await getTopGenre()
const {hoursListened, minutesListened, streams} = await getStats() const {hoursListened, minutesListened, streams} = await getStats()
@ -93,12 +92,12 @@ export async function getDashboardData() {
group: "Blog", group: "Blog",
href: "/writing" href: "/writing"
}, },
{ // {
title: "Total article views", // title: "Total article views",
value: +totalArticleViews, // value: +totalArticleViews,
group: "Blog", // group: "Blog",
href: "/writing" // href: "/writing"
} // }
] ]
// sort metrics into named groups // sort metrics into named groups

View File

@ -1,11 +0,0 @@
import {PrismaClient} from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query']
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

6
lib/supabase.ts Normal file
View File

@ -0,0 +1,6 @@
import {createClient} from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""
)

View File

@ -1,13 +0,0 @@
import {prisma} from '@/lib/prisma'
export async function getViews() {
const totalViews: { _sum: { count: any } } = await prisma.views.aggregate({
_sum: {
count: true
}
})
return {
views: totalViews._sum.count.toString()
}
}

1137
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,8 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.7", "@headlessui/react": "^1.7.7",
"@mapbox/rehype-prism": "^0.7.0", "@mapbox/rehype-prism": "^0.7.0",
"@next-auth/prisma-adapter": "^1.0.5",
"@next/mdx": "^13.1.1", "@next/mdx": "^13.1.1",
"@prisma/client": "^4.12.0", "@supabase/supabase-js": "^2.15.0",
"@tailwindcss/typography": "^0.5.8", "@tailwindcss/typography": "^0.5.8",
"@types/mdx": "^2.0.3", "@types/mdx": "^2.0.3",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
@ -23,22 +22,20 @@
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"eslint": "8.31.0", "eslint": "8.31.0",
"eslint-config-next": "^13.2.0", "eslint-config-next": "^13.3.0",
"fast-glob": "^3.2.12", "fast-glob": "^3.2.12",
"feed": "^4.2.2", "feed": "^4.2.2",
"focus-visible": "^5.2.0", "focus-visible": "^5.2.0",
"motion": "^10.15.5", "motion": "^10.15.5",
"next": "^13.2.0", "next": "^13.3.0",
"next-auth": "^4.21.1",
"node-fetch": "^3.3.0", "node-fetch": "^3.3.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"postcss-focus-visible": "^7.1.0", "postcss-focus-visible": "^7.1.0",
"prisma": "^4.12.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"swr": "^2.0.0", "swr": "^2.1.2",
"tailwind-merge": "^1.9.0", "tailwind-merge": "^1.9.0",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",

View File

@ -1,18 +0,0 @@
import NextAuth from 'next-auth'
import GithubProvider from 'next-auth/providers/github'
import {PrismaAdapter} from '@next-auth/prisma-adapter'
import {PrismaClient} from '@prisma/client'
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? ""
const GITHUB_SECRET = process.env.GITHUB_SECRET ?? ""
const prisma = new PrismaClient()
export default NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GithubProvider({
clientId: GITHUB_CLIENT_ID,
clientSecret: GITHUB_SECRET
})
],
})

View File

@ -1,47 +1,32 @@
import type {NextApiRequest, NextApiResponse} from 'next' import {NextApiRequest, NextApiResponse} from 'next'
import {prisma} from '@/lib/prisma' import {supabase} from '@/lib/supabase'
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try { if (req.method === 'POST') {
if (req.query.slug !== undefined) { if (req.query.slug !== undefined) {
const slug: string = req.query.slug.toString() const slug: string = req.query.slug.toString()
await supabase.rpc('increment_views', {page_slug: slug})
if (req.method === 'POST') { return res.status(200).json({})
const newOrUpdatedViews = await prisma.views.upsert({
where: {slug},
create: {
slug
},
update: {
count: {
increment: 1
}
}
})
return res.status(200).json({
views: newOrUpdatedViews.count.toString()
})
}
if (req.method === 'GET') {
const views = await prisma.views.findUnique({
where: {
slug
}
})
let count = null
if (views !== null) {
count = views.count.toString()
}
return res.status(200).json({
views: count
});
}
} }
} catch (e: any) {
return res.status(500).json({message: e.message}) return res.status(400).json({})
} else if (req.method === 'GET') {
if (req.query.slug !== undefined) {
const slug: string = req.query.slug.toString()
const response = await supabase
.from('analytics')
.select('views')
.eq('slug', slug)
.returns<any>()
const {views} = response.data[0]
return res.status(200).json({views})
}
return res.status(400).json({})
} }
return res.status(405).json({})
} }

View File

@ -28,7 +28,7 @@ function Article(article: Article) {
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}> <Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
{formatDate(article.date)} {formatDate(article.date)}
</Card.Eyebrow> </Card.Eyebrow>
<Views slug={article.slug} shouldUpdateViews={false} className="text-sm text-zinc-500 dark:text-zinc-400"/> <Views slug={article.slug} className="text-sm text-zinc-500 dark:text-zinc-400"/>
</p> </p>
</Card> </Card>
</article> </article>

View File

@ -19,7 +19,7 @@ function Article({article}: { article: Article }) {
<Card.Eyebrow as="time" dateTime={article.date} decorate={false}> <Card.Eyebrow as="time" dateTime={article.date} decorate={false}>
{formatDate(article.date)} {formatDate(article.date)}
</Card.Eyebrow> </Card.Eyebrow>
<Views slug={article.slug} shouldUpdateViews={false} className="text-sm text-zinc-500 dark:text-zinc-400"/> <Views slug={article.slug} className="text-sm text-zinc-500 dark:text-zinc-400"/>
</p> </p>
</Card> </Card>
</article> </article>

View File

@ -1,69 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
schemas = ["public", "auth"]
}
model Views {
slug String @id
count BigInt @default(1)
@@schema("public")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@schema("auth")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@schema("auth")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
@@schema("auth")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@schema("auth")
}