mirror of
https://github.com/r-freeman/portfolio.git
synced 2024-11-24 23:55:42 +00:00
This commit is contained in:
parent
d91c0eae02
commit
6a6dae25cd
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.git
|
||||
.next
|
||||
.env
|
@ -1,14 +1,11 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_REFRESH_TOKEN=
|
||||
NEXT_PUBLIC_SITE_URL=https://example.com
|
||||
NEXT_PUBLIC_SITE_URL=
|
||||
GITHUB_ACCESS_TOKEN=
|
||||
GITHUB_USERNAME=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_SECRET=
|
||||
STATSFM_USERNAME=
|
||||
GRAFANA_URL=
|
||||
GRAFANA_TOKEN=
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
@ -1,19 +0,0 @@
|
||||
name: Gitea Actions Demo
|
||||
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Explore-Gitea-Actions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||
- name: List files in the repository
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
59
.gitea/workflows/publish.yml
Normal file
59
.gitea/workflows/publish.yml
Normal file
@ -0,0 +1,59 @@
|
||||
name: Build And Publish
|
||||
run-name: ${{ gitea.actor }} runs ci pipeline
|
||||
on: [ push ]
|
||||
|
||||
jobs:
|
||||
BuildAndPublish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: https://github.com/actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18.17.0'
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: ./decrypt_secrets.sh
|
||||
env:
|
||||
SECRET_PASSPHRASE: ${{ secrets.SECRET_PASSPHRASE }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{secrets.DOCKER_HUB_USERNAME}}
|
||||
password: ${{secrets.DOCKER_HUB_PASSWORD}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{secrets.DOCKER_HUB_USERNAME}}/portfolio:v1
|
||||
secrets: |
|
||||
"NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}"
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}"
|
||||
"SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}"
|
||||
|
||||
- name: Stop the docker container
|
||||
continue-on-error: true
|
||||
run: sudo docker stop portfolio
|
||||
|
||||
- name: Remove the docker container
|
||||
continue-on-error: true
|
||||
run: sudo docker rm portfolio
|
||||
|
||||
- name: Pull the Docker image
|
||||
run: sudo docker pull ${{secrets.DOCKER_HUB_USERNAME}}/portfolio:v1
|
||||
|
||||
- name: Run the Docker container
|
||||
run: sudo docker run -d --restart unless-stopped --env-file ./.env --name portfolio -p ${{vars.TAILSCALE_IP}}:3000:3000 ${{secrets.DOCKER_HUB_USERNAME}}/portfolio:v1
|
78
Dockerfile
Normal file
78
Dockerfile
Normal file
@ -0,0 +1,78 @@
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci --legacy-peer-deps; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN touch .env.production
|
||||
|
||||
RUN --mount=type=secret,id=NEXT_PUBLIC_SUPABASE_URL \
|
||||
--mount=type=secret,id=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||
--mount=type=secret,id=SUPABASE_SERVICE_ROLE_KEY \
|
||||
export NEXT_PUBLIC_SUPABASE_URL=$(cat /run/secrets/NEXT_PUBLIC_SUPABASE_URL) && \
|
||||
export NEXT_PUBLIC_SUPABASE_ANON_KEY=$(cat /run/secrets/NEXT_PUBLIC_SUPABASE_ANON_KEY) && \
|
||||
export SUPABASE_SERVICE_ROLE_KEY=$(cat /run/secrets/SUPABASE_SERVICE_ROLE_KEY)
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
@ -1,26 +0,0 @@
|
||||
import {NextResponse} from 'next/server'
|
||||
import {getRamUsage, getRootFsUsage, getSysLoad, getTemp, getUptime} from '@/lib/pi'
|
||||
|
||||
export const fetchCache = 'force-no-store'
|
||||
|
||||
export async function GET(request: Request, {params}: { params: { slug: string } }) {
|
||||
const slug = params.slug
|
||||
let response
|
||||
if (slug === 'ram') {
|
||||
response = await getRamUsage()
|
||||
} else if (slug === 'rootfs') {
|
||||
response = await getRootFsUsage()
|
||||
} else if (slug === 'sysload') {
|
||||
response = await getSysLoad()
|
||||
} else if (slug === 'temp') {
|
||||
response = await getTemp()
|
||||
} else if (slug === 'uptime') {
|
||||
response = await getUptime()
|
||||
} else {
|
||||
return new Response('Not Found', {
|
||||
status: 404
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
}
|
@ -9,6 +9,7 @@ export async function GET(request: Request, {params}: { params: { slug: string }
|
||||
const supabase = createServerComponentClient<Database>({cookies})
|
||||
const slug = params.slug.toString()
|
||||
const response = await supabase
|
||||
// @ts-ignore
|
||||
.from('analytics')
|
||||
.select('views')
|
||||
.eq('slug', slug)
|
||||
|
5
decrypt_secrets.sh
Executable file
5
decrypt_secrets.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
# --batch to prevent interactive command
|
||||
# --yes to assume "yes" for questions
|
||||
gpg --quiet --batch --yes --decrypt --passphrase="$SECRET_PASSPHRASE" --output ./.env ./.env.gpg
|
145
lib/dashboard.ts
145
lib/dashboard.ts
@ -1,145 +0,0 @@
|
||||
import {cookies} from 'next/headers'
|
||||
import {createServerComponentClient} from '@supabase/auth-helpers-nextjs'
|
||||
import {getTopRepo, getTotalFollowers, getTotalForks, getTotalRepos, getTotalStars} from '@/lib/github'
|
||||
import {getAllArticles} from '@/lib/getAllArticles'
|
||||
import {getTopArtist, getTopGenre} from '@/lib/spotify'
|
||||
import {getRamUsage, getRootFsUsage, getSysLoad, getTemp, getUptime} from '@/lib/pi'
|
||||
import {getStats} from '@/lib/statsfm'
|
||||
import {Metric} from '@/types'
|
||||
|
||||
export async function getDashboardData() {
|
||||
const supabase = createServerComponentClient({cookies})
|
||||
const {data: views} = await supabase.rpc('total_views')
|
||||
const [totalRepos, totalFollowers] = await Promise.all([
|
||||
getTotalRepos(),
|
||||
getTotalFollowers()
|
||||
])
|
||||
|
||||
const topRepo = await getTopRepo()
|
||||
const totalStars = await getTotalStars(totalRepos)
|
||||
const totalForks = await getTotalForks(totalRepos)
|
||||
const totalArticles = (await getAllArticles()).length
|
||||
const topArtist = await getTopArtist()
|
||||
const {genre} = await getTopGenre()
|
||||
const {hoursListened, minutesListened, streams} = await getStats()
|
||||
const {temp} = await getTemp()
|
||||
const {sysLoad} = await getSysLoad()
|
||||
const {ramUsage} = await getRamUsage()
|
||||
const {rootFsUsage} = await getRootFsUsage()
|
||||
const {days} = await getUptime()
|
||||
|
||||
const metrics: Metric[] = [
|
||||
{
|
||||
title: "Streams",
|
||||
value: +streams,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Hours listened",
|
||||
value: +hoursListened,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Minutes listened",
|
||||
value: +minutesListened,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Top genre",
|
||||
value: genre,
|
||||
group: "Spotify",
|
||||
href: "https://open.spotify.com/?"
|
||||
},
|
||||
{
|
||||
title: "Top artist",
|
||||
value: topArtist.artist,
|
||||
group: "Spotify",
|
||||
href: topArtist.uri
|
||||
},
|
||||
{
|
||||
title: "Repos",
|
||||
value: +totalRepos,
|
||||
group: "GitHub",
|
||||
href: "https://github.com/r-freeman?tab=repositories"
|
||||
},
|
||||
{
|
||||
title: "Top repo",
|
||||
value: topRepo.name,
|
||||
group: "GitHub",
|
||||
href: topRepo.url
|
||||
},
|
||||
{
|
||||
title: "Followers",
|
||||
value: +totalFollowers,
|
||||
group: "GitHub",
|
||||
href: "https://github.com/r-freeman?tab=followers"
|
||||
},
|
||||
{
|
||||
title: "Stars",
|
||||
value: +totalStars,
|
||||
group: "GitHub",
|
||||
href: "https://github.com/r-freeman/"
|
||||
},
|
||||
{
|
||||
title: "Forks",
|
||||
value: +totalForks,
|
||||
group: "GitHub",
|
||||
href: "https://github.com/r-freeman/"
|
||||
},
|
||||
{
|
||||
title: "Total articles",
|
||||
value: +totalArticles,
|
||||
group: "Blog",
|
||||
href: "/writing"
|
||||
},
|
||||
{
|
||||
title: "Total article views",
|
||||
value: +views,
|
||||
group: "Blog",
|
||||
href: "/writing"
|
||||
},
|
||||
{
|
||||
title: "Temp",
|
||||
value: `${temp} ℃`,
|
||||
group: "Raspberry Pi",
|
||||
href: ""
|
||||
},
|
||||
{
|
||||
title: "Sys load (5m avg)",
|
||||
value: `${sysLoad}%`,
|
||||
group: "Raspberry Pi",
|
||||
href: ""
|
||||
},
|
||||
{
|
||||
title: "RAM usage",
|
||||
value: `${ramUsage}%`,
|
||||
group: "Raspberry Pi",
|
||||
href: ""
|
||||
},
|
||||
{
|
||||
title: "Root FS usage",
|
||||
value: `${rootFsUsage}%`,
|
||||
group: "Raspberry Pi",
|
||||
href: ""
|
||||
},
|
||||
{
|
||||
title: "Uptime days",
|
||||
value: `${Math.round(days)}`,
|
||||
group: "Raspberry Pi",
|
||||
href: ""
|
||||
}
|
||||
]
|
||||
|
||||
// sort metrics into named groups
|
||||
const groups = metrics.reduce((acc: { [key: string]: Metric[] }, item) => {
|
||||
(acc[item.group] = acc[item.group] || []).push(item);
|
||||
return acc
|
||||
}, {} as { [key: string]: Metric[] })
|
||||
|
||||
return Object.entries(groups).map(([groupName, groupItems]) => {
|
||||
return {groupName, groupItems}
|
||||
})
|
||||
}
|
170
lib/pi.ts
170
lib/pi.ts
@ -1,170 +0,0 @@
|
||||
import fetcher from '@/lib/fetcher'
|
||||
|
||||
const GRAFANA_URL: string = process.env.GRAFANA_URL ?? ""
|
||||
const GRAFANA_TOKEN = process.env.GRAFANA_TOKEN
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const yesterday = Date.now() - day;
|
||||
|
||||
export const getTemp = async () => {
|
||||
const response = await fetcher(GRAFANA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${GRAFANA_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "4f-R6jgRz",
|
||||
"type": "prometheus"
|
||||
},
|
||||
"expr": "node_hwmon_temp_celsius",
|
||||
"maxDataPoints": 100
|
||||
}
|
||||
],
|
||||
"from": yesterday.toString(),
|
||||
"to": "now"
|
||||
})
|
||||
})
|
||||
|
||||
const temp = parseInt(response.results.A.frames[0].data.values[1].slice(-1))
|
||||
|
||||
return {
|
||||
temp
|
||||
}
|
||||
}
|
||||
|
||||
export const getRootFsUsage = async () => {
|
||||
const response = await fetcher(GRAFANA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${GRAFANA_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "4f-R6jgRz",
|
||||
"type": "prometheus"
|
||||
},
|
||||
"expr": "100 - ((node_filesystem_avail_bytes{mountpoint='/',fstype!='rootfs'} * 100) / node_filesystem_size_bytes{mountpoint='/',fstype!='rootfs'})",
|
||||
"maxDataPoints": 100
|
||||
}
|
||||
],
|
||||
"from": yesterday.toString(),
|
||||
"to": "now"
|
||||
})
|
||||
})
|
||||
|
||||
const rootFsUsage = parseInt(response.results.A.frames[0].data.values[1].slice(-1))
|
||||
|
||||
return {
|
||||
rootFsUsage
|
||||
}
|
||||
}
|
||||
|
||||
export const getUptime = async () => {
|
||||
const response = await fetcher(GRAFANA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${GRAFANA_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "4f-R6jgRz",
|
||||
"type": "prometheus"
|
||||
},
|
||||
"expr": "node_time_seconds - node_boot_time_seconds",
|
||||
"maxDataPoints": 100
|
||||
}
|
||||
],
|
||||
"from": yesterday.toString(),
|
||||
"to": "now"
|
||||
})
|
||||
})
|
||||
|
||||
const seconds = parseInt(response.results.A.frames[0].data.values[1].slice(-1))
|
||||
const minutes = (seconds / 60)
|
||||
const hours = (seconds / 3_600)
|
||||
const days = (seconds / 86400)
|
||||
const weeks = (seconds / 604_800)
|
||||
|
||||
return {
|
||||
seconds: (seconds),
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
weeks
|
||||
}
|
||||
}
|
||||
|
||||
export const getRamUsage = async () => {
|
||||
const response = await fetcher(GRAFANA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${GRAFANA_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "4f-R6jgRz",
|
||||
"type": "prometheus"
|
||||
},
|
||||
"expr": "100 - ((node_memory_MemAvailable_bytes * 100) / node_memory_MemTotal_bytes)",
|
||||
"maxDataPoints": 100
|
||||
}
|
||||
],
|
||||
"from": yesterday.toString(),
|
||||
"to": "now"
|
||||
})
|
||||
})
|
||||
|
||||
const ramUsage = parseInt(response.results.A.frames[0].data.values[1].slice(-1))
|
||||
|
||||
return {
|
||||
ramUsage
|
||||
}
|
||||
}
|
||||
|
||||
export const getSysLoad = async () => {
|
||||
const response = await fetcher(GRAFANA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${GRAFANA_TOKEN}`,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {
|
||||
"uid": "4f-R6jgRz",
|
||||
"type": "prometheus"
|
||||
},
|
||||
"expr": "avg(node_load5) / count(count(node_cpu_seconds_total) by (cpu)) * 100",
|
||||
"maxDataPoints": 100
|
||||
}
|
||||
],
|
||||
"from": yesterday.toString(),
|
||||
"to": "now"
|
||||
})
|
||||
})
|
||||
|
||||
const sysLoad = parseInt(response.results.A.frames[0].data.values[1].slice(-1))
|
||||
|
||||
return {
|
||||
sysLoad
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import fetcher from './fetcher'
|
||||
|
||||
const STATSFM_USERNAME = process.env.STATSFM_USERNAME
|
||||
const STATSFM_LIFETIME_STATS = `https://beta-api.stats.fm/api/v1/users/${STATSFM_USERNAME}/streams/stats?range=lifetime`
|
||||
|
||||
type StatsFmResponse = {
|
||||
items: {
|
||||
durationMs: number
|
||||
count: number
|
||||
}
|
||||
}
|
||||
|
||||
export const getStats = async () => {
|
||||
const response = await fetcher(STATSFM_LIFETIME_STATS, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}) as StatsFmResponse
|
||||
|
||||
const {durationMs} = response.items
|
||||
const hoursListened = (durationMs / 3_600_000).toFixed(0)
|
||||
const minutesListened = (durationMs / 60_000).toFixed(0)
|
||||
const streams = response.items.count
|
||||
|
||||
return {
|
||||
hoursListened,
|
||||
minutesListened,
|
||||
streams
|
||||
}
|
||||
}
|
@ -11,7 +11,8 @@ const nextConfig = {
|
||||
hostname: 'i.scdn.co',
|
||||
port: ''
|
||||
}]
|
||||
}
|
||||
},
|
||||
output: 'standalone'
|
||||
}
|
||||
|
||||
const withMDX = nextMDX({
|
||||
|
9359
package-lock.json
generated
9359
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -33,7 +33,7 @@
|
||||
"feed": "^4.2.2",
|
||||
"focus-visible": "^5.2.0",
|
||||
"motion": "^10.15.5",
|
||||
"next": "^14.1.0",
|
||||
"next": "^14.1.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.21",
|
||||
"postcss-focus-visible": "^7.1.0",
|
||||
|
Loading…
Reference in New Issue
Block a user