mirror of
https://github.com/r-freeman/portfolio.git
synced 2024-11-22 19:25:41 +00:00
Added real-time page views
This commit is contained in:
parent
1fabd19a53
commit
51737338b8
@ -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>
|
||||||
|
@ -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}>
|
||||||
|
@ -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
|
||||||
|
@ -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
6
lib/supabase.ts
Normal 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 ?? ""
|
||||||
|
)
|
13
lib/views.ts
13
lib/views.ts
@ -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
1137
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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",
|
||||||
|
@ -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
|
|
||||||
})
|
|
||||||
],
|
|
||||||
})
|
|
@ -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({})
|
||||||
}
|
}
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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")
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user