Compare commits
126 Commits
d9b9977c2a
...
renovate/r
Author | SHA1 | Date | |
---|---|---|---|
|
c3e38e370b | ||
5acb6a4f81 | |||
|
7b7a3cf2a0 | ||
ee25889139 | |||
7cf1b4d16e | |||
e1b7beadbd | |||
e5a797718f | |||
b23c48246d | |||
573f13568d | |||
48973560d1 | |||
d022209305 | |||
e10d447873 | |||
|
e38d6a42de | ||
f604af5d4c | |||
1a1e854d73 | |||
a7a144dbd7 | |||
22c8034560 | |||
8436da4b95 | |||
091835f01a | |||
74b20a7d7c | |||
63afa18397 | |||
071fe82685 | |||
24ed4c96a7 | |||
e404482567 | |||
938bf248cc | |||
f8cbb90ef4 | |||
990d5c93cb | |||
a92532f3bb | |||
4e0f8957df | |||
eebf233ce9 | |||
f525a8af7c | |||
d5cb54030c | |||
72e624dfd1 | |||
a0b786ae68 | |||
ffbf485774 | |||
402962e386 | |||
adf898e3ef | |||
6a2c23229e | |||
519b688f13 | |||
447da11d71 | |||
ffc386ed93 | |||
414732bf89 | |||
|
2793e1213c | ||
40fd695d03 | |||
|
9c69c76d52 | ||
5e2946d00c | |||
cf8530c750 | |||
3d44677d82 | |||
6e949539ab | |||
45d5a0e2d4 | |||
18b31bcb7b | |||
a7da175499 | |||
24d6eb52f6 | |||
0cd347714e | |||
d364ea83b5 | |||
1614889a62 | |||
7c6fd8a5ef | |||
e26a9e0267 | |||
1634fb7520 | |||
d4209bc23d | |||
876d0094ca | |||
cbc9dcb1ab | |||
1296a34657 | |||
1717c0859d | |||
dc89db4eed | |||
40b799e280 | |||
71c24bd6cc | |||
c4f7d4bf7e | |||
54230367e1 | |||
68fae2e29e | |||
be319a4d7b | |||
325891663b | |||
36af41cbe4 | |||
fbde7667bc | |||
cdcee387b5 | |||
|
901f7706d4 | ||
ed67edde09 | |||
1f18d6fb45 | |||
96687ea94c | |||
61d052fc62 | |||
137dfacfed | |||
138b187378 | |||
195f23f44b | |||
6fd1d7b8a9 | |||
c0f9307236 | |||
2c4e2bd0b1 | |||
942938ad8f | |||
|
2c03bcd54e | ||
3305ea065c | |||
de76a38096 | |||
8cb3a38beb | |||
b7a5665036 | |||
6e46732bcc | |||
7c8d613799 | |||
666666e254 | |||
fe56169874 | |||
83ea83bd18 | |||
8cb347b1dc | |||
a548b41725 | |||
505e56bcfb | |||
d9a721d6c4 | |||
a5bdce4ea6 | |||
c93f11b710 | |||
106233c01f | |||
99ab34b2ac | |||
a396118f86 | |||
c99a21395b | |||
f6bd4a2dcb | |||
90faa80fe8 | |||
fbddc47582 | |||
4e5e64b390 | |||
05a480d41b | |||
328a2226d0 | |||
2b19fdf7bc | |||
cf6447e4c4 | |||
96b79d9995 | |||
f73c2a61e6 | |||
0dded272f4 | |||
26370ba3b6 | |||
f6a070851a | |||
dd0e020165 | |||
7e6ca0d27f | |||
638d15409c | |||
28bb9c61ac | |||
8298e33f44 | |||
80425ee7c8 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
*.lockb binary diff=lockb
|
56
.gitea/workflows/deploy-publish.yml
Normal file
56
.gitea/workflows/deploy-publish.yml
Normal file
@ -0,0 +1,56 @@
|
||||
name: Deploy & Publish Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
paths-ignore:
|
||||
- README.md
|
||||
- LICENSE
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [ "ubuntu-latest" ]
|
||||
git-version: [ "2.44.0" ]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
|
||||
# Steps to run
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Deploy to Dokku
|
||||
- name: Deploy to Dokku
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
git_remote_url: "ssh://dokku@10.10.70.73:22/docs"
|
||||
ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY }}
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to the Docker registry
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.rainnny.club
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
# Publish the image
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.rainnny.club/pulseapp/docs:${{ github.sha }}
|
||||
git.rainnny.club/pulseapp/docs:latest
|
||||
build-args: |
|
||||
GIT_REV=${{ gitea.sha }}
|
@ -1,31 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
paths-ignore:
|
||||
- README.md
|
||||
- LICENSE
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [ "ubuntu-latest" ]
|
||||
git-version: [ "2.44.0" ]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
|
||||
# Steps to run
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Deploy to Dokku
|
||||
- name: Deploy to Dokku
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
git_remote_url: "ssh://dokku@10.10.70.73:22/docs"
|
||||
ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY }}
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,7 +6,6 @@ node_modules
|
||||
.env*.local
|
||||
next-env.d.ts
|
||||
.sentryclirc
|
||||
.env
|
||||
sw.*
|
||||
workbox-*
|
||||
swe-worker-*
|
||||
|
11
Dockerfile
11
Dockerfile
@ -13,7 +13,7 @@ FROM base AS builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=depends /usr/src/app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN bun run build
|
||||
|
||||
|
||||
@ -32,14 +32,15 @@ COPY --from=builder --chown=nextjs:nextjs /usr/src/app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/next.config.mjs ./next.config.mjs
|
||||
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/docs ./docs
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Exposting on port 80 so we can
|
||||
# access via a reverse proxy for Dokku
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
EXPOSE 80
|
||||
ENV PORT 80
|
||||
ENV PORT=80
|
||||
|
||||
USER nextjs
|
||||
CMD node server.js
|
||||
CMD ["node", "server.js"]
|
@ -1,3 +1,2 @@
|
||||
# docs
|
||||
|
||||
The public documentation for Pulse App.
|
||||
The source coded for the Pulse App documentation site.
|
90
config.json
Normal file
90
config.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"siteName": "Pulse App",
|
||||
"ogApiUrl": "https://docs.pulseapp.cc/api/og?title={title}",
|
||||
"metadata": {
|
||||
"title": {
|
||||
"default": "Pulse Docs",
|
||||
"template": "%s • Pulse Docs"
|
||||
},
|
||||
"description": "A lightweight service monitoring solution for tracking the availability of whatever service your heart desires!",
|
||||
"openGraph": {
|
||||
"images": [
|
||||
{
|
||||
"url": "https://pulseapp.cc/media/logo.png",
|
||||
"width": 128,
|
||||
"height": 128
|
||||
}
|
||||
]
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary"
|
||||
}
|
||||
},
|
||||
"viewport": {
|
||||
"themeColor": "#A855F7"
|
||||
},
|
||||
"contentSource": "https://git.rainnny.club/PulseApp/docs-content.git",
|
||||
"contentEditUrl": "https://git.rainnny.club/PulseApp/docs-content/src/branch/master/{slug}{ext}",
|
||||
"socialLinks": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"tooltip": "View our Github",
|
||||
"logo": "./github.svg",
|
||||
"href": "https://github.com/PulseAppCC",
|
||||
"navbar": true
|
||||
},
|
||||
{
|
||||
"name": "Discord",
|
||||
"tooltip": "Join our Discord",
|
||||
"logo": "./discord.svg",
|
||||
"href": "https://discord.pulseapp.cc",
|
||||
"navbar": true
|
||||
},
|
||||
{
|
||||
"name": "Email",
|
||||
"tooltip": "Email us",
|
||||
"logo": "Mail",
|
||||
"href": "mailto:support@pulseapp.cc",
|
||||
"navbar": false
|
||||
}
|
||||
],
|
||||
"footer": {
|
||||
"homeUrl": "https://pulseapp.cc",
|
||||
"links": {
|
||||
"Resources": [
|
||||
{
|
||||
"name": "Support",
|
||||
"href": "https://support.pulseapp.cc"
|
||||
},
|
||||
{
|
||||
"name": "Jobs",
|
||||
"href": "https://jobs.pulseapp.cc"
|
||||
},
|
||||
{
|
||||
"name": "Developers",
|
||||
"shortName": "Devs",
|
||||
"href": "https://dev.pulseapp.cc",
|
||||
"external": true
|
||||
},
|
||||
{
|
||||
"name": "System Status",
|
||||
"shortName": "Status",
|
||||
"href": "https://status.pulseapp.cc",
|
||||
"external": true
|
||||
}
|
||||
],
|
||||
"Legal": [
|
||||
{
|
||||
"name": "Terms & Conditions",
|
||||
"shortName": "Terms",
|
||||
"href": "/legal/terms"
|
||||
},
|
||||
{
|
||||
"name": "Privacy Policy",
|
||||
"shortName": "Privacy",
|
||||
"href": "/legal/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
image: git.rainnny.club/pulseapp/docs:latest
|
||||
restart: unless-stopped
|
||||
container_name: docs
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./config.json:/usr/src/app/config.json
|
||||
- ./docs:/usr/src/app/docs
|
@ -1,7 +0,0 @@
|
||||
---
|
||||
title: 'Hello'
|
||||
published: '10-06-2024'
|
||||
summary: 'petentium usu tota noluisse errem elaboraret auctor.'
|
||||
---
|
||||
|
||||
# hello
|
@ -1,7 +0,0 @@
|
||||
---
|
||||
title: 'Hey'
|
||||
published: '10-06-2024'
|
||||
summary: 'petentium usu tota noluisse errem elaboraret auctor.'
|
||||
---
|
||||
|
||||
# hey
|
@ -1,7 +0,0 @@
|
||||
---
|
||||
title: 'Hi'
|
||||
published: '10-06-2024'
|
||||
summary: 'petentium usu tota noluisse errem elaboraret auctor.'
|
||||
---
|
||||
|
||||
# hi
|
@ -1,8 +0,0 @@
|
||||
---
|
||||
title: 'Home'
|
||||
published: '10-06-2024'
|
||||
summary: 'petentium usu tota noluisse errem elaboraret auctor.'
|
||||
---
|
||||
|
||||
# Get started with Pulse App!
|
||||
petentium usu tota noluisse errem elaboraret auctor.
|
9
docs/intro.mdx
Normal file
9
docs/intro.mdx
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
title: 'Example'
|
||||
updated: '2024-10-06'
|
||||
summary: 'petentium usu tota noluisse errem elaboraret auctor.'
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Hello World
|
||||
This is an example content file.
|
19
package.json
19
package.json
@ -15,30 +15,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@vercel/og": "^0.6.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"framer-motion": "^11.11.1",
|
||||
"lucide-react": "^0.447.0",
|
||||
"lucide-react": "^0.452.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "^15.0.0-canary.179",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^19.0.0-rc-1460d67c-20241003",
|
||||
"react-dom": "^19.0.0-rc-1460d67c-20241003",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remote-mdx": "^0.0.8",
|
||||
"simple-git": "^3.27.0",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.8"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
|
||||
<path fill="#fff"
|
||||
<path fill="#5865f2"
|
||||
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 777 B After Width: | Height: | Size: 780 B |
@ -1,5 +1,50 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
|
||||
fill="#fff"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 96 96" id="Github-Octocat--Streamline-Svg-Logos.svg"
|
||||
height="98" width="98" stroke-width="1">
|
||||
<desc>Github Octocat Streamline Icon: https://streamlinehq.com</desc>
|
||||
<path fill="#9EDCF2"
|
||||
d="M74.4304 73.4278c0 5.0396-11.6744 9.1002-26.068 9.1002-14.3936 0-26.068-4.0969-26.068-9.1002 0-5.0396 11.6744-9.1002 26.068-9.1002 14.3936 0 26.068 4.0606 26.068 9.1002Z"></path>
|
||||
<mask id="a" width="30" height="25" x="33" y="70" maskUnits="userSpaceOnUse" style="mask-type:luminance">
|
||||
<path fill="#fff"
|
||||
d="M37.159 89.7428c.5801 2.7192 1.9941 4.3145 3.4081 5.2571h14.9012c1.8128-1.2327 3.6618-3.553 3.6618-7.9037V75.8568s.2175-2.7917 2.7917-3.6981c0 0 1.4865-1.0514-.1088-1.6315 0 0-7.0699-.5801-7.0699 5.2209v8.5564s.2901 3.1542-1.3777 4.4594V78.1772s.1088-3.3718 1.8491-4.6407c0 0 1.1602-2.0666-1.3777-1.5228 0 0-4.8583.6889-5.0759 6.381l-.1087 10.8768H47.492l-.1088-10.8768c-.2175-5.6559-5.0758-6.381-5.0758-6.381-2.5379-.5801-1.3777 1.5228-1.3777 1.5228 1.7402 1.2689 1.849 4.6407 1.849 4.6407v10.6955c-1.6678-1.1965-1.3777-4.5682-1.3777-4.5682v-8.5564c0-5.801-7.0699-5.2209-7.0699-5.2209-1.6315.5801-.1088 1.6315-.1088 1.6315 2.5379.9427 2.7917 3.6981 2.7917 3.6981v7.8676l.145 6.0184Z"></path>
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<path fill="#7DBCE7"
|
||||
d="M74.4304 73.4276c0 5.0395-11.6744 9.1002-26.068 9.1002-14.3936 0-26.068-4.0969-26.068-9.1002 0-5.0396 11.6744-9.1003 26.068-9.1003 14.3936 0 26.068 4.0607 26.068 9.1003Z"></path>
|
||||
</g>
|
||||
<path fill="#9EDCF2"
|
||||
d="m18.5959 46.6347-.7614 2.6104s-.1812.9427.6889 1.124c.9426-.0363.8701-.9064.7976-1.1602l-.7251-2.5742Z"></path>
|
||||
<path fill="#010101"
|
||||
d="m94.3348 35.6491.0725-.3263c-7.65-1.5227-15.4812-1.559-20.2308-1.3414.7614-2.7917 1.0152-6.0548 1.0152-9.6441 0-5.1846-1.9578-9.3177-5.0758-12.4358.5438-1.7765 1.2689-5.72838-.7251-10.76795 0 0-3.5531-1.12392813-11.6382 4.2782-3.1542-.79763-6.526-1.19645-9.8978-1.19645-3.6981 0-7.4325.47133-10.9493 1.41398C28.5667-.0628898 24.9048 1.0973 24.9048 1.0973c-2.3929 5.98222-.9064 10.4417-.4713 11.5294-2.828 3.0455-4.532 6.9248-4.532 11.6744 0 3.5893.3988 6.8161 1.414 9.6078-4.7858-.1813-12.32702-.1088-19.72321 1.3777l.07251.3263c7.39619-1.4865 15.0099-1.5228 19.7594-1.3415.2176.5801.4714 1.1602.7252 1.704-4.7133.1451-12.72585.7614-20.41209 2.9368l.10877.3263C9.60483 37.0631 17.6899 36.483 22.3669 36.338c2.828 5.2208 8.3389 8.6289 18.2004 9.6803-1.4139.9427-2.8279 2.5379-3.408 5.2571-1.9216.9064-7.94 3.1543-11.5656-3.0817 0 0-2.0304-3.6981-5.9097-3.9882 0 0-3.7706-.0725-.2538 2.3567 0 0 2.5016 1.1964 4.2419 5.6559 0 0 2.2841 7.6137 13.1971 5.1483v7.795s-.2175 2.7917-2.7917 3.6981c0 0-1.5227 1.0514.1088 1.6315 0 0 7.0699.5801 7.0699-5.2208v-8.5564s-.29-3.408 1.3777-4.5682v14.0672s-.1087 3.3718-1.849 4.6408c0 0-1.1602 2.0666 1.3777 1.5227 0 0 4.8583-.6888 5.0758-6.381l.1088-14.2485h1.1602l.1088 14.2485c.2175 5.6559 5.0758 6.381 5.0758 6.381 2.5379.5801 1.3777-1.5227 1.3777-1.5227-1.7403-1.269-1.8491-4.6408-1.8491-4.6408V52.2543c1.6678 1.3052 1.3778 4.4595 1.3778 4.4595v8.5564c0 5.8009 7.0699 5.2208 7.0699 5.2208 1.6315-.5801.1087-1.6315.1087-1.6315-2.5379-.9426-2.7917-3.6981-2.7917-3.6981V53.9221c0-4.387-1.849-6.7073-3.6618-7.9038 10.5142-1.0514 15.5538-4.4232 17.8741-9.7166 4.6045.1088 12.9071.6889 20.8109 2.9368l.1088-.3263c-7.8676-2.2116-16.0976-2.7917-20.7746-2.9368.2175-.5438.3988-1.0876.5801-1.6677 4.8582-.1813 12.7258-.1813 20.412 1.3414Z"></path>
|
||||
<path fill="#F5CCB3"
|
||||
d="M64.8947 24.0835c2.2479 2.0666 3.5893 4.532 3.5893 7.1787 0 12.472-9.2815 12.7983-20.7383 12.7983-11.4569 0-20.7384-1.7403-20.7384-12.7983 0-2.6467 1.3052-5.1121 3.5531-7.1424 3.7344-3.4081 10.0429-1.5953 17.1853-1.5953 7.1424 0 13.4147-1.849 17.149 1.559Z"></path>
|
||||
<path fill="#fff"
|
||||
d="M40.8579 31.9148c0 3.4443-1.9216 6.1997-4.3144 6.1997-2.3929 0-4.3145-2.7917-4.3145-6.1997 0-3.4443 1.9216-6.1998 4.3145-6.1998 2.3928-.0362 4.3144 2.7555 4.3144 6.1998Z"></path>
|
||||
<path fill="#AF5C51"
|
||||
d="M39.4794 31.9872c0 2.2842-1.3052 4.1332-2.8642 4.1332-1.5953 0-2.8642-1.849-2.8642-4.1332 0-2.2841 1.3052-4.1331 2.8642-4.1331s2.8642 1.849 2.8642 4.1331Z"></path>
|
||||
<path fill="#fff"
|
||||
d="M64.0254 31.9148c0 3.4443-1.9216 6.1997-4.3145 6.1997s-4.3144-2.7917-4.3144-6.1997c0-3.4443 1.9215-6.1998 4.3144-6.1998 2.3567-.0362 4.3145 2.7555 4.3145 6.1998Z"></path>
|
||||
<path fill="#AF5C51"
|
||||
d="M62.6108 31.9872c0 2.2842-1.3053 4.1332-2.8643 4.1332-1.5952 0-2.8642-1.849-2.8642-4.1332 0-2.2841 1.3052-4.1331 2.8642-4.1331 1.5953 0 2.8643 1.849 2.8643 4.1331Z"></path>
|
||||
<path fill="#AF5C51"
|
||||
d="M48.9068 37.4257c0 .5801-.4713 1.0877-1.0877 1.0877-.5801 0-1.0877-.4714-1.0877-1.0877 0-.6164.4714-1.0877 1.0877-1.0877.5801 0 1.0877.4713 1.0877 1.0877Z"></path>
|
||||
<path fill="#AF5C51"
|
||||
d="M45.3171 40.1449c-.0725-.1813.0363-.3625.2176-.4351.1813-.0725.3625.0363.4351.2176.29.7976 1.0151 1.3052 1.849 1.3052.8339 0 1.559-.5438 1.8491-1.3052.0725-.1813.2537-.2901.435-.2176.1813.0726.2901.2538.2176.4351-.3626 1.0514-1.3778 1.7766-2.5017 1.7766-1.1239 0-2.1391-.7252-2.5017-1.7766Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M21.3518 45.0757c0 .29-.3263.5076-.7613.5076-.3988 0-.7614-.2176-.7614-.5076 0-.29.3263-.5076.7614-.5076.435 0 .7613.2176.7613.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M23.4544 46.2359c0 .29-.3263.5075-.7614.5075-.3988 0-.7614-.2175-.7614-.5075 0-.2901.3263-.5076.7614-.5076s.7614.2175.7614.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M24.7229 47.7586c0 .2901-.3263.5076-.7613.5076-.3988 0-.7614-.2175-.7614-.5076 0-.29.3263-.5076.7614-.5076.435-.0362.7613.2176.7613.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M25.8836 49.499c0 .29-.3263.5075-.7614.5075-.3988 0-.7614-.2175-.7614-.5075 0-.2901.3263-.5076.7614-.5076.4351-.0363.7614.2175.7614.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M27.1521 51.0941c0 .2901-.3263.5076-.7613.5076-.3988 0-.7614-.2175-.7614-.5076 0-.29.3263-.5076.7614-.5076.435 0 .7613.2176.7613.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M28.8924 52.5081c0 .2901-.3263.5076-.7614.5076-.3988 0-.7614-.2175-.7614-.5076 0-.29.3263-.5076.7614-.5076.4351-.0362.7614.2176.7614.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M31.3216 53.4146c0 .29-.3263.5075-.7614.5075-.3988 0-.7614-.2175-.7614-.5075 0-.2901.3263-.5076.7614-.5076s.7614.2175.7614.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M33.7517 53.4146c0 .29-.3263.5075-.7613.5075-.3988 0-.7614-.2175-.7614-.5075 0-.2901.3263-.5076.7614-.5076.435 0 .7613.2175.7613.5076Z"></path>
|
||||
<path fill="#C4E5D9"
|
||||
d="M36.2166 53.0157c0 .29-.3263.5076-.7614.5076-.3988 0-.7614-.2176-.7614-.5076 0-.29.3263-.5076.7614-.5076.3988 0 .7614.2176.7614.5076Z"></path>
|
||||
</svg>
|
Before Width: | Height: | Size: 986 B After Width: | Height: | Size: 6.7 KiB |
BIN
public/media/mike.png
Normal file
BIN
public/media/mike.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
@ -2,6 +2,19 @@ import { ReactElement } from "react";
|
||||
import { getDocsContent } from "@/lib/mdx";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CustomMDX } from "@/components/mdx";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { capitalizeWords } from "@/lib/string";
|
||||
import { Metadata } from "next";
|
||||
import Embed from "@/components/embed";
|
||||
import DocsFooter from "@/components/docs-footer";
|
||||
import OnThisPage from "@/components/on-this-page";
|
||||
import config from "@/config";
|
||||
|
||||
/**
|
||||
* The page to render the documentation markdown content.
|
||||
@ -18,18 +31,91 @@ const DocsPage = async ({
|
||||
);
|
||||
|
||||
// Get the content to display based on the provided slug
|
||||
const content: DocsContentMetadata | undefined = getDocsContent().find(
|
||||
const pages: DocsContentMetadata[] = await getDocsContent();
|
||||
const decodedSlug: string = decodeURIComponent(slug || "");
|
||||
const page: DocsContentMetadata | undefined = pages.find(
|
||||
(metadata: DocsContentMetadata): boolean =>
|
||||
metadata.slug === (slug || "home")
|
||||
metadata.slug === (decodedSlug || pages[0].slug)
|
||||
);
|
||||
if (!content) {
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
const splitSlug: string[] = page.slug?.split("/") || [];
|
||||
|
||||
return (
|
||||
<main>
|
||||
<CustomMDX source={content.content} />
|
||||
<main className="w-full flex flex-col">
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb className="pt-4 pb-3 select-none">
|
||||
<BreadcrumbList>
|
||||
{splitSlug
|
||||
.slice(0, -1)
|
||||
.map((part: string, index: number): ReactElement => {
|
||||
const slug: string = splitSlug
|
||||
.slice(1, index + 2) // Include one more to account for the index shift
|
||||
.join("/");
|
||||
return (
|
||||
<div className="flex items-center" key={part}>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
href={slug}
|
||||
draggable={false}
|
||||
>
|
||||
{capitalizeWords(part)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{index < splitSlug.length - 1 && ( // Adjusted to avoid separator after the last breadcrumb
|
||||
<BreadcrumbSeparator className="pl-1.5" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<BreadcrumbItem className="text-primary">
|
||||
<BreadcrumbLink href="#" draggable={false}>
|
||||
{page.title}{" "}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex gap-5 justify-between">
|
||||
<div className="flex flex-col">
|
||||
<CustomMDX source={page.content} />
|
||||
</div>
|
||||
<div className="hidden xl:flex">
|
||||
<OnThisPage page={page} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<DocsFooter pages={pages} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata | undefined> => {
|
||||
const slug: string = (((await params).slug as string[]) || undefined)?.join(
|
||||
"/"
|
||||
);
|
||||
if (slug) {
|
||||
const pages: DocsContentMetadata[] = await getDocsContent();
|
||||
const decodedSlug: string = decodeURIComponent(slug || "");
|
||||
const page: DocsContentMetadata | undefined = pages.find(
|
||||
(metadata: DocsContentMetadata): boolean =>
|
||||
metadata.slug === (decodedSlug || pages[0].slug)
|
||||
);
|
||||
if (page) {
|
||||
return Embed({
|
||||
title: page.title,
|
||||
description: page.summary,
|
||||
thumbnail: config.ogApiUrl.replace("{title}", page.title),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default DocsPage;
|
||||
|
30
src/app/api/og/route.tsx
Normal file
30
src/app/api/og/route.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import config from "@/config";
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const title: string | undefined = searchParams.has("title")
|
||||
? searchParams.get("title")?.slice(0, 100)
|
||||
: "Hello World (:";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw="w-full h-full flex flex-col justify-center items-center bg-black/95 text-white">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src={(config.metadata.openGraph?.images as any)[0].url}
|
||||
alt={`${config.siteName} Logo`}
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<h1 tw="text-5xl font-bold">{title}</h1>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
};
|
7
src/app/config.ts
Normal file
7
src/app/config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The configuration for this app.
|
||||
*/
|
||||
import config from "@/configJson";
|
||||
import { Config } from "@/types/config";
|
||||
|
||||
export default config as Config;
|
@ -2,62 +2,59 @@ import type { Metadata, Viewport } from "next";
|
||||
import "./styles/globals.css";
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import Navbar from "@/components/navbar";
|
||||
import Navbar from "@/components/navbar/navbar";
|
||||
import Sidebar from "@/components/sidebar/sidebar";
|
||||
import Footer from "@/components/footer";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getDocsContent } from "@/lib/mdx";
|
||||
import config from "@/config";
|
||||
|
||||
/**
|
||||
* The metadata for this app.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Pulse App Docs",
|
||||
template: "%s • Pulse App Docs",
|
||||
},
|
||||
description:
|
||||
"A lightweight service monitoring solution for tracking the availability of whatever service your heart desires!",
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: "https://pulseapp.cc/media/logo.png",
|
||||
width: 128,
|
||||
height: 128,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
},
|
||||
};
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#A855F7",
|
||||
};
|
||||
export const metadata: Metadata = config.metadata;
|
||||
export const viewport: Viewport = config.viewport;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The primary layout for this app.
|
||||
*/
|
||||
const RootLayout = ({
|
||||
const RootLayout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>): ReactElement => (
|
||||
}>): Promise<ReactElement> => {
|
||||
const pages: DocsContentMetadata[] = await getDocsContent();
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className="scroll-smooth antialiased"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, hsl(240, 6%, 10%), hsl(var(--background)))",
|
||||
background: "var(--background-gradient)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
<div className="px-10 max-w-[90rem] mx-auto min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="w-full h-full flex flex-grow gap-5">
|
||||
<Sidebar />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className="px-3 md:px-7 max-w-screen-2xl min-h-screen mx-auto flex flex-col transition-transform">
|
||||
<Navbar pages={pages} />
|
||||
<div className="pt-[4.5rem] w-full h-full flex flex-grow gap-5 sm:gap-8 transition-transform transform-gpu">
|
||||
<div className="relative hidden xs:flex">
|
||||
<Sidebar pages={pages} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
export default RootLayout;
|
||||
|
33
src/app/not-found.tsx
Normal file
33
src/app/not-found.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { ReactElement } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* The not found page.
|
||||
*
|
||||
* @return the page jsx
|
||||
*/
|
||||
const NotFoundPage = (): ReactElement => (
|
||||
<main className="w-full pt-[25vh] min-h-screen flex justify-center select-none pointer-events-none">
|
||||
<div className="h-fit flex gap-5 sm:gap-10 items-center transition-all transform-gpu">
|
||||
{/* Image */}
|
||||
<div className="relative w-24 h-24 xs:w-20 xs:h-20 sm:w-44 sm:h-44">
|
||||
<Image
|
||||
src="/media/mike.png"
|
||||
alt="Mike Wazowski"
|
||||
fill
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-3xl font-bold">Wrong Door!</h1>
|
||||
<p className="max-w-72 sm:text-lg opacity-75">
|
||||
The documentation page you were looking for could not be
|
||||
found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
export default NotFoundPage;
|
@ -20,13 +20,13 @@ body {
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary: 271 91% 65%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent: 0 0% 90%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
@ -39,6 +39,8 @@ body {
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
--background-gradient: hsl(var(--background));
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -66,15 +68,40 @@ body {
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--background-gradient: linear-gradient(to top, hsl(240, 6%, 10%), hsl(var(--background)));
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
||||
/* Scrollbar (Firefox) */
|
||||
scrollbar-color: hsl(var(--accent)) hsl(var(--background));
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar (Chrome & Safari) */
|
||||
@layer base {
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-accent rounded-3xl;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-opacity-80;
|
||||
}
|
||||
}
|
104
src/app/types/config.ts
Normal file
104
src/app/types/config.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
* The name of this app.
|
||||
*/
|
||||
siteName: string;
|
||||
|
||||
/**
|
||||
* The URL to use for generating OG images.
|
||||
*/
|
||||
ogApiUrl: string;
|
||||
|
||||
/**
|
||||
* The metadata for this app.
|
||||
*/
|
||||
metadata: Metadata;
|
||||
|
||||
/**
|
||||
* The viewport for this app.
|
||||
*/
|
||||
viewport: Viewport;
|
||||
|
||||
/**
|
||||
* The source to get the content from.
|
||||
* This can either be a local source, or
|
||||
* a remote Git repository.
|
||||
*/
|
||||
contentSource: string;
|
||||
|
||||
/**
|
||||
* The URL to link to for editing content of a page.
|
||||
*/
|
||||
contentEditUrl: string;
|
||||
|
||||
/**
|
||||
* Social links for this app.
|
||||
*/
|
||||
socialLinks: SocialLinkType[];
|
||||
|
||||
/**
|
||||
* Configuration for the footer.
|
||||
*/
|
||||
footer: {
|
||||
/**
|
||||
* The URL to link to when the branding is clicked.
|
||||
*/
|
||||
homeUrl: string;
|
||||
|
||||
/**
|
||||
* Links for the footer.
|
||||
*/
|
||||
links: {
|
||||
[category: string]: FooterLink[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocialLinkType = {
|
||||
/**
|
||||
* The name of this social link.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The tooltip for this social link.
|
||||
*/
|
||||
tooltip: string;
|
||||
|
||||
/**
|
||||
* The logo for this social link.
|
||||
* This can either be an image URL, or
|
||||
* the name of an icon from <a href="https://lucide.dev/icons">Lucide Icons</a>
|
||||
*/
|
||||
logo: string;
|
||||
|
||||
/**
|
||||
* The href for this social link.
|
||||
*/
|
||||
href: string;
|
||||
|
||||
/**
|
||||
* Whether to show this social link in the navbar.
|
||||
*/
|
||||
navbar: boolean;
|
||||
};
|
||||
|
||||
export type FooterLink = {
|
||||
/**
|
||||
* The name of this link.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The href for this link.
|
||||
*/
|
||||
href: string;
|
||||
|
||||
/**
|
||||
* The optional name to show
|
||||
* when the screen size is small.
|
||||
*/
|
||||
shortName?: string;
|
||||
};
|
@ -8,14 +8,19 @@ type DocsContentMetadata = MDXMetadata & {
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The date this content was published.
|
||||
* The date this content was updated.
|
||||
*/
|
||||
published: string;
|
||||
updated: string;
|
||||
|
||||
/**
|
||||
* The summary of this content.
|
||||
*/
|
||||
summary: string;
|
||||
|
||||
/**
|
||||
* The order of this content.
|
||||
*/
|
||||
order: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
94
src/components/docs-footer.tsx
Normal file
94
src/components/docs-footer.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DateTime } from "luxon";
|
||||
import SimpleTooltip from "@/components/simple-tooltip";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
const DocsFooter = ({
|
||||
pages,
|
||||
}: {
|
||||
pages: DocsContentMetadata[];
|
||||
}): ReactElement => {
|
||||
const path: string = decodeURIComponent(usePathname());
|
||||
|
||||
const current: number = pages.findIndex(
|
||||
(page: DocsContentMetadata) =>
|
||||
(path === "/" && page.slug === pages[0].slug) ||
|
||||
path === `/${page.slug}`
|
||||
);
|
||||
const previous: DocsContentMetadata | undefined =
|
||||
current > 0 ? pages[current - 1] : undefined;
|
||||
const next: DocsContentMetadata | undefined =
|
||||
current < pages.length - 1 ? pages[current + 1] : undefined;
|
||||
|
||||
const [updatedDate, setUpdatedDate] = useState<string | null>(
|
||||
DateTime.fromISO(pages[current]?.updated).toRelative()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setUpdatedDate(
|
||||
DateTime.fromISO(pages[current]?.updated).toRelative()
|
||||
);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [current, pages]);
|
||||
|
||||
return (
|
||||
<footer className="xs:mx-5 sm:mx-10 my-2 flex flex-col select-none transition-all transform-gpu">
|
||||
{/* updated Date */}
|
||||
<div className="ml-auto pt-4">
|
||||
<SimpleTooltip
|
||||
content={`Last updated on ${DateTime.fromISO(
|
||||
pages[current]?.updated
|
||||
).toLocaleString(DateTime.DATETIME_MED)}`}
|
||||
>
|
||||
<span className="text-xs sm:text-sm opacity-75 transition-all transform-gpu">
|
||||
Updated {updatedDate}
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
{/* Pages */}
|
||||
{previous || next ? (
|
||||
<Separator className="my-4 dark:bg-separator-gradient" />
|
||||
) : undefined}
|
||||
<div className="flex justify-between text-xs sm:text-base">
|
||||
{/* Previous */}
|
||||
{previous && (
|
||||
<Link
|
||||
className="flex gap-2 items-end hover:opacity-75 transition-all transform-gpu group"
|
||||
href={`/${previous.slug}` || "#"}
|
||||
draggable={false}
|
||||
>
|
||||
<ChevronLeft className="pb-1 w-4 h-4 group-hover:-translate-x-0.5 transition-all transform-gpu" />
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-sm opacity-75">Previous</h1>
|
||||
<p>{previous.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{next && (
|
||||
<Link
|
||||
className="ml-auto flex gap-2 items-end hover:opacity-75 transition-all transform-gpu group"
|
||||
href={`/${next.slug}` || "#"}
|
||||
draggable={false}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-sm opacity-75">Next</h1>
|
||||
<p>{next.title}</p>
|
||||
</div>
|
||||
<ChevronRight className="pb-1 w-4 h-4 group-hover:translate-x-0.5 transition-all transform-gpu" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
export default DocsFooter;
|
50
src/components/embed.tsx
Normal file
50
src/components/embed.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* Props for an embed.
|
||||
*/
|
||||
type EmbedProps = {
|
||||
/**
|
||||
* The title of the embed.
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The description of the embed.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The optional thumbnail image of the embed.
|
||||
*/
|
||||
thumbnail?: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* An embed for a page.
|
||||
*
|
||||
* @param props the embed props
|
||||
* @returns the embed jsx
|
||||
*/
|
||||
const Embed = ({ title, description, thumbnail }: EmbedProps): Metadata => {
|
||||
return {
|
||||
title: title,
|
||||
openGraph: {
|
||||
title: `${title}`,
|
||||
description: description,
|
||||
...(thumbnail && {
|
||||
images: [
|
||||
{
|
||||
url: thumbnail,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
...(thumbnail && {
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
export default Embed;
|
121
src/components/footer.tsx
Normal file
121
src/components/footer.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import AnimatedGridPattern from "@/components/ui/animated-grid-pattern";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import SocialLink from "@/components/social-link";
|
||||
import config from "@/config";
|
||||
import { SocialLinkType } from "@/types/config";
|
||||
|
||||
const Footer = (): ReactElement => (
|
||||
<footer className="relative mt-3 h-[19.5rem] md:h-[17rem] flex justify-center border-t border-zinc-700/75 overflow-hidden select-none">
|
||||
<div className="w-full md:max-w-[65rem]">
|
||||
<div className="px-5 py-5 md:py-10 w-full flex flex-col md:flex-row items-center justify-around md:items-start gap-7">
|
||||
{/* Top */}
|
||||
<div className="flex flex-col gap-2.5 items-center md:items-start">
|
||||
<Branding />
|
||||
|
||||
{/* Socials */}
|
||||
<div className="pl-1 flex gap-2.5 items-center z-50">
|
||||
{config.socialLinks.map((link: SocialLinkType) => (
|
||||
<SocialLink
|
||||
key={link.name}
|
||||
className="w-5 h-5"
|
||||
{...link}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex gap-7 md:gap-12 transition-all transform-gpu">
|
||||
{Object.entries(config.footer.links).map(
|
||||
([title, links]) => (
|
||||
<LinkCategory key={title} title={title}>
|
||||
{links.map((link) => (
|
||||
<FooterLink key={link.name} {...link} />
|
||||
))}
|
||||
</LinkCategory>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<p className="absolute inset-x-0 bottom-3.5 flex text-sm text-center justify-center opacity-60">
|
||||
Copyright © {new Date().getFullYear()} {config.siteName}.
|
||||
All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<AnimatedGridPattern
|
||||
className="inset-x-0 skew-y-12 [mask-image:radial-gradient(500px_circle_at_center,white,transparent)]"
|
||||
numSquares={30}
|
||||
maxOpacity={0.1}
|
||||
duration={3}
|
||||
repeatDelay={1}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
|
||||
const Branding = () => (
|
||||
<Link
|
||||
className="flex gap-3 items-center hover:opacity-75 transition-all transform-gpu"
|
||||
href={config.footer.homeUrl}
|
||||
draggable={false}
|
||||
>
|
||||
<Image
|
||||
src="/media/logo.png"
|
||||
alt={`${config.siteName} Logo`}
|
||||
width={40}
|
||||
height={40}
|
||||
draggable={false}
|
||||
/>
|
||||
<h1 className="text-xl font-bold">{config.siteName}</h1>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const LinkCategory = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const FooterLink = ({
|
||||
name,
|
||||
shortName,
|
||||
href,
|
||||
}: {
|
||||
name: string;
|
||||
shortName?: string | undefined;
|
||||
href: string;
|
||||
}): ReactElement => {
|
||||
const external: boolean = !href.startsWith("/");
|
||||
return (
|
||||
<Link
|
||||
className="flex gap-2 items-center hover:opacity-75 transition-all transform-gpu"
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
draggable={false}
|
||||
>
|
||||
<span className={cn("hidden sm:flex", !shortName && "flex")}>
|
||||
{name}
|
||||
</span>
|
||||
{shortName && <span className="flex sm:hidden">{shortName}</span>}
|
||||
{external && <ExternalLink className="w-3.5 h-3.5" />}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
32
src/components/image-viewer.tsx
Normal file
32
src/components/image-viewer.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImageViewerProps = {
|
||||
className?: string | undefined;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ImageViewer = ({
|
||||
className,
|
||||
children,
|
||||
}: ImageViewerProps): ReactElement => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
className={cn(
|
||||
"hover:scale-[1.005] transition-all transform-gpu",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-0 min-w-[20rem] max-w-screen-xl">
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default ImageViewer;
|
@ -1,42 +1,80 @@
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import { isValidElement, ReactElement, ReactNode } from "react";
|
||||
import { MDXRemote } from "remote-mdx/rsc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
Activity,
|
||||
CircleAlert,
|
||||
Lightbulb,
|
||||
MessageSquareWarning,
|
||||
OctagonAlert,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { capitalizeWords } from "@/lib/string";
|
||||
import ImageViewer from "@/components/image-viewer";
|
||||
import Image from "next/image";
|
||||
|
||||
const blockquoteStyles: { [key: string]: any } = {
|
||||
NOTE: {
|
||||
icon: <CircleAlert className="w-4 h-4" />,
|
||||
style: "text-[#1F6FEB] border-[#1F6FEB]",
|
||||
},
|
||||
TIP: {
|
||||
icon: <Lightbulb className="w-4 h-4" />,
|
||||
style: "text-[#4A8BD5] border-[#4A8BD5]",
|
||||
},
|
||||
IMPORTANT: {
|
||||
icon: <MessageSquareWarning className="w-4 h-4" />,
|
||||
style: "text-[#8957E5] border-[#8957E5]",
|
||||
},
|
||||
WARNING: {
|
||||
icon: <TriangleAlert className="w-4 h-4" />,
|
||||
style: "text-[#9E6A03] border-[#9E6A03]",
|
||||
},
|
||||
CAUTION: {
|
||||
icon: <OctagonAlert className="w-4 h-4" />,
|
||||
style: "text-[#DA3633] border-[#DA3633]",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The MDX components to style.
|
||||
*/
|
||||
const components = {
|
||||
// Headings
|
||||
h1: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={1} className="text-4xl">
|
||||
<Heading as="h1" size={1} className="text-4xl">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h2: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={2} className="text-3xl">
|
||||
<Heading as="h2" size={2} className="text-3xl">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h3: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={3} className="text-2xl">
|
||||
<Heading as="h3" size={3} className="text-2xl">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h4: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={4} className="text-xl">
|
||||
<Heading as="h4" size={4} className="text-xl">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h5: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={5} className="text-lg">
|
||||
<Heading as="h5" size={5} className="text-lg">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h6: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<Heading size={5} className="text-md">
|
||||
<Heading as="h6" size={6} className="text-md">
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
|
||||
// Text
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
@ -44,19 +82,62 @@ const components = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement => (
|
||||
<a
|
||||
className="text-minecraft-green-4 cursor-pointer hover:opacity-85 transition-all transform-gpu"
|
||||
<Link
|
||||
className="text-primary cursor-pointer hover:opacity-75 transition-all transform-gpu"
|
||||
href={href}
|
||||
draggable={false}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
p: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<p className="leading-4 text-zinc-300/80">{children}</p>
|
||||
<p className="leading-5 select-none">{children}</p>
|
||||
),
|
||||
|
||||
// Media
|
||||
img: ({ src, alt }: { src: string; alt: string }): ReactElement => (
|
||||
<ImageViewer className="m-2 my-2.5">
|
||||
<Image
|
||||
className="ring-1 ring-muted/45 rounded-2xl select-none"
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={1920}
|
||||
height={1080}
|
||||
unoptimized
|
||||
draggable={false}
|
||||
/>
|
||||
</ImageViewer>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }: { children: ReactNode }): ReactElement => (
|
||||
<ul className="px-3 list-disc list-inside">{children}</ul>
|
||||
<ul className="px-3 list-disc list-inside select-none">{children}</ul>
|
||||
),
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }: { children: ReactNode }): ReactElement => {
|
||||
const match = extractBlockQuoteText(children).match(
|
||||
/^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)]\s*(.*)/i
|
||||
);
|
||||
let style: any;
|
||||
if (!match || !(style = blockquoteStyles[match[1]])) {
|
||||
return <blockquote>{children}</blockquote>;
|
||||
}
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"my-2 pl-3 py-1.5 flex flex-col gap-2 border-l-[3px] select-none",
|
||||
style.style
|
||||
)}
|
||||
>
|
||||
<h1 className="flex gap-2 items-center">
|
||||
{style.icon}
|
||||
{capitalizeWords(match[1])}
|
||||
</h1>
|
||||
<p className="text-foreground opacity-85">{match[2]}</p>
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@ -71,6 +152,8 @@ export const CustomMDX = (props: any): ReactElement => (
|
||||
components={{
|
||||
...components,
|
||||
...(props.components || {}),
|
||||
Link,
|
||||
Activity,
|
||||
}}
|
||||
options={{
|
||||
mdxOptions: {
|
||||
@ -83,21 +166,55 @@ export const CustomMDX = (props: any): ReactElement => (
|
||||
/**
|
||||
* A heading component.
|
||||
*
|
||||
* @param as the type of heading
|
||||
* @param className the class name of the heading
|
||||
* @param size the size of the heading
|
||||
* @param children the children within the heading
|
||||
* @return the heading jsx
|
||||
*/
|
||||
const Heading = ({
|
||||
as: Component,
|
||||
className,
|
||||
size,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
className: string;
|
||||
size: number;
|
||||
children: ReactNode;
|
||||
}): ReactElement => (
|
||||
<h1 className={cn("pt-2.5 font-bold", size >= 2 && "pt-7", className)}>
|
||||
}): ReactElement => {
|
||||
const id: string | undefined =
|
||||
typeof children === "string" ? slugify(children) : undefined;
|
||||
return (
|
||||
<Component
|
||||
id={id}
|
||||
className={cn(
|
||||
"py-3 font-bold select-none",
|
||||
size >= 2 && "pt-7",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
const slugify = (text: string): string =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.trim();
|
||||
|
||||
const extractBlockQuoteText = (node: ReactNode): string => {
|
||||
if (typeof node === "string") {
|
||||
return node;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractBlockQuoteText).join("");
|
||||
}
|
||||
if (isValidElement(node)) {
|
||||
return extractBlockQuoteText(node.props.children);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
@ -1,71 +0,0 @@
|
||||
import { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const Navbar = (): ReactElement => (
|
||||
<nav
|
||||
className={cn(
|
||||
"py-4 flex justify-between items-center",
|
||||
"after:absolute after:inset-x-0 after:top-[4.2rem] after:h-0.5 after:bg-muted/55"
|
||||
)}
|
||||
>
|
||||
{/* Branding */}
|
||||
<Link
|
||||
className="flex gap-1 items-end hover:opacity-75 transition-all transform-gpu select-none"
|
||||
href="/"
|
||||
draggable={false}
|
||||
>
|
||||
<h1 className="text-lg font-semibold">docs.</h1>
|
||||
<Image
|
||||
src="/media/logo.png"
|
||||
alt="Pulse App Logo"
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex gap-7 items-center">
|
||||
{/* Search */}
|
||||
<Input
|
||||
className="hidden xs:flex rounded-lg"
|
||||
placeholder="Search the docs..."
|
||||
disabled
|
||||
/>
|
||||
|
||||
{/* Social */}
|
||||
<div className="flex gap-5 items-center">
|
||||
<SocialLink
|
||||
name="GitHub"
|
||||
link="https://github.com/PulseAppCC"
|
||||
icon="/media/github.svg"
|
||||
/>
|
||||
<SocialLink
|
||||
name="Discord"
|
||||
link="https://discord.pulseapp.cc"
|
||||
icon="/media/discord.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
const SocialLink = ({
|
||||
name,
|
||||
link,
|
||||
icon,
|
||||
}: {
|
||||
name: string;
|
||||
link: string;
|
||||
icon: string;
|
||||
}): ReactElement => (
|
||||
<div className="relative w-6 h-6 hover:opacity-75 transition-all transform-gpu select-none">
|
||||
<Link href={link} target="_blank" draggable={false}>
|
||||
<Image src={icon} alt={`${name} Logo`} fill draggable={false} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Navbar;
|
55
src/components/navbar/navbar.tsx
Normal file
55
src/components/navbar/navbar.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import QuickSearchDialog from "@/components/navbar/search-dialog";
|
||||
import Sidebar from "@/components/sidebar/sidebar";
|
||||
import SocialLink from "@/components/social-link";
|
||||
import config from "@/config";
|
||||
import { SocialLinkType } from "@/types/config";
|
||||
|
||||
const Navbar = ({ pages }: { pages: DocsContentMetadata[] }): ReactElement => (
|
||||
<nav className="fixed left-0 inset-x-0 bg-white/95 dark:bg-white/[0.007] backdrop-saturate-100 backdrop-blur-xl border-b z-50">
|
||||
<div className="px-3 md:px-7 max-w-screen-2xl mx-auto py-4 flex justify-between items-center transition-all transform-gpu">
|
||||
{/* Branding */}
|
||||
<Link
|
||||
className="flex gap-1 items-end hover:opacity-75 transition-all transform-gpu select-none"
|
||||
href="/"
|
||||
draggable={false}
|
||||
>
|
||||
<h1 className="text-lg font-semibold">docs.</h1>
|
||||
<Image
|
||||
src="/media/logo.png"
|
||||
alt={`${config.siteName} Logo`}
|
||||
width={36}
|
||||
height={36}
|
||||
draggable={false}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex gap-5 sm:gap-7 items-center transition-all transform-gpu">
|
||||
{/* Search */}
|
||||
<div className="hidden xs:flex">
|
||||
<QuickSearchDialog pages={pages} bindKeybind />
|
||||
</div>
|
||||
|
||||
{/* Social */}
|
||||
<div className="flex gap-5 items-center">
|
||||
{config.socialLinks
|
||||
.filter((link: SocialLinkType) => link.navbar)
|
||||
.map((link: SocialLinkType) => (
|
||||
<SocialLink key={link.name} {...link} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<div className="flex xs:hidden">
|
||||
<Sidebar pages={pages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
export default Navbar;
|
117
src/components/navbar/search-dialog.tsx
Normal file
117
src/components/navbar/search-dialog.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The dialog for quickly searching the docs.
|
||||
*
|
||||
* @return the content jsx
|
||||
*/
|
||||
const QuickSearchDialog = ({
|
||||
pages,
|
||||
bindKeybind = false,
|
||||
}: {
|
||||
pages: DocsContentMetadata[];
|
||||
bindKeybind?: boolean;
|
||||
}): ReactElement => {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const router: AppRouterInstance = useRouter();
|
||||
|
||||
// Listen for CTRL + K keybinds to open this dialog
|
||||
useEffect(() => {
|
||||
if (!bindKeybind) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setOpen((open: boolean) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [bindKeybind]);
|
||||
|
||||
// Render the contents
|
||||
return (
|
||||
<>
|
||||
{/* Button to open */}
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-85 transition-all transform-gpu select-none"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="absolute top-[0.55rem] left-3 z-10">
|
||||
<Search className="w-[1.15rem] h-[1.15rem]" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="pl-10 rounded-lg cursor-pointer"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="Search the docs..."
|
||||
readOnly
|
||||
/>
|
||||
|
||||
<div className="absolute top-1.5 right-3">
|
||||
<kbd className="h-5 px-1.5 inline-flex gap-1 items-center bg-muted font-medium text-muted-foreground rounded select-none pointer-events-none">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialog */}
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
{/* Input */}
|
||||
<CommandInput
|
||||
className="select-none"
|
||||
placeholder="Start typing to get started..."
|
||||
/>
|
||||
|
||||
{/* Results */}
|
||||
<CommandList className="select-none">
|
||||
<CommandEmpty className="text-center text-red-500">
|
||||
No results were found.
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Results">
|
||||
{pages?.map(
|
||||
(
|
||||
result: DocsContentMetadata,
|
||||
index: number
|
||||
): ReactElement => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
className="flex flex-col gap-1 items-start cursor-pointer"
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
router.push(`/${result.slug}`);
|
||||
}}
|
||||
>
|
||||
<h1 className="text-primary font-bold">
|
||||
{result.title}
|
||||
</h1>
|
||||
<p className="opacity-60">
|
||||
{result.summary}
|
||||
</p>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default QuickSearchDialog;
|
197
src/components/on-this-page.tsx
Normal file
197
src/components/on-this-page.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { truncateText } from "@/lib/string";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlignLeftIcon, ArrowUpFromDot, MoveRight } from "lucide-react";
|
||||
import config from "@/config";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
type Header = {
|
||||
id: string;
|
||||
text: string;
|
||||
level: number;
|
||||
};
|
||||
|
||||
const OnThisPage = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [headers, setHeaders] = useState<Header[]>([]);
|
||||
const [activeHeader, setActiveHeader] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const observerRef = useRef<IntersectionObserver | undefined>(undefined);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inView = useInView(ref);
|
||||
|
||||
useEffect(() => {
|
||||
// Regular expression to match markdown headers
|
||||
const headerRegex: RegExp = /^(#{1,6})\s+(.*)$/gm;
|
||||
const extractedHeaders: Header[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = headerRegex.exec(page.content)) !== null) {
|
||||
const level: number = match[1].length; // The number of # symbols determines the header level
|
||||
const text: string = match[2].trim().replace(/<[^>]*>/g, "");
|
||||
const id: string = text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w-]/g, "");
|
||||
|
||||
extractedHeaders.push({ id, text, level });
|
||||
}
|
||||
|
||||
setHeaders(extractedHeaders);
|
||||
setLoading(false);
|
||||
}, [page.content]);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup existing observer
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveHeader(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "0px 0px -80% 0px", threshold: 0.1 }
|
||||
);
|
||||
observerRef.current = observer;
|
||||
|
||||
// Observe all header elements
|
||||
headers.forEach((header: Header) => {
|
||||
const element: HTMLElement | null = document.getElementById(
|
||||
header.id
|
||||
);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [headers]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className="sticky top-[7.5rem] w-44 max-h-[calc(100vh-3.5rem)] flex flex-col gap-2 text-sm select-none"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: inView ? 1 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<AlignLeftIcon className="w-5 h-5" />
|
||||
<h1>On This Page</h1>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<ul className="relative">
|
||||
{loading ? (
|
||||
<Skeleton className="w-full h-5 bg-accent rounded-lg" />
|
||||
) : headers.length === 0 ? (
|
||||
<span className="opacity-75">Nothing ):</span>
|
||||
) : (
|
||||
headers.map((header: Header) => (
|
||||
<li
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"hover:opacity-80 transition-all transform-gpu relative",
|
||||
activeHeader === header.id
|
||||
? "font-semibold text-primary"
|
||||
: "opacity-65"
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: `${(header.level - 1) * 16}px`,
|
||||
}}
|
||||
>
|
||||
{/* Indentation */}
|
||||
{header.level > 1 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 border-l border-accent"
|
||||
style={{
|
||||
left: `${(header.level - 2) * 16 + 4}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<Link
|
||||
href={`#${header.id}`}
|
||||
draggable={false}
|
||||
className="block py-1"
|
||||
>
|
||||
{truncateText(header.text, 20)}
|
||||
</Link>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Footer */}
|
||||
<div>
|
||||
<Separator className="mt-1 mb-3.5 dark:bg-separator-gradient" />
|
||||
<Footer page={page} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({ page }: { page: DocsContentMetadata }): ReactElement => {
|
||||
const [hasScrolled, setHasScrolled] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setHasScrolled(window.scrollY > 400);
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="flex flex-col opacity-75">
|
||||
{/* Edit on Git */}
|
||||
<Link
|
||||
className="flex gap-1.5 items-center text-xs hover:opacity-75 transition-all transform-gpu group"
|
||||
href={config.contentEditUrl
|
||||
.replace("{slug}", page.slug as string)
|
||||
.replace("{ext}", page.extension as string)}
|
||||
target="_blank"
|
||||
draggable={false}
|
||||
>
|
||||
<span>Edit this page on GitHub</span>
|
||||
<MoveRight className="w-4 h-4 group-hover:translate-x-px transition-all transform-gpu" />
|
||||
</Link>
|
||||
|
||||
{/* Scroll to Top */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity transform-gpu",
|
||||
hasScrolled
|
||||
? "opacity-100"
|
||||
: "opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="p-0 justify-start flex gap-1.5 items-center text-xs hover:bg-transparent hover:opacity-75 transition-all transform-gpu group"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
<span>Scroll to Top</span>
|
||||
<ArrowUpFromDot className="w-4 h-4 group-hover:-translate-y-px transition-all transform-gpu" />
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnThisPage;
|
@ -8,10 +8,10 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
const SidebarLinks = ({
|
||||
pages,
|
||||
@ -20,11 +20,11 @@ const SidebarLinks = ({
|
||||
}): ReactElement => {
|
||||
const tree = useMemo(() => buildTree(pages), [pages]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.values(tree).map((node: TreeNode) => (
|
||||
<CategoryItem key={node.slug} node={node} />
|
||||
<CategoryItem key={node.slug} pages={pages} node={node} />
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -36,29 +36,33 @@ type TreeNode = {
|
||||
};
|
||||
|
||||
const CategoryItem = ({
|
||||
pages,
|
||||
node,
|
||||
depth = 0,
|
||||
isLast = true,
|
||||
}: {
|
||||
pages: DocsContentMetadata[];
|
||||
node: TreeNode;
|
||||
depth?: number;
|
||||
isLast?: boolean;
|
||||
}) => {
|
||||
const path = usePathname();
|
||||
const path = decodeURIComponent(usePathname());
|
||||
const active =
|
||||
(path === "/" && node.slug === "home") || path === `/${node.slug}`;
|
||||
(path === "/" && node.slug === pages[0].slug) ||
|
||||
path === `/${node.slug}`;
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const hasChildren = Object.keys(node.children).length > 0;
|
||||
|
||||
return (
|
||||
<div className={`relative ${depth > 0 ? "ml-4" : ""}`}>
|
||||
<div className={cn(`relative select-none`, depth > 0 && "ml-3")}>
|
||||
{/* Indentation */}
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className={`absolute left-0 top-1 bottom-0 border-l-2 border-muted`}
|
||||
style={{
|
||||
height: isLast ? "30px" : "100%",
|
||||
}}
|
||||
className={cn(
|
||||
"absolute left-0 bottom-0 border-l border-accent",
|
||||
isLast ? "h-[32px]" : "h-[100%]",
|
||||
active && "border-primary"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -70,21 +74,25 @@ const CategoryItem = ({
|
||||
draggable={false}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
`relative ${depth > 0 ? "pl-4" : ""} w-full justify-between`,
|
||||
`relative w-full px-1.5 h-8 justify-between hover:bg-accent/35 hover:opacity-90`,
|
||||
node.isFolder
|
||||
? "mb-0.5 text-sm font-semibold"
|
||||
: "lg:text-base",
|
||||
depth > 0 && "pl-4",
|
||||
active &&
|
||||
"bg-primary/15 hover:bg-primary/20 text-primary/95 hover:text-primary"
|
||||
"text-primary/95 font-semibold hover:text-primary"
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
{node.title}
|
||||
{hasChildren && (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
animate={{ rotate: isOpen ? 90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
@ -101,11 +109,10 @@ const CategoryItem = ({
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
open: { opacity: 1, height: "auto", y: 0 },
|
||||
open: { opacity: 1, height: "auto" },
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
y: -20,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
@ -117,6 +124,7 @@ const CategoryItem = ({
|
||||
(child, index, array) => (
|
||||
<CategoryItem
|
||||
key={child.slug}
|
||||
pages={pages}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
isLast={index === array.length - 1}
|
||||
@ -135,7 +143,7 @@ const CategoryItem = ({
|
||||
const buildTree = (pages: DocsContentMetadata[]): Record<string, TreeNode> => {
|
||||
const tree: Record<string, TreeNode> = {};
|
||||
|
||||
pages.forEach((page) => {
|
||||
pages.forEach((page: DocsContentMetadata) => {
|
||||
const parts: string[] | undefined = page.slug?.split("/");
|
||||
let currentLevel = tree;
|
||||
|
||||
|
@ -1,24 +1,52 @@
|
||||
import { ReactElement } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getDocsContent } from "@/lib/mdx";
|
||||
import SidebarLinks from "@/components/sidebar/sidebar-links";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import QuickSearchDialog from "@/components/navbar/search-dialog";
|
||||
import { AlignRightIcon } from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ThemeSwitcher from "@/components/theme-switcher";
|
||||
|
||||
const Sidebar = (): ReactElement => {
|
||||
const pages: DocsContentMetadata[] = getDocsContent();
|
||||
return (
|
||||
<div className="w-52 py-3 flex flex-col justify-between">
|
||||
{/* Links */}
|
||||
const Sidebar = ({ pages }: { pages: DocsContentMetadata[] }): ReactElement => (
|
||||
<>
|
||||
{/* Mobile */}
|
||||
<div className="xs:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger className="flex items-center">
|
||||
<AlignRightIcon className="w-6 h-6" />
|
||||
</SheetTrigger>
|
||||
<SheetContent className="h-full px-5 pt-11" side="right">
|
||||
<SidebarContent pages={pages} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden xs:flex sticky top-[4.5rem] max-h-[calc(100vh-4.5rem)] min-w-32 w-40 lg:w-52 py-5 pb-3 flex-col justify-between transition-all transform-gpu overflow-y-auto">
|
||||
<SidebarContent pages={pages} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const SidebarContent = ({
|
||||
pages,
|
||||
}: {
|
||||
pages: DocsContentMetadata[];
|
||||
}): ReactElement => (
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{/* Top */}
|
||||
<div className="flex flex-col">
|
||||
<div className="xs:hidden pb-3">
|
||||
<QuickSearchDialog pages={pages} />
|
||||
</div>
|
||||
<SidebarLinks pages={pages} />
|
||||
</div>
|
||||
|
||||
{/* Theme Switcher */}
|
||||
<div className="flex flex-col">
|
||||
<Separator className="mb-3" />
|
||||
<span>Theme Switcher</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<Separator className="mb-3 dark:bg-separator-gradient" />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
50
src/components/simple-tooltip.tsx
Normal file
50
src/components/simple-tooltip.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { SIDE_OPTIONS } from "@radix-ui/react-popper";
|
||||
|
||||
/**
|
||||
* The props for a simple tooltip.
|
||||
*/
|
||||
type SimpleTooltipProps = {
|
||||
/**
|
||||
* The content to display in the tooltip.
|
||||
*/
|
||||
content: string | ReactElement;
|
||||
|
||||
/**
|
||||
* The side to display the tooltip on.
|
||||
*/
|
||||
side?: (typeof SIDE_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* The children to render in this tooltip.
|
||||
*/
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A simple tooltip, this is wrapping the
|
||||
* shadcn tooltip to make it easier to use.
|
||||
*
|
||||
* @return the tooltip jsx
|
||||
*/
|
||||
const SimpleTooltip = ({
|
||||
content,
|
||||
side,
|
||||
children,
|
||||
}: SimpleTooltipProps): ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="bg-accent text-accent-foreground"
|
||||
side={side}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
export default SimpleTooltip;
|
46
src/components/social-link.tsx
Normal file
46
src/components/social-link.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import SimpleTooltip from "@/components/simple-tooltip";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { icons } from "lucide-react";
|
||||
import { SocialLinkType } from "@/types/config";
|
||||
|
||||
type SocialLinkProps = SocialLinkType & {
|
||||
className?: string | undefined;
|
||||
};
|
||||
|
||||
const SocialLink = ({
|
||||
className,
|
||||
name,
|
||||
tooltip,
|
||||
logo,
|
||||
href,
|
||||
}: SocialLinkProps) => (
|
||||
<SimpleTooltip content={tooltip}>
|
||||
<Link
|
||||
className={cn(
|
||||
"w-6 h-6 hover:opacity-75 transition-all transform-gpu select-none",
|
||||
className
|
||||
)}
|
||||
href={href}
|
||||
target="_blank"
|
||||
draggable={false}
|
||||
>
|
||||
{logo.startsWith("./") ? (
|
||||
<Image
|
||||
src={`/media/${logo.substring(2)}`}
|
||||
alt={`${name}'s Logo`}
|
||||
fill
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
className="opacity-95 w-full h-full"
|
||||
name={logo as keyof typeof icons}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
export default SocialLink;
|
57
src/components/theme-switcher.tsx
Normal file
57
src/components/theme-switcher.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { UseThemeProps } from "next-themes/dist/types";
|
||||
import { Monitor, MoonStar, Sun } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import SimpleTooltip from "@/components/simple-tooltip";
|
||||
import { capitalizeWords } from "@/lib/string";
|
||||
|
||||
const themes = {
|
||||
system: <Monitor className="w-4 h-4" />,
|
||||
dark: <MoonStar className="w-4 h-4" />,
|
||||
light: <Sun className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* The theme switcher component.
|
||||
*
|
||||
* @return the switcher jsx
|
||||
*/
|
||||
const ThemeSwitcher = (): ReactElement => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme: activeTheme, setTheme }: UseThemeProps = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-fit p-1 flex gap-1.5 bg-gray-600/5 dark:bg-black/30 ring-1 light:ring-inset ring-gray-600/5 dark:ring-white/5 rounded-full">
|
||||
{Object.entries(themes).map(([theme, icon]) => {
|
||||
const active: boolean = mounted && theme === activeTheme;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
key={theme}
|
||||
content={`${capitalizeWords(theme)} Theme`}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"p-1 h-6 opacity-80 rounded-full",
|
||||
active &&
|
||||
"ring-1 bg-white dark:bg-zinc-900 ring-gray-900/10 dark:ring-white/15 hover:bg-white hover:dark:bg-zinc-900 opacity-100"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => setTheme(theme)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ThemeSwitcher;
|
153
src/components/ui/animated-grid-pattern.tsx
Normal file
153
src/components/ui/animated-grid-pattern.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GridPatternProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
strokeDasharray?: any;
|
||||
numSquares?: number;
|
||||
className?: string;
|
||||
maxOpacity?: number;
|
||||
duration?: number;
|
||||
repeatDelay?: number;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = 0,
|
||||
numSquares = 50,
|
||||
className,
|
||||
maxOpacity = 0.5,
|
||||
duration = 4,
|
||||
repeatDelay = 0.5,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
const containerRef = useRef(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [squares, setSquares] = useState(() => generateSquares(numSquares));
|
||||
|
||||
function getPos() {
|
||||
return [
|
||||
Math.floor((Math.random() * dimensions.width) / width),
|
||||
Math.floor((Math.random() * dimensions.height) / height),
|
||||
];
|
||||
}
|
||||
|
||||
// Adjust the generateSquares function to return objects with an id, x, and y
|
||||
function generateSquares(count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
pos: getPos(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Function to update a single square's position
|
||||
const updateSquarePosition = (id: number) => {
|
||||
setSquares((currentSquares) =>
|
||||
currentSquares.map((sq) =>
|
||||
sq.id === id
|
||||
? {
|
||||
...sq,
|
||||
pos: getPos(),
|
||||
}
|
||||
: sq
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Update squares to animate in
|
||||
useEffect(() => {
|
||||
if (dimensions.width && dimensions.height) {
|
||||
setSquares(generateSquares(numSquares));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dimensions, numSquares]);
|
||||
|
||||
// Resize observer to update container dimensions
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setDimensions({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
let current = undefined;
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
current = containerRef.current;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (current) {
|
||||
resizeObserver.unobserve(current);
|
||||
}
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(({ pos: [x, y], id }, index) => (
|
||||
<motion.rect
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: maxOpacity }}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: 1,
|
||||
delay: index * 0.1,
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
onAnimationComplete={() => updateSquarePosition(id)}
|
||||
key={`${x}-${y}-${index}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridPattern;
|
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
@ -32,26 +32,26 @@ const buttonVariants = cva(
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
158
src/components/ui/command.tsx
Normal file
158
src/components/ui/command.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
10
src/components/ui/icon.tsx
Normal file
10
src/components/ui/icon.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { icons, LucideProps } from "lucide-react";
|
||||
|
||||
interface IconProps extends LucideProps {
|
||||
name: keyof typeof icons;
|
||||
}
|
||||
|
||||
export default function Icon({ name, color, size, ...props }: IconProps) {
|
||||
const LucideIcon = icons[name];
|
||||
return <LucideIcon color={color} size={size} {...props} />;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@ -17,9 +17,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
@ -19,13 +19,15 @@ const Separator = React.forwardRef<
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
orientation === "horizontal"
|
||||
? "h-[1px] w-full"
|
||||
: "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
138
src/components/ui/sheet.tsx
Normal file
138
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-[450ms] data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-105 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
122
src/lib/mdx.ts
122
src/lib/mdx.ts
@ -1,6 +1,11 @@
|
||||
import * as fs from "node:fs";
|
||||
import { Stats } from "node:fs";
|
||||
import path from "node:path";
|
||||
import config from "@/config";
|
||||
import simpleGit from "simple-git";
|
||||
import os from "node:os";
|
||||
import { cache } from "react";
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* The regex to match for metadata.
|
||||
@ -8,20 +13,91 @@ import path from "node:path";
|
||||
const METADATA_REGEX: RegExp = /---\s*([\s\S]*?)\s*---/;
|
||||
|
||||
/**
|
||||
* The directory docs are stored in.
|
||||
* Check if the DOCS_DIR is a Git URL.
|
||||
*/
|
||||
const DOCS_DIR: string = path.join(process.cwd(), "docs");
|
||||
const isGitUrl = (url: string): boolean => {
|
||||
return /^https?:\/\/|git@|\.git$/.test(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the content to
|
||||
* display in the docs.
|
||||
* The directory docs are stored in.
|
||||
*/
|
||||
export const getDocsContent = (): DocsContentMetadata[] => {
|
||||
const content: DocsContentMetadata[] = [];
|
||||
for (const directory of getRecursiveDirectories(DOCS_DIR)) {
|
||||
content.push(...getMetadata<DocsContentMetadata>(DOCS_DIR, directory));
|
||||
const DOCS_DIR: string = isGitUrl(config.contentSource)
|
||||
? config.contentSource
|
||||
: path.join(config.contentSource.replace("{process}", process.cwd()));
|
||||
|
||||
const LAST_UPDATE_FILE = path.join(
|
||||
os.tmpdir(),
|
||||
"docs_cache",
|
||||
"last_update.json"
|
||||
);
|
||||
const CACHE_DURATION_MS: number = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
/**
|
||||
* Clone the Git repository if DOCS_DIR is a URL, else use the local directory.
|
||||
* If it's a Git URL, clone it to a cache directory and reuse it.
|
||||
*/
|
||||
const getDocsDirectory = cache(async (): Promise<string> => {
|
||||
if (isGitUrl(DOCS_DIR)) {
|
||||
const repoHash: string = Buffer.from(DOCS_DIR).toString("base64"); // Create a unique identifier based on the repo URL
|
||||
const cacheDir: string = path.join(os.tmpdir(), "docs_cache", repoHash);
|
||||
|
||||
// Pull the latest changes from the repo if we don't have it
|
||||
if (!fs.existsSync(cacheDir) || fs.readdirSync(cacheDir).length < 1) {
|
||||
console.log("Cloning initial docs content from Git...");
|
||||
try {
|
||||
await simpleGit().clone(DOCS_DIR, cacheDir, { "--depth": 1 });
|
||||
storeUpdatedRepoTime();
|
||||
} catch (error) {
|
||||
// Simply ignore this error. When cloning the repo for
|
||||
// the first time, it'll sometimes error saying the dir
|
||||
// is already created.
|
||||
}
|
||||
return content;
|
||||
} else if (shouldUpdateRepo()) {
|
||||
// Pull the latest changes from Git
|
||||
console.log("Pulling docs content from Git...");
|
||||
await simpleGit(cacheDir)
|
||||
.reset(["--hard"]) // Reset any local changes
|
||||
.pull(); // Pull latest changes
|
||||
storeUpdatedRepoTime();
|
||||
}
|
||||
return cacheDir;
|
||||
}
|
||||
return DOCS_DIR;
|
||||
});
|
||||
|
||||
const shouldUpdateRepo = (): boolean => {
|
||||
if (!fs.existsSync(LAST_UPDATE_FILE)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
Date.now() -
|
||||
JSON.parse(fs.readFileSync(LAST_UPDATE_FILE, "utf-8")).lastUpdate >
|
||||
CACHE_DURATION_MS
|
||||
);
|
||||
};
|
||||
|
||||
const storeUpdatedRepoTime = () =>
|
||||
fs.writeFileSync(
|
||||
LAST_UPDATE_FILE,
|
||||
JSON.stringify({ lastUpdate: Date.now() }),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the content to display in the docs.
|
||||
*/
|
||||
export const getDocsContent = async (): Promise<DocsContentMetadata[]> => {
|
||||
const docsDir: string = await getDocsDirectory();
|
||||
const content: DocsContentMetadata[] = [];
|
||||
for (const directory of getRecursiveDirectories(docsDir)) {
|
||||
content.push(...getMetadata<DocsContentMetadata>(docsDir, directory));
|
||||
}
|
||||
return content.sort((a: DocsContentMetadata, b: DocsContentMetadata) => {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -41,18 +117,27 @@ const getMetadata = <T extends MDXMetadata>(
|
||||
const extension: string = path.extname(file); // The file extension
|
||||
return extension === ".md" || extension === ".mdx";
|
||||
}); // Read the MDX files
|
||||
return files.map((file: string): T => {
|
||||
const metadata: T[] = [];
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const file: string = files[i];
|
||||
const filePath: string = path.join(directory, file); // The path of the file
|
||||
return {
|
||||
const fileMetadata: T | undefined = parseMetadata<T>(
|
||||
fs.readFileSync(filePath, "utf-8")
|
||||
);
|
||||
if (!fileMetadata) {
|
||||
continue;
|
||||
}
|
||||
metadata.push({
|
||||
slug: filePath
|
||||
.replace(parent, "")
|
||||
.replace(/\\/g, "/") // Normalize the path
|
||||
.replace(/\.mdx?$/, "")
|
||||
.substring(1),
|
||||
extension: path.extname(file),
|
||||
...parseMetadata<T>(fs.readFileSync(filePath, "utf-8")),
|
||||
}; // Map each file to its metadata
|
||||
...fileMetadata,
|
||||
});
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -63,8 +148,15 @@ const getMetadata = <T extends MDXMetadata>(
|
||||
* @returns the metadata and content
|
||||
* @template T the type of metadata
|
||||
*/
|
||||
const parseMetadata = <T extends MDXMetadata>(content: string): T => {
|
||||
const metadataBlock: string = METADATA_REGEX.exec(content)![1]; // Get the block of metadata
|
||||
const parseMetadata = <T extends MDXMetadata>(
|
||||
content: string
|
||||
): T | undefined => {
|
||||
const extracted = METADATA_REGEX.exec(content);
|
||||
const metadataBlock: string | undefined =
|
||||
extracted && extracted.length > 1 ? extracted[1] : undefined; // Get the block of metadata
|
||||
if (!metadataBlock) {
|
||||
return undefined;
|
||||
}
|
||||
content = content.replace(METADATA_REGEX, "").trim(); // Remove the metadata block from the content
|
||||
const metadata: Partial<{
|
||||
[key: string]: string;
|
||||
|
18
src/lib/string.ts
Normal file
18
src/lib/string.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Capitalize the first letter of
|
||||
* each word in the given string.
|
||||
*
|
||||
* @param str the string to capitalize
|
||||
* @return the capitalized string
|
||||
*/
|
||||
export const capitalizeWords = (str: string | undefined): string | undefined =>
|
||||
str &&
|
||||
str.toLowerCase().replace(/\b\w/g, (char: string) => char.toUpperCase());
|
||||
|
||||
export const truncateText = (
|
||||
text: string | undefined,
|
||||
maxLength: number
|
||||
): string | undefined =>
|
||||
text && text.length > maxLength
|
||||
? text.slice(0, maxLength - 3).trim() + "..."
|
||||
: text;
|
28
src/middleware.ts
Normal file
28
src/middleware.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest): NextResponse {
|
||||
const before: number = Date.now();
|
||||
const response: NextResponse = NextResponse.next();
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const ip: string | null =
|
||||
request.headers.get("CF-Connecting-IP") ||
|
||||
request.headers.get("X-Forwarded-For");
|
||||
console.log(
|
||||
`${ip} | ${request.method} ${request.nextUrl.pathname} ${response.status} in ${(Date.now() - before).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
@ -63,6 +63,10 @@ const config: Config = {
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"separator-gradient":
|
||||
"linear-gradient(90deg, #161619, hsl(var(--muted)), #161619)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
|
@ -24,6 +24,15 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/config": [
|
||||
"./src/app/config.ts"
|
||||
],
|
||||
"@/types/*": [
|
||||
"./src/app/types/*"
|
||||
],
|
||||
"@/configJson": [
|
||||
"./config.json"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
|
Reference in New Issue
Block a user