feat (v1.0.0): initial refactor and redesign

This commit is contained in:
Aidan 2025-10-09 04:12:05 -04:00
parent 3058aa1ab4
commit fe9b50b30e
134 changed files with 17792 additions and 3670 deletions

5
.gitignore vendored
View file

@ -48,4 +48,7 @@ bun.lockb
.vscode/
# webstorm
.idea/
.idea/
# docs
public/docs

View file

@ -17,10 +17,16 @@ Just create a `.env` file with the below variables, run `docker compose -d --bui
## Environment Variables
| Variable | Required? | Description |
|----------------------|-----------|-------------------------------------------------------------------------------------|
| `LISTENBRAINZ_TOKEN` | No | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
| `LASTFM_API_KEY` | Yes | Get this from your Last.fm [API account](https://www.last.fm/api/account/create) |
| Variable | Required? | Description |
|------------------------|-----------|----------------------------------------------------------------------------------------------------------|
| `LASTFM_API_KEY` | Yes | Get this from your Last.fm [API account](https://www.last.fm/api/account/create) |
| `LISTENBRAINZ_TOKEN` | No | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
| `GITHUB_PROJECTS_USER` | No | GitHub username to display in the footer projects list (defaults to `ihatenodejs`) |
| `GITHUB_USERNAME` | No | Fallback GitHub username if `GITHUB_PROJECTS_USER` is not set |
| `GITHUB_PROJECTS_PAT` | No | GitHub personal access token used to increase API limits for the footer projects list |
| `GITHUB_PAT` | No | Fallback GitHub personal access token if `GITHUB_PROJECTS_PAT` is not set |
| `PORT` | No | Server port (defaults to `3000`) |
| `NODE_ENV` | No | Environment mode (`production` or `development`, automatically set by deployment platform) |
## MusicBrainz

View file

@ -1,232 +1,159 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import Link from '@/components/objects/Link'
import Button from '@/components/objects/Button'
import FeaturedRepos from '@/components/widgets/FeaturedRepos'
import Image from 'next/image'
import { useState } from 'react'
import GitHubStatsImage from '@/components/widgets/GitHubStatsImage'
import PageHeader from '@/components/objects/PageHeader'
import { Card } from '@/components/ui/Card'
import { CardGrid } from '@/components/ui/CardGrid'
import { SiGoogle } from 'react-icons/si'
import { TbUserHeart } from 'react-icons/tb'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { getFeaturedReposWithMetrics } from '@/lib/github'
export default function About() {
const { t } = useTranslation()
const [imageError, setImageError] = useState(false)
const mainStrings: string[][] = [
t('about.projects', { returnObjects: true }) as string[],
t('about.hobbies', { returnObjects: true }) as string[],
t('about.devices', { returnObjects: true }) as string[],
t('about.contributions', { returnObjects: true }) as string[],
t('about.featuredProjects', { returnObjects: true }) as string[]
]
const getGitHubUsername = () => {
return process.env.GITHUB_PROJECTS_USER ?? process.env.GITHUB_USERNAME ?? 'ihatenodejs'
}
const mainSections = [
t('about.sections.projects'),
t('about.sections.hobbies'),
t('about.sections.devices'),
t('about.sections.contributions'),
t('about.sections.featuredProjects')
]
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full">
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<TbUserHeart size={60} />
interface ContentSection {
title: string
content: React.ReactElement
}
export default async function About() {
const featuredProjects = await getFeaturedReposWithMetrics()
const githubUsername = getGitHubUsername()
const sections: ContentSection[] = [
{
title: "Projects",
content: (
<>
<p className="text-gray-300 leading-relaxed mt-2">
I have worked on countless projects over the past five years, for the most part. I started learning to code with Python when I was seven and my interest has only evolved from there. I got into web development due to my uncle, who taught my how to write my first lines of HTML.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
Recently, I have been involved in developing several projects, especially with TypeScript, which is my new favorite language as of a year ago. My biggest project currently is <Link href="https://p0ntus.com/">p0ntus</Link>, a free service provider for privacy-focused individuals.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
You will also come to find that I have an addiction to Docker! Almost every project I&apos;ve made is able to be run in Docker.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
Me and my developer friends operate an organization called <Link href="https://github.com/abocn">ABOCN</Link>, where we primarily maintain a Telegram bot called <Link href="https://github.com/abocn/TelegramBot">Kowalski</Link>. You can find it on Telegram as <Link href="https://t.me/KowalskiNodeBot">@KowalskiNodeBot</Link>.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I have learned system administration from the past three years of learning Linux for practical use and fun. I currently operate four servers running in the cloud, ran out of Canada, Germany, and the United States.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I own a channel called <Link href="https://t.me/PontusHub">PontusHub</Link> on Telegram, where I post updates about my projects, along with commentary and info about my projects related to the Android rooting community.
</p>
</>
)
},
{
title: "Hobbies",
content: (
<>
<p className="text-gray-300 leading-relaxed mt-2">
When I&apos;m not programming, I can typically be found distro hopping or flashing a new ROM to <Link href="/device/cheetah">my phone</Link>. I also spend a lot of time spreading Next.js and TypeScript propaganda to JavaScript developers.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I consider maintaining my devices as a hobby as well, as I devote a lot of time to it. I genuinely enjoy installing Arch, Gentoo, and NixOS frequently, and flashing new ROMs to the phones I own.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I am frequently active on <Link href="https://git.p0ntus.com/">my Forgejo server</Link> and GitHub, and aim to make daily contributions. I am a big fan of open source software and public domain software (which most of my repos are licensed under). In fact, the website you&apos;re currently on is free and open source. It&apos;s even under the public domain!
</p>
<p className="text-gray-300 leading-relaxed mt-2">
When I touch grass, I prefer to walk on the streets, especially in Boston, Massachusetts. I also used to swim competitively, though it has turned into to a casual hobby over time.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
Editing Wikipedia has also been a good pastime for me, and I have been editing for a year and a half now. As of writing, I have made 6.1k edits to the English Wikipedia. I am also an <Link href="https://en.wikipedia.org/wiki/Wikipedia:WikiProject_Articles_for_creation">AfC</Link> reviewer, new page reviewer, and rollbacker. You can find me on Wikipedia as <Link href="https://en.wikipedia.org/wiki/User:OnlyNano">OnlyNano</Link>.
</p>
</>
)
},
{
title: "Devices",
content: (
<>
<h3 className="text-xl font-semibold mb-2 text-gray-200">Mobile Devices</h3>
<p className="text-gray-300 leading-relaxed mt-2">
I use a Google Pixel 9 Pro XL (komodo) as my daily driver. It runs <Link href="https://developer.android.com/about/versions/16/get">Android 16</Link> and is proudly rooted with <Link href="https://github.com/KernelSU-Next/KernelSU-Next">KernelSU-Next</Link>.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
My previous phone, the Google Pixel 7 Pro (cheetah), is still in use as my secondary WiFi-only device. It runs <Link href="https://developer.android.com/about/versions/16/get">Android 16</Link> and is proudly rooted with <Link href="https://github.com/KernelSU-Next/KernelSU-Next">KernelSU-Next</Link>.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I also have a Google Pixel 3a XL (bonito) which I use as a tertiary device. It runs <Link href="https://wiki.lineageos.org/devices/bonito/">LineageOS 22.2</Link> and is rooted with Magisk.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
<Button href="/device/komodo" icon={<SiGoogle />}>
Pixel 9 Pro XL
</Button>
<Button href="/device/cheetah" icon={<SiGoogle />}>
Pixel 7 Pro
</Button>
<Button href="/device/bonito" icon={<SiGoogle />}>
Pixel 3a XL
</Button>
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">{t('about.title')}</h1>
</div>
<h3 className="text-xl font-semibold mb-2 text-gray-200 mt-4">Laptops</h3>
<p className="text-gray-300 leading-relaxed mt-2">
I currently daily-drive with a 16-inch MacBook Pro with an M4 Max, 64GB of memory, 2TB of storage, 16 core CPU, and a 40 core GPU.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I use a Lenovo Thinkpad T470s with macOS Sequoia (using <Link href="https://github.com/acidanthera/OpenCorePkg">OpenCore</Link>) as my &quot;side piece,&quot; if you will. I&apos;ve had it for about a year now, and it&apos;s been a great experience.
</p>
<p className="text-gray-300 leading-relaxed mt-2">
I also own two MacBook Airs (2015 and 2013 base models) and an HP Chromebook, used as secondary devices. The 2013 runs unsupported macOS Sequoia Beta, the 2015 runs <Link href="https://xubuntu.org/">Xubuntu</Link>, and the Chromebook runs Arch Linux.
</p>
</>
)
},
{
title: "Contributions",
content: (
<>
<p className="text-gray-300 leading-relaxed mt-2">
Most of my repositories have migrated to <Link href="https://git.p0ntus.com/">p0ntus git</Link>. My username is <Link href="https://git.p0ntus.com/aidan/">aidan</Link>. You can find me on GitHub as <Link href={`https://github.com/${githubUsername}/`}>{githubUsername}</Link>.
</p>
<GitHubStatsImage username={githubUsername} />
</>
)
},
{
title: "Featured Projects",
content: (
<>
<p className="text-gray-300 leading-relaxed mt-2">
Here&apos;s just four of my top projects. Star and fork counts are fetched in real-time from both GitHub and Forgejo APIs.
</p>
<FeaturedRepos projects={featuredProjects} className="mt-4" />
</>
)
}
]
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{mainStrings.map((section, index) => {
if (mainSections[index] === t('about.sections.featuredProjects')) {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg lg:col-span-2 hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{section.map((text, index) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text}
</p>
))}
<FeaturedRepos className="mt-4" />
</section>
)
} else if (mainSections[index] === t('about.sections.contributions')) {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{section.map((text, index) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text.split(/(ihatenodejs|p0ntus git|aidan)/).map((part, i) => {
if (part === 'ihatenodejs') {
return <Link key={i} href="https://github.com/ihatenodejs/">ihatenodejs</Link>
}
if (part === 'p0ntus git') {
return <Link key={i} href="https://git.p0ntus.com/">p0ntus git</Link>
}
if (part === 'aidan') {
return <Link key={i} href="https://git.p0ntus.com/aidan/">aidan</Link>
}
return part
})}
</p>
))}
{!imageError && (
<div className="flex flex-col justify-center items-center w-full mt-4 gap-4">
<Image
src="https://github-readme-stats.vercel.app/api?username=ihatenodejs&theme=dark&show_icons=true&hide_border=true&count_private=true"
alt="ihatenodejs's Stats"
width={420}
height={200}
onError={() => setImageError(true)}
loading="eager"
priority
unoptimized
className="max-w-full h-auto"
/>
<Image
src="https://github-readme-stats.vercel.app/api/top-langs/?username=ihatenodejs&theme=dark&show_icons=true&hide_border=true&layout=compact"
alt="ihatenodejs's Top Languages"
width={300}
height={200}
onError={() => setImageError(true)}
loading="eager"
priority
unoptimized
className="max-w-full h-auto"
/>
</div>
)}
</section>
)
} else if (mainSections[index] === t('about.sections.devices')) {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{Object.entries(section).map(([key, value], index) => (
<div key={index}>
<h3 className={cn("text-xl font-semibold mb-2 text-gray-200", key === "Laptops" && "mt-4")}>{key}</h3>
{(value as unknown as string[]).map((text: string, index: number) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text.split(/(KernelSU-Next|LineageOS 22.2|Android 16|Xubuntu)/).map((part, i) => {
if (part === 'KernelSU-Next') {
return <Link key={i} href="https://github.com/KernelSU-Next/KernelSU-Next">KernelSU-Next</Link>
}
if (part === 'LineageOS 22.2') {
return <Link key={i} href="https://wiki.lineageos.org/devices/bonito/">LineageOS 22.2</Link>
}
if (part === 'Android 16') {
return <Link key={i} href="https://developer.android.com/about/versions/16/get">Android 16</Link>
}
if (part === 'OpenCore') {
return <Link key={i} href="https://github.com/acidanthera/OpenCorePkg">OpenCore</Link>
}
if (part === 'Xubuntu') {
return <Link key={i} href="https://xubuntu.org/">Xubuntu</Link>
}
return part
})}
</p>
))}
{key === "Mobile Devices" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
<Button
href="/device/komodo"
icon={<SiGoogle />}
>
Pixel 9 Pro XL
</Button>
<Button
href="/device/cheetah"
icon={<SiGoogle />}
>
Pixel 7 Pro
</Button>
<Button
href="/device/bonito"
icon={<SiGoogle />}
>
Pixel 3a XL
</Button>
</div>
)}
</div>
))}
</section>
)
} else if (mainSections[index] === t('about.sections.hobbies')) {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{section.map((text, index) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text.split(/(my Forgejo server|my phone|AfC|OnlyNano)/).map((part, i) => {
if (part === 'my Forgejo server') {
return <Link key={i} href="https://git.p0ntus.com/">my Forgejo server</Link>
}
if (part === 'my phone') {
return <Link key={i} href="/device/cheetah">my phone</Link>
}
if (part === 'AfC') {
return <Link key={i} href="https://en.wikipedia.org/wiki/Wikipedia:WikiProject_Articles_for_creation">AfC</Link>
}
if (part === 'OnlyNano') {
return <Link key={i} href="https://en.wikipedia.org/wiki/User:OnlyNano">OnlyNano</Link>
}
return part
})}
</p>
))}
</section>
)
} else if (mainSections[index] === t('about.sections.projects')) {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{section.map((text, index) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text.split(/(p0ntus|PontusHub|ABOCN|Kowalski|@KowalskiNodeBot)/).map((part, i) => {
if (part === 'p0ntus') {
return <Link key={i} href="https://p0ntus.com/">p0ntus</Link>
}
if (part === 'PontusHub') {
return <Link key={i} href="https://t.me/PontusHub">PontusHub</Link>
}
if (part === 'ABOCN') {
return <Link key={i} href="https://github.com/abocn">ABOCN</Link>
}
if (part === 'Kowalski') {
return <Link key={i} href="https://github.com/abocn/TelegramBot">Kowalski</Link>
}
if (part === '@KowalskiNodeBot') {
return <Link key={i} href="https://t.me/KowalskiNodeBot">@KowalskiNodeBot</Link>
}
return part
})}
</p>
))}
</section>
)
} else {
return (
<section key={index} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{mainSections[index]}</h2>
{section.map((text, index) => (
<p key={index} className="text-gray-300 leading-relaxed mt-2">
{text}
</p>
))}
</section>
)
}
})}
</div>
</main>
<Footer />
return (
<div className="w-full">
<div className="my-12 text-center">
<PageHeader
icon={<TbUserHeart size={60} />}
title="Get to Know Me"
/>
</div>
<CardGrid cols="3">
{sections.map((section) => (
<Card
key={section.title}
variant="default"
title={section.title}
spanCols={section.title === "Featured Projects" ? 2 : undefined}
className="p-4 sm:p-8"
>
{section.content}
</Card>
))}
</CardGrid>
</div>
)
}

View file

@ -1,177 +0,0 @@
"use client"
import PageHeader from './PageHeader'
export default function LoadingSkeleton() {
return (
<main className="w-full relative">
<PageHeader />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
<div className="flex items-center">
<div className="h-9 w-16 bg-gray-800 rounded animate-pulse" />
<div className="ml-3 h-5 w-12 bg-gray-800 rounded-full animate-pulse" />
</div>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<div className="h-9 w-32 bg-gray-800 rounded animate-pulse" />
</div>
</div>
<div className="p-4 pb-0">
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 relative md:col-span-2 lg:col-span-1">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-200">Activity</h2>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Heatmap</span>
<div className="h-6 w-11 bg-gray-700 rounded-full animate-pulse" />
</div>
</div>
<div className="overflow-x-auto pb-6">
<div className="min-w-[900px]">
<div className="flex gap-1">
<div className="flex flex-col gap-1 text-xs text-gray-400 w-10 pr-2">
<div className="h-4"></div>
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
<div key={day} className="h-4 flex items-center justify-end text-[10px]">
{day}
</div>
))}
</div>
<div className="relative">
<div className="h-4 mb-1 text-xs text-gray-400">
<div className="flex gap-16">
{['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov'].map((month) => (
<div key={month} className="w-12 h-3 bg-gray-800 rounded animate-pulse" />
))}
</div>
</div>
<div className="flex gap-1">
{(() => {
const today = new Date()
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const firstDay = startOfYear.getUTCDay()
const startDate = new Date(startOfYear)
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
const msPerWeek = 7 * 24 * 60 * 60 * 1000
const weekCount = Math.ceil((endDate.getTime() - startDate.getTime()) / msPerWeek)
return [...Array(weekCount)].map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{[...Array(7)].map((_, dayIndex) => (
<div key={dayIndex} className="w-4 h-4 bg-gray-800 rounded-sm animate-pulse" />
))}
</div>
))
})()}
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-4 text-xs text-gray-400">
<span>Less</span>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div key={i} className="w-3 h-3 bg-gray-800 rounded-sm animate-pulse" />
))}
</div>
<span>More</span>
</div>
</div>
</div>
</section>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
<div className="flex flex-col justify-center space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-800 rounded-full animate-pulse" />
<div className="h-4 w-20 bg-gray-800 rounded animate-pulse" />
</div>
<div className="flex items-center gap-3">
<div className="h-4 w-10 bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-16 bg-gray-800 rounded animate-pulse" />
</div>
</div>
))}
<div className="pt-3 mt-3 border-t border-gray-700">
<div className="flex justify-between items-center">
<span className="text-gray-400">Total Models Used</span>
<div className="h-5 w-8 bg-gray-800 rounded animate-pulse" />
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-gray-400">Most Used</span>
<div className="h-4 w-20 bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
</div>
</section>
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Type Breakdown</h2>
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
</section>
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 sm:col-span-2">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
<div className="h-[300px] bg-gray-800 rounded animate-pulse" />
</section>
</div>
<div className="px-4 pb-4">
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-700">
<th className="py-2 px-4 text-gray-400">Date</th>
<th className="py-2 px-4 text-gray-400">Models Used</th>
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
<th className="py-2 px-4 text-gray-400">Cost</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, index) => (
<tr key={index} className="border-b border-gray-800">
<td className="py-2 px-4">
<div className="h-5 w-24 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-96 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-16 bg-gray-800 rounded animate-pulse" />
</td>
<td className="py-2 px-4">
<div className="h-5 w-20 bg-gray-800 rounded animate-pulse" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
</main>
)
}

View file

@ -1,26 +0,0 @@
"use client"
import Link from 'next/link'
import { SiClaude } from 'react-icons/si'
export default function PageHeader() {
return (
<>
<Link
href="/ai"
className="absolute top-4 left-4 text-gray-400 hover:text-gray-200 hover:underline transition-colors duration-200 z-10 px-2 py-1 text-sm sm:text-base"
>
Back to AI
</Link>
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<SiClaude size={60} />
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">Claude Code Usage</h1>
<p className="text-gray-400">How much I use Claude Code!</p>
</div>
</>
)
}

View file

@ -1,37 +0,0 @@
"use client"
import { DailyData } from './types'
import { getModelLabel } from './utils'
export default function RecentSessions({ daily }: { daily: DailyData[] }) {
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-700">
<th className="py-2 px-4 text-gray-400">Date</th>
<th className="py-2 px-4 text-gray-400">Models Used</th>
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
<th className="py-2 px-4 text-gray-400">Cost</th>
</tr>
</thead>
<tbody>
{daily.slice(-5).reverse().map((day, index) => (
<tr key={index} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-4 text-gray-300">{new Date(day.date + 'T00:00:00').toLocaleDateString()}</td>
<td className="py-2 px-4 text-gray-300">
{day.modelsUsed.map(getModelLabel).join(', ')}
</td>
<td className="py-2 px-4 text-gray-300">{(day.totalTokens / 1000000).toFixed(2)}M</td>
<td className="py-2 px-4 text-[#c15f3c] font-semibold">${day.totalCost.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}

View file

@ -1,34 +0,0 @@
"use client"
import { CCData, DailyData } from './types'
import { formatStreakCompact, computeStreak } from './utils'
export default function StatsGrid({ totals, daily }: { totals: CCData['totals']; daily: DailyData[] }) {
const streak = computeStreak(daily)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<p className="text-3xl font-bold text-[#c15f3c]">${totals.totalCost.toFixed(2)}</p>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<p className="text-3xl font-bold text-[#c15f3c]">{(totals.totalTokens / 1000000).toFixed(1)}M</p>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
<p className="text-3xl font-bold text-[#c15f3c] flex items-center">
{daily.length}
<span className="ml-3 text-xs font-semibold text-gray-300 bg-gray-800 px-2 py-0.5 rounded-full">
🔥 {formatStreakCompact(streak)}
</span>
</p>
</div>
<div className="p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<p className="text-3xl font-bold text-[#c15f3c]">${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}</p>
</div>
</div>
)
}

View file

@ -1,30 +0,0 @@
"use client"
import { ResponsiveContainer, ComposedChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, Bar, Line } from 'recharts'
import { DailyData } from './types'
import { buildDailyTrendData } from './utils'
export default function TokenComposition({ daily }: { daily: DailyData[] }) {
const dailyTrendData = buildDailyTrendData(daily)
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 sm:col-span-2">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={dailyTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" tickFormatter={(value) => `${value}K`} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
formatter={(value: number) => `${value.toFixed(1)}K tokens`}
/>
<Legend />
<Bar dataKey="inputTokens" stackId="a" fill="#c15f3c" name="Input (K)" />
<Bar dataKey="outputTokens" stackId="a" fill="#b1ada1" name="Output (K)" />
<Line type="monotone" dataKey="cacheTokens" stroke="#f4f3ee" name="Cache (M)" strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -1,27 +0,0 @@
"use client"
import { ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, Bar } from 'recharts'
import { CCData } from './types'
import { buildTokenTypeData } from './utils'
export default function TokenTypeBreakdown({ totals }: { totals: CCData['totals'] }) {
const tokenTypeData = buildTokenTypeData(totals)
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Type Breakdown</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tokenTypeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="name" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" tickFormatter={(value) => `${(value / 1000000).toFixed(0)}M`} />
<Tooltip
contentStyle={{ backgroundColor: 'rgba(31, 41, 55)', border: '1px solid #374151' }}
formatter={(value: number) => `${(value / 1000000).toFixed(2)}M tokens`}
/>
<Bar dataKey="value" fill="#b1ada1" />
</BarChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -1,191 +0,0 @@
import { CCData, DailyData, HeatmapDay } from './types'
export const COLORS = ['#c15f3c', '#b1ada1', '#f4f3ee', '#c15f3c', '#b1ada1', '#f4f3ee']
export const MODEL_LABELS: Record<string, string> = {
'claude-sonnet-4-20250514': 'Sonnet 4',
'claude-opus-4-1-20250805': 'Opus 4.1',
}
export const getModelLabel = (modelName: string): string => {
return MODEL_LABELS[modelName] || modelName
}
export const formatCurrency = (value: number) => `$${value.toFixed(2)}`
export const formatTokens = (value: number) => `${value.toFixed(1)}M`
export const computeStreak = (daily: DailyData[]): number => {
if (!daily.length) return 0
const datesSet = new Set(daily.map(d => d.date))
const latest = daily
.map(d => new Date(d.date + 'T00:00:00Z'))
.reduce((a, b) => (a > b ? a : b))
const toKey = (d: Date) => {
const y = d.getUTCFullYear()
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
const day = d.getUTCDate().toString().padStart(2, '0')
return `${y}-${m}-${day}`
}
let count = 0
for (
let d = new Date(latest.getTime());
;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1))
) {
const key = toKey(d)
if (datesSet.has(key)) count++
else break
}
return count
}
export const formatStreakCompact = (days: number) => {
if (days >= 365) return `${Math.floor(days / 365)}y`
if (days >= 30) return `${Math.floor(days / 30)}mo`
if (days >= 7) return `${Math.floor(days / 7)}w`
return `${days}d`
}
export const computeFilledDailyRange = (daily: DailyData[]): DailyData[] => {
if (!daily.length) return []
const dates = daily.map(d => new Date(d.date + 'T00:00:00Z'))
const start = dates.reduce((a, b) => (a < b ? a : b))
const end = dates.reduce((a, b) => (a > b ? a : b))
const byDate = new Map<string, DailyData>(
daily.map(d => [d.date, d] as const)
)
const result: DailyData[] = []
for (
let d = new Date(start.getTime());
d <= end;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
) {
const y = d.getUTCFullYear()
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
const day = d.getUTCDate().toString().padStart(2, '0')
const key = `${y}-${m}-${day}`
if (byDate.has(key)) {
result.push(byDate.get(key)!)
} else {
result.push({
date: key,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0,
modelsUsed: [],
modelBreakdowns: [],
})
}
}
return result
}
export const buildDailyTrendData = (daily: DailyData[]) => {
const filled = computeFilledDailyRange(daily)
return filled.map(day => ({
date: new Date(day.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
cost: day.totalCost,
tokens: day.totalTokens / 1000000,
inputTokens: day.inputTokens / 1000,
outputTokens: day.outputTokens / 1000,
cacheTokens: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000,
}))
}
export const prepareHeatmapData = (daily: DailyData[]): (HeatmapDay | null)[][] => {
const dayMap = new Map<string, DailyData>()
daily.forEach(day => {
dayMap.set(day.date, day)
})
const today = new Date()
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const weeks: (HeatmapDay | null)[][] = []
let currentWeek: (HeatmapDay | null)[] = []
const firstDay = startOfYear.getUTCDay()
const startDate = new Date(startOfYear)
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
for (
let d = new Date(startDate);
d <= endDate;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
) {
if (d < startOfYear) {
currentWeek.push(null)
if (d.getUTCDay() === 6) {
weeks.push(currentWeek)
currentWeek = []
}
continue
}
const dateStr = `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1).toString().padStart(2, '0')}-${d.getUTCDate().toString().padStart(2, '0')}`
const dayData = dayMap.get(dateStr)
currentWeek.push({
date: dateStr,
value: dayData ? dayData.totalCost : 0,
tokens: dayData ? dayData.totalTokens : 0,
cost: dayData ? dayData.totalCost : 0,
day: d.getUTCDay(),
formattedDate: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
})
if (d.getUTCDay() === 6 || d.getTime() === endDate.getTime()) {
while (currentWeek.length < 7) {
currentWeek.push(null)
}
weeks.push(currentWeek)
currentWeek = []
}
}
return weeks
}
export const getHeatmapColor = (maxCost: number, value: number) => {
if (value === 0) return '#1f2937'
const denominator = maxCost === 0 ? 1 : maxCost
const intensity = value / denominator
if (intensity < 0.25) return '#4a3328'
if (intensity < 0.5) return '#6b4530'
if (intensity < 0.75) return '#8d5738'
return '#c15f3c'
}
export const buildModelUsageData = (daily: DailyData[]) => {
const raw = daily.reduce((acc, day) => {
day.modelBreakdowns.forEach(model => {
const label = getModelLabel(model.modelName)
const existing = acc.find(m => m.name === label)
if (existing) {
existing.value += model.cost
} else {
acc.push({ name: label, value: model.cost })
}
})
return acc
}, [] as { name: string; value: number }[])
return raw.sort((a, b) => b.value - a.value)
}
export const buildTokenTypeData = (totals: CCData['totals']) => ([
{ name: 'Input', value: totals.inputTokens },
{ name: 'Output', value: totals.outputTokens },
{ name: 'Cache Creation', value: totals.cacheCreationTokens },
{ name: 'Cache Read', value: totals.cacheReadTokens },
])

View file

@ -1,85 +0,0 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import { useEffect, useState } from 'react'
import LoadingSkeleton from './components/LoadingSkeleton'
import PageHeader from './components/PageHeader'
import StatsGrid from './components/StatsGrid'
import Activity from './components/Activity'
import ModelUsageCard from './components/ModelUsageCard'
import TokenTypeBreakdown from './components/TokenTypeBreakdown'
import TokenComposition from './components/TokenComposition'
import RecentSessions from './components/RecentSessions'
import { CCData } from './components/types'
export default function AI() {
const [data, setData] = useState<CCData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/data/cc.json')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch data')
return res.json()
})
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<LoadingSkeleton />
<Footer />
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center">
<div className="text-red-400">Error loading data: {error}</div>
</main>
<Footer />
</div>
)
}
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full relative">
<PageHeader />
<StatsGrid totals={data.totals} daily={data.daily} />
<div className="p-4 pb-0">
<Activity daily={data.daily} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<ModelUsageCard daily={data.daily} totalCost={data.totals.totalCost} />
<TokenTypeBreakdown totals={data.totals} />
<TokenComposition daily={data.daily} />
</div>
<div className="px-4 pb-4">
<RecentSessions daily={data.daily} />
</div>
</main>
<Footer />
</div>
)
}

View file

@ -32,63 +32,71 @@ export default function AIStack({ tools }: AIStackProps) {
}
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<TbStack2 size={24} />
<section className="p-4 sm:p-6 lg:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 mb-4 sm:mb-6">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-200 flex items-center gap-2">
<TbStack2 size={20} className="sm:w-6 sm:h-6" />
My AI Stack
</h2>
<p className="text-muted-foreground">The AI tools I use as a part of my routine and workflow.</p>
<p className="text-muted-foreground text-xs sm:text-sm">The AI tools I use as a part of my routine and workflow.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{tools.map((tool, index) => (
<div key={index} className="p-4 border border-gray-700 rounded-lg hover:border-gray-500 transition-all duration-300 flex flex-col">
<div className="flex items-start justify-between mb-3 flex-1">
<div className="flex items-center gap-3 flex-1">
{tool.icon && <tool.icon className="text-2xl text-gray-300" />}
<div key={index} className="p-3 sm:p-4 border border-gray-700 rounded-lg hover:border-gray-500 transition-all duration-300 flex flex-col">
<div className="flex items-start justify-between mb-2 sm:mb-3 flex-1">
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
{tool.icon && <tool.icon className="text-xl sm:text-2xl text-gray-300 flex-shrink-0" />}
{tool.svg && (
<div className="w-6 h-6 text-gray-300 fill-current">
<div className="w-5 h-5 sm:w-6 sm:h-6 text-gray-300 fill-current flex-shrink-0">
{tool.svg}
</div>
)}
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-200">{tool.name}</h3>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate">{tool.name}</h3>
{tool.price !== undefined && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{tool.discountedPrice !== undefined ? (
<>
<span className="text-gray-500 line-through">
<span className="text-xs sm:text-sm text-gray-500 line-through">
{formatPrice(tool.price)}
</span>
<span className="text-gray-200">
<span className="text-xs sm:text-sm text-gray-200">
{formatPrice(tool.discountedPrice)}
</span>
</>
) : (
<span className="text-gray-200">
<span className="text-xs sm:text-sm text-gray-200">
{formatPrice(tool.price)}
</span>
)}
</div>
)}
</div>
<p className="text-sm text-gray-400">{tool.description}</p>
<p className="text-xs sm:text-sm text-gray-400 line-clamp-2">{tool.description}</p>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-auto">
<span className={`text-xs px-2 py-1 rounded-full border ${getStatusColor(tool.status)}`}>
<div className="flex items-center justify-between mt-auto pt-2 gap-2">
<span className={`text-xs px-2 py-0.5 sm:py-1 rounded-full border whitespace-nowrap ${getStatusColor(tool.status)}`}>
{getStatusLabel(tool.status)}
</span>
<span className="flex flex-row items-center gap-4">
<span className="flex flex-row items-center gap-2 sm:gap-4">
{tool.link && (
<Link href={tool.link} className="text-blue-400 hover:text-blue-300 text-sm" target="_blank" rel="noopener noreferrer">
<Link
href={tool.link}
className="text-xs sm:text-sm hover:text-blue-300 whitespace-nowrap"
target="_blank"
rel="noopener noreferrer"
>
Visit
</Link>
)}
{tool.usage && (
<Link href={tool.usage} className="text-blue-400 hover:text-blue-300 text-sm">
{(tool.usage || tool.hasUsage) && (
<Link
href={tool.usage ?? '/ai/usage'}
className="text-xs sm:text-sm hover:text-blue-300 whitespace-nowrap"
>
Usage
</Link>
)}

View file

@ -1,4 +1,5 @@
import { Brain, Star } from 'lucide-react'
import { Brain } from 'lucide-react'
import PaginatedCardList from '@/components/ui/PaginatedCardList'
import type { FavoriteModel } from '../types'
interface FavoriteModelsProps {
@ -7,36 +8,29 @@ interface FavoriteModelsProps {
export default function FavoriteModels({ models }: FavoriteModelsProps) {
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<Brain size={24} />
Favorite Models
</h2>
<p className="text-muted-foreground italic text-sm">Based on personal preference</p>
</div>
<div className="space-y-4">
{models.map((model, index) => (
<div key={index} className="p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<h3 className="font-semibold text-gray-200">{model.name}</h3>
<p className="text-sm text-gray-400">{model.provider}</p>
</div>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={14}
className={i < model.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-600'}
/>
))}
</div>
<PaginatedCardList
items={models}
title="Favorite Models"
icon={<Brain size={24} />}
subtitle="Based on personal preference"
itemsPerPage={5}
getItemKey={(model) => model.name}
renderItem={(model) => (
<div className="p-3 sm:p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-start gap-2 mb-2">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate">{model.name}</h3>
<p className="text-xs sm:text-sm text-gray-400">{model.provider}</p>
</div>
<div className="flex items-center gap-1 px-2 sm:px-3 py-0.5 sm:py-1 bg-yellow-400/10 border border-yellow-400/20 rounded-md flex-shrink-0">
<span className="text-base sm:text-lg font-bold text-yellow-400">
{model.rating.toFixed(1)}
</span>
</div>
<p className="text-sm text-gray-300">{model.review}</p>
</div>
))}
</div>
</section>
<p className="text-xs sm:text-sm text-gray-300 leading-relaxed">{model.review}</p>
</div>
)}
/>
)
}

View file

@ -1,5 +1,5 @@
import { Star } from 'lucide-react'
import { TbTool } from 'react-icons/tb'
import PaginatedCardList from '@/components/ui/PaginatedCardList'
import type { AIReview } from '../types'
interface FavoriteToolsProps {
@ -8,51 +8,44 @@ interface FavoriteToolsProps {
export default function FavoriteTools({ reviews }: FavoriteToolsProps) {
return (
<section className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<div className="flex flex-row justify-between">
<h2 className="text-2xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<TbTool size={24} />
Favorite Tools
</h2>
<p className="text-muted-foreground italic text-sm">Based on personal preference</p>
</div>
<div className="space-y-4">
{reviews.map((review, index) => (
<div key={index} className="p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-center mb-3">
<h3 className="font-semibold text-gray-200">{review.tool}</h3>
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={14}
className={i < review.rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-600'}
/>
))}
</div>
<PaginatedCardList
items={reviews}
title="Favorite Tools"
icon={<TbTool size={24} />}
subtitle="Based on personal preference"
itemsPerPage={3}
getItemKey={(review) => review.tool}
renderItem={(review) => (
<div className="p-3 sm:p-4 bg-gray-800/50 rounded-lg">
<div className="flex justify-between items-center gap-2 mb-2 sm:mb-3">
<h3 className="font-semibold text-sm sm:text-base text-gray-200 truncate flex-1">{review.tool}</h3>
<div className="flex items-center gap-1 px-2 sm:px-3 py-0.5 sm:py-1 bg-yellow-400/10 border border-yellow-400/20 rounded-md flex-shrink-0">
<span className="text-base sm:text-lg font-bold text-yellow-400">
{review.rating.toFixed(1)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 mb-2 text-sm">
<div>
<p className="text-green-400 font-medium mb-1">Pros:</p>
<ul className="text-gray-300 space-y-1">
{review.pros.map((pro, i) => (
<li key={i} className="text-xs"> {pro}</li>
))}
</ul>
</div>
<div>
<p className="text-red-400 font-medium mb-1">Cons:</p>
<ul className="text-gray-300 space-y-1">
{review.cons.map((con, i) => (
<li key={i} className="text-xs"> {con}</li>
))}
</ul>
</div>
</div>
<p className="text-sm text-blue-400 font-medium">{review.verdict}</p>
</div>
))}
</div>
</section>
<div className="grid grid-cols-2 gap-2 mb-2 text-xs sm:text-sm">
<div>
<p className="text-green-400 font-medium mb-1 text-xs sm:text-sm">Pros:</p>
<ul className="text-gray-300 space-y-0.5 sm:space-y-1">
{review.pros.map((pro, i) => (
<li key={i} className="text-xs leading-tight"> {pro}</li>
))}
</ul>
</div>
<div>
<p className="text-red-400 font-medium mb-1 text-xs sm:text-sm">Cons:</p>
<ul className="text-gray-300 space-y-0.5 sm:space-y-1">
{review.cons.map((con, i) => (
<li key={i} className="text-xs leading-tight"> {con}</li>
))}
</ul>
</div>
</div>
<p className="text-xs sm:text-sm text-blue-400 font-medium">{review.verdict}</p>
</div>
)}
/>
)
}

View file

@ -1,41 +1,44 @@
import { Trophy, ChevronRight } from 'lucide-react'
import { SiClaude } from 'react-icons/si'
import Link from '@/components/objects/Link'
import { surfaces, colors } from '@/lib/theme'
export default function TopPick() {
return (
<div className="px-4 mb-4">
<h2 className="text-4xl font-semibold mb-6 text-gray-200 flex items-center gap-2">
<Trophy size={32} className="text-orange-300" />
Top Pick of <i className="-ml-[1.55px]">2025</i>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-semibold mb-4 sm:mb-6 text-gray-200 flex items-center gap-2">
<Trophy size={24} className="sm:w-8 sm:h-8 text-orange-300" />
<span className="flex items-center gap-1">
Top Pick of <i className="-ml-[1.55px]">2025</i>
</span>
</h2>
<div className="p-6 sm:p-8 border-2 border-[#c15f3c] rounded-lg bg-orange-500/5">
<div className="grid md:grid-cols-2 gap-6">
<div className="flex items-center gap-4">
<SiClaude className="text-6xl text-[#c15f3c]" />
<div>
<h3 className="text-3xl font-bold text-gray-100">Claude</h3>
<p className="text-gray-400">by Anthropic</p>
<div className="flex items-center gap-2 mt-2">
<Link href="https://claude.ai" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
Visit <ChevronRight size={16} />
<div className={surfaces.card.featured}>
<div className="grid md:grid-cols-2 gap-4 sm:gap-6">
<div className="flex items-center gap-3 sm:gap-4">
<SiClaude className="text-4xl sm:text-5xl md:text-6xl flex-shrink-0" style={{ color: colors.accents.ai }} />
<div className="min-w-0">
<h3 className="text-2xl sm:text-3xl font-bold text-gray-100">Claude</h3>
<p className="text-sm sm:text-base text-gray-400">by Anthropic</p>
<div className="flex flex-wrap items-center gap-2 mt-2">
<Link href="https://claude.ai" className="flex items-center gap-1 text-sm sm:text-base hover:text-blue-300">
Visit <ChevronRight size={14} className="sm:w-4 sm:h-4" />
</Link>
<Link href="/ai/claude" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
My Usage <ChevronRight size={16} />
<Link href="/ai/usage" className="flex items-center gap-1 text-sm sm:text-base hover:text-blue-300">
My Usage <ChevronRight size={14} className="sm:w-4 sm:h-4" />
</Link>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-gray-300">
<div className="space-y-2 sm:space-y-3">
<p className="text-sm sm:text-base text-gray-300 leading-relaxed">
Claude has become my go-to AI assistant for coding, writing, and learning very quickly.
I believe their Max 5x ($100/mo) is the best value for budget-conscious consumers like myself.
</p>
<div className='flex flex-col items-center gap-y-6 sm:flex-row sm:justify-between'>
<div className="flex gap-2 flex-wrap">
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">Top-Tier Tool Calling</span>
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">High-Value Plans</span>
<span className="px-2 py-1 bg-gray-700 rounded text-xs text-gray-300">Good Speed</span>
<div className="flex gap-2 flex-wrap justify-center sm:justify-start">
<span className={surfaces.badge.default}>Top-Tier Tool Calling</span>
<span className={surfaces.badge.default}>High-Value Plans</span>
<span className={surfaces.badge.default}>Good Speed</span>
</div>
</div>
</div>

View file

@ -13,7 +13,8 @@ export const aiTools: AITool[] = [
icon: SiClaude,
description: "My favorite model provider for general use and coding",
status: "primary",
usage: "/ai/claude",
usage: "/ai/usage",
hasUsage: true,
link: "https://claude.ai/",
price: 100
},
@ -22,6 +23,7 @@ export const aiTools: AITool[] = [
icon: SiOpenai,
description: "Feature-rich and budget-friendly (for now)",
status: "active",
hasUsage: true,
link: "https://chatgpt.com/",
price: 60
},
@ -126,71 +128,71 @@ export const favoriteModels: FavoriteModel[] = [
name: "Claude 4 Sonnet",
provider: "Anthropic",
review: "The perfect balance of capability, speed, and price. Perfect for development with React.",
rating: 5
rating: 10.0
},
{
name: "Claude 4.1 Opus",
provider: "Anthropic",
review: "Amazing planner, useful for Plan Mode in Claude Code. Useful in code generation, albeit at a higher cost.",
rating: 5
rating: 10.0
},
{
name: "Qwen3-235B-A22B",
provider: "Alibaba",
review: "The OG thinking model. Amazing, funny, and smart for chats. Surprisingly good at coding too.",
rating: 5
rating: 9.5
},
{
name: "GPT-5",
provider: "OpenAI",
review: "A model I am still testing with. Seems to be good with coding and following instructions so far, but not with the same flair as Claude.",
rating: 4
rating: 8.0
},
{
name: "Qwen3-Max-Preview",
provider: "Alibaba",
review: "A new personality for Qwen3 at a larger size, amazing for use in chats. I'm not so happy that it's closed source (for now).",
rating: 4
rating: 8.5
},
{
name: "Gemini 2.5 Pro",
provider: "Google",
review: "Amazing for Deep Research and reasoning tasks. I hate it for coding.",
rating: 4
rating: 7.5
},
{
name: "gemma3 27B",
provider: "Google",
review: "My favorite for playing around with AI or creating a project. Easy to run locally and open weight!",
rating: 4
rating: 8.0
},
]
export const aiReviews: AIReview[] = [
{
tool: "Claude Code",
rating: 5,
rating: 10.0,
pros: ["Flagship models", "High usage limits", "Exceptional Claude integration"],
cons: ["API interface be slow at times", "High investment cost to get full value"],
verdict: "Best overall for Claude lovers"
},
{
tool: "Cursor",
rating: 4,
rating: 8.0,
pros: ["Works like magic", "Lots of model support", "Huge ecosystem and community"],
cons: ["Expensive", "Hype around it is dying", "Unclear/manipulative pricing"],
verdict: "Great all-rounder, slowly dying"
},
{
tool: "Trae",
rating: 4,
rating: 8.5,
pros: ["Good UI/UX", "Very budget-friendly", "Fantastic premium usage limits"],
cons: ["No thinking", "Occasional parsing issues"],
verdict: "Budget-friendly productivity boost"
},
{
tool: "GitHub Copilot",
rating: 3,
rating: 6.0,
pros: ["Latest models", "Great autocomplete", "Budget-friendly subscription price"],
cons: ["No thinking", "Low quality output", "Bad support for other IDEs"],
verdict: "Good for casual use"

View file

@ -1,7 +1,6 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import PageHeader from '@/components/objects/PageHeader'
import { Brain } from 'lucide-react'
import TopPick from './components/TopPick'
import AIStack from './components/AIStack'
@ -11,15 +10,13 @@ import { aiTools, favoriteModels, aiReviews } from './data'
export default function AI() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full px-2 sm:px-6">
<div className="w-full px-2 sm:px-6">
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<Brain size={60} />
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">AI</h1>
<p className="text-gray-400">My journey with using LLMs</p>
<PageHeader
icon={<Brain size={60} />}
title="AI"
subtitle="My journey with using LLMs"
/>
</div>
<TopPick />
@ -32,8 +29,6 @@ export default function AI() {
<FavoriteModels models={favoriteModels} />
<FavoriteTools reviews={aiReviews} />
</div>
</main>
<Footer />
</div>
)
}

141
app/ai/theme.ts Normal file
View file

@ -0,0 +1,141 @@
export type ProviderId = 'all' | 'claudeCode' | 'codex'
export interface HeatmapPalette {
empty: string
steps: string[]
}
export interface ChartTheme {
areaStroke: string
areaFill: string
trend: string
pie: string[]
barPrimary: string
barSecondary: string
line: string
}
export interface ButtonTheme {
activeBackground: string
activeText: string
}
export interface ToolTheme {
id: ProviderId
label: string
accent: string
accentContrast: string
accentMuted: string
secondary: string
tertiary: string
focusRing: string
button: ButtonTheme
chart: ChartTheme
heatmap: HeatmapPalette
emphasis: {
cost: string
}
}
const claudeTheme: ToolTheme = {
id: 'claudeCode',
label: 'Claude Code',
accent: '#c15f3c',
accentContrast: '#1a100d',
accentMuted: '#d68b6b',
secondary: '#b1ada1',
tertiary: '#f4f3ee',
focusRing: '#c15f3c',
button: {
activeBackground: '#c15f3c',
activeText: '#1a100d',
},
chart: {
areaStroke: '#c15f3c',
areaFill: '#c15f3c',
trend: '#b1ada1',
pie: ['#c15f3c', '#d68b6b', '#b1ada1', '#8d5738', '#f4f3ee'],
barPrimary: '#c15f3c',
barSecondary: '#b1ada1',
line: '#f4f3ee',
},
heatmap: {
empty: '#1f2937',
steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c'],
},
emphasis: {
cost: '#c15f3c',
},
}
const codexTheme: ToolTheme = {
id: 'codex',
label: 'Codex',
accent: '#f5f5f5',
accentContrast: '#111827',
accentMuted: '#d1d5db',
secondary: '#9ca3af',
tertiary: '#6b7280',
focusRing: '#f5f5f5',
button: {
activeBackground: '#f5f5f5',
activeText: '#111827',
},
chart: {
areaStroke: '#f5f5f5',
areaFill: '#f5f5f5',
trend: '#d1d5db',
pie: ['#f5f5f5', '#d1d5db', '#9ca3af', '#6b7280', '#374151'],
barPrimary: '#f5f5f5',
barSecondary: '#9ca3af',
line: '#e5e7eb',
},
heatmap: {
empty: '#111827',
steps: ['#1f2937', '#374151', '#4b5563', '#f5f5f5'],
},
emphasis: {
cost: '#f5f5f5',
},
}
const combinedTheme: ToolTheme = {
id: 'all',
label: 'All Tools',
accent: '#9ca3af',
accentContrast: '#111827',
accentMuted: '#6b7280',
secondary: '#6b7280',
tertiary: '#e5e7eb',
focusRing: '#9ca3af',
button: {
activeBackground: '#9ca3af',
activeText: '#111827',
},
chart: {
areaStroke: '#9ca3af',
areaFill: '#9ca3af',
trend: '#6b7280',
pie: ['#e5e7eb', '#d1d5db', '#9ca3af', '#6b7280', '#4b5563'],
barPrimary: '#9ca3af',
barSecondary: '#6b7280',
line: '#e5e7eb',
},
heatmap: {
empty: '#1f2937',
steps: ['#374151', '#4b5563', '#6b7280', '#9ca3af'],
},
emphasis: {
cost: '#9ca3af',
},
}
export const toolThemes: Record<ProviderId, ToolTheme> = {
all: combinedTheme,
claudeCode: claudeTheme,
codex: codexTheme,
}
export const getToolTheme = (provider: ProviderId): ToolTheme => {
return toolThemes[provider] ?? toolThemes.all
}

View file

@ -6,6 +6,7 @@ export interface AITool {
status: 'primary' | 'active' | 'occasional' | string;
link?: string;
usage?: string;
hasUsage?: boolean;
price?: number;
discountedPrice?: number;
}
@ -14,13 +15,13 @@ export interface FavoriteModel {
name: string;
provider: string;
review: string;
rating: number;
rating: number; // 1.0 - 10.0 scale
}
export interface AIReview {
tool: string;
rating: number;
rating: number; // 1.0 - 10.0 scale
pros: string[];
cons: string[];
verdict: string;
}
}

View file

@ -1,26 +1,36 @@
"use client"
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import {
AreaChart,
Area,
Line,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import { DailyData } from './types'
import { DailyData, TimeRangeKey } from '@/lib/types'
import {
buildDailyTrendData,
formatCurrency,
formatTokens,
getHeatmapColor,
prepareHeatmapData,
formatAxisLabel,
formatTooltipDate,
} from './utils'
import type { ToolTheme } from '@/app/ai/theme'
export default function Activity({ daily }: { daily: DailyData[] }) {
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('heatmap')
interface ActivityProps {
daily: DailyData[]
theme: ToolTheme
timeRange: TimeRangeKey
}
export default function Activity({ daily, theme, timeRange }: ActivityProps) {
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('chart')
const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost')
const dailyTrendData = useMemo(() => buildDailyTrendData(daily), [daily])
@ -30,6 +40,50 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
[daily]
)
const toggleStyles = {
'--ring-color': theme.focusRing,
'--knob-color': theme.button.activeBackground,
} as React.CSSProperties
const heatmapLegendColors = useMemo(
() => [theme.heatmap.empty, ...theme.heatmap.steps],
[theme]
)
const xAxisFormatter = useCallback(
(value: string) => formatAxisLabel(String(value), timeRange),
[timeRange]
)
const tooltipLabelFormatter = useCallback(
(value: string) => formatTooltipDate(String(value)),
[]
)
const tooltipFormatter = useCallback(
(value: number | string, name: string) => {
const isTrend = name === 'Trend'
const label = isTrend
? selectedMetric === 'cost'
? 'Cost Trend'
: 'Token Trend'
: selectedMetric === 'cost'
? 'Daily Cost'
: 'Daily Tokens'
if (typeof value !== 'number') {
return ['—', label]
}
if (selectedMetric === 'cost') {
return [formatCurrency(value), label]
}
return [`${formatTokens(value)} tokens`, label]
},
[selectedMetric]
)
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 relative md:col-span-2 lg:col-span-1">
<div className="flex justify-between items-center mb-6">
@ -38,11 +92,13 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<span className="text-sm text-gray-400">{viewMode === 'heatmap' ? 'Heatmap' : 'Chart'}</span>
<button
onClick={() => setViewMode(viewMode === 'heatmap' ? 'chart' : 'heatmap')}
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700 transition-colors focus:outline-none focus:ring-2 focus:ring-[#c15f3c] focus:ring-offset-2 focus:ring-offset-gray-900"
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
style={toggleStyles}
>
<span className="sr-only">Toggle view mode</span>
<span
className={`${viewMode === 'chart' ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-[#c15f3c] transition-transform`}
className={`${viewMode === 'chart' ? 'translate-x-1' : 'translate-x-6'} inline-block h-4 w-4 transform rounded-full transition-transform`}
style={{ backgroundColor: theme.button.activeBackground }}
/>
</button>
</div>
@ -96,13 +152,15 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div key={dayIndex} className="relative group">
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0) }}
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0, theme.heatmap) }}
/>
{day && (
<div className="absolute z-10 invisible group-hover:visible -top-2 left-6">
<div className="bg-gray-900 border border-gray-700 rounded-lg p-2 shadow-lg whitespace-nowrap">
<p className="text-gray-300 text-xs font-medium mb-1">{day.formattedDate}</p>
<p className="text-[#c15f3c] font-bold text-sm">Cost: ${day.cost.toFixed(2)}</p>
<p className="font-bold text-sm" style={{ color: theme.emphasis.cost }}>
Cost: ${day.cost.toFixed(2)}
</p>
<p className="text-gray-400 text-xs">Tokens: {(day.tokens / 1000000).toFixed(2)}M</p>
</div>
</div>
@ -117,11 +175,9 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div className="flex items-center gap-2 mt-4 text-xs text-gray-400">
<span>Less</span>
<div className="flex gap-1">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#1f2937' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#4a3328' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#6b4530' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#8d5738' }}></div>
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#c15f3c' }}></div>
{heatmapLegendColors.map((color, idx) => (
<div key={idx} className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></div>
))}
</div>
<span>More</span>
</div>
@ -132,13 +188,21 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<div className="flex gap-2 mb-4">
<button
onClick={() => setSelectedMetric('cost')}
className={`px-3 py-1 rounded ${selectedMetric === 'cost' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
className={`px-3 py-1 rounded transition-colors ${selectedMetric === 'cost' ? '' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
style={selectedMetric === 'cost'
? { backgroundColor: theme.button.activeBackground, color: theme.button.activeText }
: undefined
}
>
Cost
</button>
<button
onClick={() => setSelectedMetric('tokens')}
className={`px-3 py-1 rounded ${selectedMetric === 'tokens' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
className={`px-3 py-1 rounded transition-colors ${selectedMetric === 'tokens' ? '' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
style={selectedMetric === 'tokens'
? { backgroundColor: theme.button.activeBackground, color: theme.button.activeText }
: undefined
}
>
Tokens
</button>
@ -146,21 +210,40 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={dailyTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9ca3af" />
<XAxis
dataKey="date"
stroke="#9ca3af"
tickFormatter={xAxisFormatter}
interval={timeRange === '7d' ? 0 : undefined}
tickMargin={12}
minTickGap={12}
/>
<YAxis
stroke="#9ca3af"
tickFormatter={selectedMetric === 'cost' ? formatCurrency : formatTokens}
domain={[0, 'auto']}
/>
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
formatter={(value: number) => selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)}
labelFormatter={tooltipLabelFormatter}
formatter={tooltipFormatter}
/>
<Area
type="monotone"
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
stroke="#c15f3c"
fill="#c15f3c"
stroke={theme.chart.areaStroke}
fill={theme.chart.areaFill}
fillOpacity={0.3}
name={selectedMetric === 'cost' ? 'Daily Cost' : 'Daily Tokens'}
/>
<Line
type="monotone"
dataKey={selectedMetric === 'cost' ? 'costTrend' : 'tokensTrend'}
stroke={theme.chart.trend}
strokeWidth={2}
dot={false}
strokeDasharray="6 4"
name="Trend"
/>
</AreaChart>
</ResponsiveContainer>
@ -169,4 +252,3 @@ export default function Activity({ daily }: { daily: DailyData[] }) {
</section>
)
}

View file

@ -0,0 +1,242 @@
"use client"
import PageHeader from './PageHeader'
import ProviderFilter from './ProviderFilter'
import TimeRangeFilter from './TimeRangeFilter'
import type { ToolTheme, ProviderId } from '@/app/ai/theme'
import type { TimeRangeKey } from '@/lib/types'
interface LoadingSkeletonProps {
theme: ToolTheme
selectedProvider?: ProviderId
timeRange?: TimeRangeKey
}
const hexToRgba = (hex: string, alpha: number): string => {
const normalized = hex.replace('#', '')
const value = normalized.length === 3
? normalized.split('').map((char) => `${char}${char}`).join('')
: normalized.padEnd(6, '0')
const num = parseInt(value, 16)
const r = (num >> 16) & 255
const g = (num >> 8) & 255
const b = num & 255
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const buildSkeletonStyles = (theme: ToolTheme) => {
const accentBase = theme.id === 'codex' ? theme.accentContrast : theme.accent
const softAccent = hexToRgba(accentBase, 0.14)
const mediumAccent = hexToRgba(accentBase, 0.22)
const strongAccent = hexToRgba(accentBase, 0.35)
return {
cardBorder: hexToRgba(accentBase, 0.28),
chipBorder: hexToRgba(accentBase, 0.4),
solid: { backgroundColor: mediumAccent },
gradient: {
backgroundImage: `linear-gradient(90deg, ${softAccent}, ${strongAccent}, ${softAccent})`,
backgroundColor: softAccent,
},
subtle: { backgroundColor: softAccent },
}
}
export default function LoadingSkeleton({ theme, selectedProvider = 'all', timeRange = '1m' }: LoadingSkeletonProps) {
const placeholderStyles = buildSkeletonStyles(theme)
return (
<main className="w-full relative">
<PageHeader theme={theme} selectedProvider={selectedProvider} />
<div className="mb-6 px-4">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div aria-hidden="true" />
<div className="justify-self-center">
<ProviderFilter
selectedProvider={selectedProvider}
onProviderChange={() => {}}
hasClaudeCode
hasCodex
theme={theme}
disabled
/>
</div>
<div className="justify-self-end">
<TimeRangeFilter
value={timeRange}
onChange={() => {}}
theme={theme}
disabled
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div
className="p-6 border-2 rounded-lg transition-colors duration-300"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
<div
className="p-6 border-2 rounded-lg transition-colors duration-300"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
<div
className="p-6 border-2 rounded-lg transition-colors duration-300"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
<div className="flex items-center">
<div className="h-9 w-16 rounded animate-pulse" style={placeholderStyles.gradient} />
<div className="ml-3 h-5 w-12 rounded-full animate-pulse" style={placeholderStyles.subtle} />
</div>
</div>
<div
className="p-6 border-2 rounded-lg transition-colors duration-300"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<div className="h-9 w-32 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
</div>
<div className="p-4 pb-0">
<section
className="p-8 border-2 rounded-lg transition-colors duration-300 relative md:col-span-2 lg:col-span-1"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-200">Activity</h2>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Chart</span>
<button
className="relative inline-flex h-6 w-11 items-center rounded-full"
style={{ backgroundColor: hexToRgba(theme.focusRing, 0.25) }}
>
<span className="sr-only">Toggle view mode</span>
<span
className="inline-block h-4 w-4 transform rounded-full translate-x-1 animate-pulse"
style={placeholderStyles.gradient}
/>
</button>
</div>
</div>
<div className="pb-6">
<div className="flex gap-2 mb-4">
<button
className="px-3 py-1 rounded"
style={{ backgroundColor: theme.button.activeBackground, color: theme.button.activeText }}
>
Cost
</button>
<button
className="px-3 py-1 rounded border text-gray-300"
style={{ borderColor: placeholderStyles.chipBorder, backgroundColor: hexToRgba(theme.focusRing, 0.12) }}
>
Tokens
</button>
</div>
<div className="h-[400px] w-full rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
</section>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<section
className="p-8 border-2 rounded-lg transition-colors duration-300 col-span-2 lg:col-span-1"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
<div className="flex flex-col justify-center space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full animate-pulse" style={placeholderStyles.gradient} />
<div className="h-4 w-20 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
<div className="flex items-center gap-3">
<div className="h-4 w-10 rounded animate-pulse" style={placeholderStyles.subtle} />
<div className="h-4 w-16 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
</div>
))}
<div className="pt-3 mt-3 border-t border-gray-700">
<div className="flex justify-between items-center">
<span className="text-gray-400">Total Models Used</span>
<div className="h-5 w-8 rounded animate-pulse" style={placeholderStyles.gradient} />
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-gray-400">Most Used</span>
<div className="h-4 w-20 rounded animate-pulse" style={placeholderStyles.subtle} />
</div>
</div>
</div>
</div>
</section>
<section
className="p-8 border-2 rounded-lg transition-colors duration-300 col-span-2 lg:col-span-1"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h2 className="text-2xl font-semibold mb-4 text-gray-200">By Token Type</h2>
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
</section>
<section
className="p-8 border-2 rounded-lg transition-colors duration-300 sm:col-span-2"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
<div className="h-[300px] rounded animate-pulse" style={placeholderStyles.gradient} />
</section>
</div>
<div className="px-4 pb-4">
<section
className="p-8 border-2 rounded-lg transition-colors duration-300"
style={{ borderColor: placeholderStyles.cardBorder }}
>
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-700">
<th className="py-2 px-4 text-gray-400">Date</th>
<th className="py-2 px-4 text-gray-400">Models Used</th>
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
<th className="py-2 px-4 text-gray-400">Cost</th>
</tr>
</thead>
<tbody>
{[...Array(5)].map((_, index) => (
<tr key={index} className="border-b border-gray-800">
<td className="py-2 px-4">
<div className="h-5 w-24 rounded animate-pulse" style={placeholderStyles.gradient} />
</td>
<td className="py-2 px-4">
<div className="h-5 w-96 rounded animate-pulse" style={placeholderStyles.gradient} />
</td>
<td className="py-2 px-4">
<div className="h-5 w-16 rounded animate-pulse" style={placeholderStyles.subtle} />
</td>
<td className="py-2 px-4">
<div className="h-5 w-20 rounded animate-pulse" style={placeholderStyles.gradient} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
</main>
)
}

View file

@ -1,11 +1,19 @@
"use client"
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
import { DailyData } from './types'
import { COLORS, buildModelUsageData, formatCurrency } from './utils'
import { DailyData } from '@/lib/types'
import { buildModelUsageData, formatCurrency } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[]; totalCost: number }) {
interface ModelUsageCardProps {
daily: DailyData[]
totalCost: number
theme: ToolTheme
}
export default function ModelUsageCard({ daily, totalCost, theme }: ModelUsageCardProps) {
const modelUsageData = buildModelUsageData(daily)
const palette = theme.chart.pie
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Model Usage Distribution</h2>
@ -23,12 +31,15 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
dataKey="value"
>
{modelUsageData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell key={`cell-${index}`} fill={palette[index % palette.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
formatter={(value: number) => formatCurrency(value)}
formatter={(value: number, _name, props) => {
const percentage = props?.payload?.percentage ?? 0
return [`${formatCurrency(Number(value))} · ${percentage.toFixed(1)}%`, 'Cost']
}}
labelStyle={{ color: '#fff' }}
itemStyle={{ color: '#fff' }}
/>
@ -42,7 +53,7 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
style={{ backgroundColor: palette[index % palette.length] }}
/>
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
</div>
@ -70,4 +81,3 @@ export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[
</section>
)
}

View file

@ -0,0 +1,72 @@
'use client'
import Link from 'next/link'
import { SiClaude, SiOpenai } from 'react-icons/si'
import { toolThemes, type ToolTheme, type ProviderId } from '@/app/ai/theme'
interface PageHeaderProps {
selectedProvider?: ProviderId
theme: ToolTheme
}
export default function PageHeader({ selectedProvider = 'all', theme }: PageHeaderProps) {
const iconSize = 60
const renderIcons = (): React.JSX.Element => {
if (selectedProvider === 'claudeCode') {
return <SiClaude size={iconSize} style={{ color: theme.accent }} />
} else if (selectedProvider === 'codex') {
return (
<SiOpenai
size={iconSize}
style={{ color: theme.accent }}
className="drop-shadow-[0_0_12px_rgba(255,255,255,0.25)]"
/>
)
} else {
return (
<div className="flex gap-4 justify-center">
<SiClaude size={iconSize} style={{ color: toolThemes.claudeCode.accent }} />
<SiOpenai
size={iconSize}
style={{ color: toolThemes.codex.accent }}
className="drop-shadow-[0_0_12px_rgba(255,255,255,0.25)]"
/>
</div>
)
}
}
const getTitle = (): string => {
if (selectedProvider === 'claudeCode') return 'Claude Code Usage'
if (selectedProvider === 'codex') return 'Codex Usage'
return 'AI Usage'
}
const getSubtitle = (): string => {
if (selectedProvider === 'claudeCode') return 'Track my Claude Code usage'
if (selectedProvider === 'codex') return 'Track my Codex usage'
return 'Track my AI usage across providers'
}
return (
<div className="relative">
<div className="container mx-auto px-4 relative">
<Link
href="/ai"
className="absolute top-5 left-2 text-gray-400 hover:text-gray-200 hover:underline transition-colors duration-200 px-2 py-1 text-sm sm:text-base z-10"
>
Back to AI
</Link>
<div className="py-12 text-center">
<div className="flex justify-center mb-6">
{renderIcons()}
</div>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">{getTitle()}</h1>
<p className="text-gray-400">{getSubtitle()}</p>
<div className="mx-auto mt-6 h-1 w-16 rounded-full" style={{ backgroundColor: theme.accent }} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,71 @@
"use client"
import { SiClaude, SiOpenai } from 'react-icons/si'
import { toolThemes, type ToolTheme } from '@/app/ai/theme'
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'
type ProviderOptionId = 'all' | 'claudeCode' | 'codex'
interface ProviderFilterProps {
selectedProvider: ProviderOptionId
onProviderChange: (provider: ProviderOptionId) => void
hasClaudeCode: boolean
hasCodex: boolean
theme: ToolTheme
disabled?: boolean
loading?: boolean
className?: string
}
export default function ProviderFilter({
selectedProvider,
onProviderChange,
hasClaudeCode,
hasCodex,
theme,
disabled = false,
loading = false,
className,
}: ProviderFilterProps) {
const providers: Array<SegmentedOption<ProviderOptionId> & { available: boolean }> = [
{
id: 'all',
label: 'All Tools',
icon: null,
available: hasClaudeCode || hasCodex,
accentColor: toolThemes.all.accent,
},
{
id: 'claudeCode',
label: 'Claude Code',
icon: <SiClaude />,
available: hasClaudeCode,
accentColor: toolThemes.claudeCode.accent,
},
{
id: 'codex',
label: 'Codex',
icon: <SiOpenai />,
available: hasCodex,
accentColor: toolThemes.codex.accent,
}
]
const segmentedOptions: SegmentedOption<ProviderOptionId>[] = providers.map(provider => ({
id: provider.id,
label: provider.label,
icon: provider.icon,
accentColor: provider.accentColor ?? theme.accent,
disabled: !provider.available,
}))
return (
<SegmentedControl
options={segmentedOptions}
value={selectedProvider}
onChange={onProviderChange}
disabled={disabled || loading}
className={className}
/>
)
}

View file

@ -0,0 +1,55 @@
"use client"
import { DailyData } from '@/lib/types'
import { getModelLabel } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
interface RecentSessionsProps {
daily: DailyData[]
theme: ToolTheme
}
export default function RecentSessions({ daily, theme }: RecentSessionsProps) {
const sessions = daily.filter(day => day.totalTokens > 0 || day.totalCost > 0)
const rows = sessions.slice(-5).reverse()
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Recent Sessions</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-700">
<th className="py-2 px-4 text-gray-400">Date</th>
<th className="py-2 px-4 text-gray-400">Models Used</th>
<th className="py-2 px-4 text-gray-400">Total Tokens</th>
<th className="py-2 px-4 text-gray-400">Cost</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={4} className="py-4 px-4 text-center text-gray-500">
No sessions in this range.
</td>
</tr>
) : (
rows.map((day, index) => (
<tr key={index} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-4 text-gray-300">{new Date(day.date + 'T00:00:00').toLocaleDateString()}</td>
<td className="py-2 px-4 text-gray-300">
{day.modelsUsed.map(getModelLabel).join(', ')}
</td>
<td className="py-2 px-4 text-gray-300">{(day.totalTokens / 1000000).toFixed(2)}M</td>
<td className="py-2 px-4 font-semibold" style={{ color: theme.emphasis.cost }}>
${day.totalCost.toFixed(2)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)
}

View file

@ -0,0 +1,71 @@
"use client"
import { type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface SegmentedOption<T extends string> {
id: T
label: string
icon?: ReactNode
disabled?: boolean
accentColor?: string
}
interface SegmentedControlProps<T extends string> {
options: SegmentedOption<T>[]
value: T
onChange?: (value: T) => void
disabled?: boolean
className?: string
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
disabled = false,
className,
}: SegmentedControlProps<T>) {
return (
<div className={cn('inline-flex rounded-xl border border-gray-800 bg-gray-900/60 p-1', className)}>
{options.map((option, index) => {
const isSelected = option.id === value
const isDisabled = disabled || option.disabled
const accent = option.accentColor ?? '#f9fafb'
return (
<button
key={option.id}
type="button"
aria-pressed={isSelected}
disabled={isDisabled}
onClick={() => {
if (!isDisabled && option.id !== value) onChange?.(option.id)
}}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
isSelected && 'bg-gray-800 text-gray-100',
!isSelected && !isDisabled && 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/50',
isDisabled && 'text-gray-600 cursor-not-allowed opacity-50',
index > 0 && 'ml-1'
)}
style={isSelected ? { boxShadow: `0 0 0 1px ${accent}`, color: accent } : undefined}
>
{option.icon && (
<span
aria-hidden="true"
className="flex items-center"
style={{
color: isSelected ? accent : isDisabled ? '#4b5563' : '#9ca3af',
}}
>
{option.icon}
</span>
)}
{option.label}
</button>
)
})}
</div>
)
}

View file

@ -0,0 +1,48 @@
"use client"
import { Totals, DailyData } from '@/lib/types/ai'
import { formatStreakCompact, computeStreak } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
import { surfaces } from '@/lib/theme'
interface StatsGridProps {
totals: Totals
daily: DailyData[]
theme: ToolTheme
}
export default function StatsGrid({ totals, daily, theme }: StatsGridProps) {
const activeDays = daily.filter(day => day.totalTokens > 0 || day.totalCost > 0)
const streak = computeStreak(activeDays)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 px-4">
<div className={surfaces.card.ai}>
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Cost</h3>
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
${totals.totalCost.toFixed(2)}
</p>
</div>
<div className={surfaces.card.ai}>
<h3 className="text-sm font-medium text-gray-400 mb-2">Total Tokens</h3>
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
{(totals.totalTokens / 1000000).toFixed(1)}M
</p>
</div>
<div className={surfaces.card.ai}>
<h3 className="text-sm font-medium text-gray-400 mb-2">Days Active</h3>
<p className="text-3xl font-bold flex items-center" style={{ color: theme.emphasis.cost }}>
{activeDays.length}
<span className="ml-3 text-xs font-semibold text-gray-300 bg-gray-800 px-2 py-0.5 rounded-full">
🔥 {formatStreakCompact(streak)}
</span>
</p>
</div>
<div className={surfaces.card.ai}>
<h3 className="text-sm font-medium text-gray-400 mb-2">Avg Daily Cost</h3>
<p className="text-3xl font-bold" style={{ color: theme.emphasis.cost }}>
${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
"use client"
import type { ToolTheme } from '@/app/ai/theme'
import type { TimeRangeKey } from '@/lib/types'
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'
const TIME_RANGE_OPTIONS = [
{ id: '7d', label: '7d' },
{ id: '1m', label: '1mo' },
{ id: '3m', label: '3mo' },
{ id: '6m', label: '6mo' },
{ id: '1y', label: '1y' },
{ id: 'all', label: 'All' },
] as const satisfies ReadonlyArray<SegmentedOption<TimeRangeKey>>
type TimeRangeOptionId = (typeof TIME_RANGE_OPTIONS)[number]['id']
interface TimeRangeFilterProps {
value: TimeRangeKey
onChange: (value: TimeRangeKey) => void
theme: ToolTheme
disabled?: boolean
className?: string
}
export default function TimeRangeFilter({
value,
onChange,
theme,
disabled = false,
className,
}: TimeRangeFilterProps) {
const options = TIME_RANGE_OPTIONS.map<SegmentedOption<TimeRangeOptionId>>(option => ({
...option,
accentColor: theme.accent,
}))
return (
<SegmentedControl
options={options}
value={value}
onChange={onChange}
disabled={disabled}
className={className}
/>
)
}

View file

@ -0,0 +1,75 @@
"use client"
import { ResponsiveContainer, ComposedChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, Bar, Line } from 'recharts'
import { DailyData, TimeRangeKey } from '@/lib/types'
import { buildTokenCompositionData, formatAxisLabel, formatTooltipDate } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
const formatWithUnit = (value: number): string => {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}M`
} else if (value >= 1) {
return `${value.toFixed(value >= 100 ? 0 : 1)}K`
} else {
return value.toFixed(2)
}
}
const formatTooltipValue = (value: number, dataKey: string | undefined): string => {
if (dataKey === 'cacheTokens') {
if (value >= 1000) {
return `${(value / 1000).toFixed(2)}B tokens`
} else if (value >= 1) {
return `${value.toFixed(2)}M tokens`
} else {
return `${(value * 1000).toFixed(0)}K tokens`
}
} else {
if (value >= 1000) {
return `${(value / 1000).toFixed(2)}M tokens`
} else if (value >= 1) {
return `${value.toFixed(1)}K tokens`
} else {
return `${(value * 1000).toFixed(0)} tokens`
}
}
}
interface TokenCompositionProps {
daily: DailyData[]
theme: ToolTheme
timeRange: TimeRangeKey
}
export default function TokenComposition({ daily, theme, timeRange }: TokenCompositionProps) {
const tokenCompositionData = buildTokenCompositionData(daily)
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 sm:col-span-2">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Composition</h2>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={tokenCompositionData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="date"
stroke="#9ca3af"
tickFormatter={(value) => formatAxisLabel(String(value), timeRange)}
interval={timeRange === '7d' ? 0 : undefined}
tickMargin={12}
minTickGap={12}
/>
<YAxis stroke="#9ca3af" tickFormatter={formatWithUnit} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: number, _name: string, props: any) => formatTooltipValue(value, props?.dataKey)}
labelFormatter={(value: string) => formatTooltipDate(String(value))}
/>
<Legend />
<Bar dataKey="inputTokens" stackId="a" fill={theme.chart.barPrimary} name="Input" />
<Bar dataKey="outputTokens" stackId="a" fill={theme.chart.barSecondary} name="Output" />
<Line type="monotone" dataKey="cacheTokens" stroke={theme.chart.line} name="Cache" strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -0,0 +1,60 @@
"use client"
import {
ResponsiveContainer,
BarChart,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
Bar,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import type { Payload, ValueType, NameType } from 'recharts/types/component/DefaultTooltipContent'
import type { CCData } from '@/lib/types'
import { buildTokenTypeData } from './utils'
import type { ToolTheme } from '@/app/ai/theme'
type TokenTooltipProps = TooltipProps<ValueType, NameType> & {
payload?: Payload<ValueType, NameType>[]
}
interface TokenTypeProps {
totals: CCData['totals']
theme: ToolTheme
}
export default function TokenType({ totals, theme }: TokenTypeProps) {
const tokenTypeData = buildTokenTypeData(totals)
const renderTooltip = ({ active, payload }: TokenTooltipProps) => {
if (!active || !payload?.length) return null
const [firstEntry] = payload
const dataPoint = (firstEntry?.payload ?? null) as (typeof tokenTypeData)[number] | null
const rawValue = Number(firstEntry?.value ?? 0)
const formattedValue = `${(rawValue / 1_000_000).toFixed(2)}M tokens`
const percentage = dataPoint?.percentage ?? 0
return (
<div className="rounded-md border border-gray-700 bg-gray-900/80 px-3 py-2 text-sm text-gray-100">
<p className="font-medium">{dataPoint?.name ?? firstEntry?.name ?? 'Token Type'}</p>
<p className="text-xs text-gray-400">{percentage.toFixed(1)}% · {formattedValue}</p>
</div>
)
}
return (
<section className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 col-span-2 lg:col-span-1">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">Token Type</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tokenTypeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="name" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" tickFormatter={(value) => `${(value / 1000000).toFixed(0)}M`} domain={[0, 'auto']} />
<Tooltip content={renderTooltip} cursor={{ fill: 'rgba(31, 41, 55, 0.3)' }} />
<Bar dataKey="value" fill={theme.chart.barSecondary} />
</BarChart>
</ResponsiveContainer>
</section>
)
}

View file

@ -0,0 +1,119 @@
import { CCData, DailyData, HeatmapDay, TimeRangeKey } from '@/lib/types'
import { AIService } from '@/lib/services'
import type { HeatmapPalette } from '@/app/ai/theme'
export const getModelLabel = (modelName: string): string => {
return AIService.getModelLabel(modelName)
}
export const formatCurrency = (value: number) => `$${value.toFixed(2)}`
export const formatTokens = (value: number) => `${value.toFixed(1)}M`
export const computeStreak = (daily: DailyData[]): number => {
return AIService.computeStreak(daily)
}
export const formatStreakCompact = (days: number) => {
return AIService.formatStreakCompact(days)
}
export const computeFilledDailyRange = (daily: DailyData[]): DailyData[] => {
return AIService.computeFilledDailyRange(daily)
}
export const buildDailyTrendData = (daily: DailyData[]) => {
const trendData = AIService.buildDailyTrendData(daily)
return trendData.map(day => ({
date: day.date,
cost: day.totalCost,
tokens: day.totalTokens / 1000000,
inputTokens: day.inputTokensNormalized,
outputTokens: day.outputTokensNormalized,
cacheTokens: day.cacheTokensNormalized,
costTrend: day.costTrend,
tokensTrend: day.tokensTrend,
}))
}
export const prepareHeatmapData = (daily: DailyData[]): (HeatmapDay | null)[][] => {
return AIService.prepareHeatmapData(daily)
}
export const getHeatmapColor = (maxCost: number, value: number, palette: HeatmapPalette) => {
return AIService.getHeatmapColor(maxCost, value, palette)
}
export const buildModelUsageData = (daily: DailyData[]) => {
return AIService.buildModelUsageData(daily)
}
export const buildTokenTypeData = (totals: CCData['totals']) => {
return AIService.buildTokenTypeData(totals)
}
export const buildTokenCompositionData = (daily: DailyData[]) => {
return AIService.buildTokenCompositionData(daily)
}
export const filterDailyByRange = (
daily: DailyData[],
range: TimeRangeKey,
options?: { endDate?: Date }
) => {
return AIService.filterDailyByRange(daily, range, options)
}
export const computeTotalsFromDaily = (daily: DailyData[]) => {
return AIService.computeTotalsFromDaily(daily)
}
const toUtcDate = (isoDate: string) => new Date(`${isoDate}T00:00:00Z`)
export const formatTooltipDate = (isoDate: string): string => {
const date = toUtcDate(isoDate)
if (Number.isNaN(date.getTime())) return isoDate
return date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}
export const formatAxisLabel = (isoDate: string, range: TimeRangeKey): string => {
const date = toUtcDate(isoDate)
if (Number.isNaN(date.getTime())) return isoDate
switch (range) {
case '7d':
return date.toLocaleDateString('en-US', {
weekday: 'long',
timeZone: 'UTC',
})
case '1m':
case '3m':
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
case '6m':
return date.toLocaleDateString('en-US', {
month: 'short',
timeZone: 'UTC',
})
case '1y':
return date.toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
})
default:
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
}
}

200
app/ai/usage/page.tsx Normal file
View file

@ -0,0 +1,200 @@
"use client"
import { useEffect, useState, useMemo } from 'react'
import LoadingSkeleton from './components/LoadingSkeleton'
import PageHeader from './components/PageHeader'
import ProviderFilter from './components/ProviderFilter'
import StatsGrid from './components/StatsGrid'
import Activity from './components/Activity'
import ModelUsageCard from './components/ModelUsageCard'
import TokenType from './components/TokenType'
import TokenComposition from './components/TokenComposition'
import RecentSessions from './components/RecentSessions'
import TimeRangeFilter from './components/TimeRangeFilter'
import { filterDailyByRange, computeTotalsFromDaily } from './components/utils'
import type { ExtendedCCData, CCData, TimeRangeKey, DailyData } from '@/lib/types/ai'
import { getToolTheme } from '@/app/ai/theme'
export default function Usage() {
const [data, setData] = useState<ExtendedCCData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedProvider, setSelectedProvider] = useState<'all' | 'claudeCode' | 'codex'>('all')
const [timeRange, setTimeRange] = useState<TimeRangeKey>('1m')
const sortedAllDaily = useMemo<DailyData[]>(() => {
if (!data) return []
const dateMap = new Map<string, DailyData>()
if (data.claudeCode?.daily) {
for (const entry of data.claudeCode.daily) {
dateMap.set(entry.date, { ...entry })
}
}
if (data.codex?.daily) {
for (const entry of data.codex.daily) {
const existing = dateMap.get(entry.date)
if (existing) {
existing.inputTokens += entry.inputTokens
existing.outputTokens += entry.outputTokens
existing.cacheCreationTokens += entry.cacheCreationTokens
existing.cacheReadTokens += entry.cacheReadTokens
existing.totalTokens += entry.totalTokens
existing.totalCost += entry.totalCost
existing.modelsUsed = [...existing.modelsUsed, ...entry.modelsUsed]
existing.modelBreakdowns = [...existing.modelBreakdowns, ...entry.modelBreakdowns]
} else {
dateMap.set(entry.date, { ...entry })
}
}
}
return Array.from(dateMap.values()).sort((a, b) => a.date.localeCompare(b.date))
}, [data])
const globalEndDate = useMemo<Date | null>(() => {
if (!sortedAllDaily.length) return null
const last = sortedAllDaily[sortedAllDaily.length - 1]
return new Date(last.date + 'T00:00:00Z')
}, [sortedAllDaily])
useEffect(() => {
fetch('/data/cc.json')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch data')
return res.json()
})
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
const providerScopedData = useMemo<CCData | null>(() => {
if (!data) return null
const baseDaily = sortedAllDaily
const createEmptyDay = (date: string): DailyData => ({
date,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0,
modelsUsed: [],
modelBreakdowns: [],
})
if (selectedProvider === 'claudeCode' && data.claudeCode) {
const byDate = new Map(data.claudeCode.daily.map(day => [day.date, day] as const))
const normalizedDaily = baseDaily.map(day => byDate.get(day.date) ?? createEmptyDay(day.date))
return {
daily: normalizedDaily,
totals: data.claudeCode.totals,
}
}
if (selectedProvider === 'codex' && data.codex) {
const byDate = new Map(data.codex.daily.map(day => [day.date, day] as const))
const normalizedDaily = baseDaily.map(day => byDate.get(day.date) ?? createEmptyDay(day.date))
return {
daily: normalizedDaily,
totals: data.codex.totals,
}
}
const totals = data.totals || computeTotalsFromDaily(baseDaily)
return {
daily: baseDaily,
totals,
}
}, [data, selectedProvider, sortedAllDaily])
const filteredData = useMemo<CCData | null>(() => {
if (!providerScopedData) return null
const scopedDaily = filterDailyByRange(providerScopedData.daily, timeRange, {
endDate: globalEndDate ?? undefined,
})
const totals = timeRange === 'all'
? providerScopedData.totals
: computeTotalsFromDaily(scopedDaily)
return {
daily: scopedDaily,
totals
}
}, [providerScopedData, timeRange, globalEndDate])
const theme = getToolTheme(selectedProvider)
if (loading) {
return (
<LoadingSkeleton
theme={theme}
selectedProvider={selectedProvider}
timeRange={timeRange}
/>
)
}
if (error || !data || !filteredData) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-red-400">Error loading data: {error}</div>
</div>
)
}
return (
<div className="w-full relative">
<PageHeader selectedProvider={selectedProvider} theme={theme} />
<div className="mb-6 px-4">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div aria-hidden="true" />
<div className="justify-self-center">
<ProviderFilter
selectedProvider={selectedProvider}
onProviderChange={setSelectedProvider}
hasClaudeCode={!!data.claudeCode}
hasCodex={!!data.codex}
theme={theme}
/>
</div>
<div className="justify-self-end">
<TimeRangeFilter
value={timeRange}
onChange={setTimeRange}
theme={theme}
/>
</div>
</div>
</div>
<StatsGrid totals={filteredData.totals} daily={filteredData.daily} theme={theme} />
<div className="p-4 pb-0">
<Activity daily={filteredData.daily} theme={theme} timeRange={timeRange} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
<ModelUsageCard daily={filteredData.daily} totalCost={filteredData.totals.totalCost} theme={theme} />
<TokenType totals={filteredData.totals} theme={theme} />
<TokenComposition daily={filteredData.daily} theme={theme} timeRange={timeRange} />
</div>
<div className="px-4 pb-4">
<RecentSessions daily={filteredData.daily} theme={theme} />
</div>
</div>
)
}

View file

@ -2,6 +2,32 @@ import { NextResponse } from 'next/server'
export const runtime = 'edge';
/**
* Fetch currently playing music from ListenBrainz
*
* Returns the most recent listening data for the configured user.
* Requires LISTENBRAINZ_TOKEN environment variable to be set.
*
* @returns {Promise<NextResponse>} ListenBrainz listening data with track metadata
*
* @example
* // Response structure
* {
* payload: {
* count: 1,
* listens: [{
* playing_now: true,
* track_metadata: {
* artist_name: "Daft Punk",
* track_name: "Get Lucky",
* release_name: "Random Access Memories"
* }
* }]
* }
* }
*
* @category API
*/
export async function GET() {
try {
const response = await fetch("https://api.listenbrainz.org/1/user/p0ntus/playing-now", {

View file

@ -1,80 +1,57 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import Button from '@/components/objects/Button'
import { Phone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import PageHeader from '@/components/objects/PageHeader'
import { Phone, Smartphone, Mail } from 'lucide-react'
import { SiGithub, SiForgejo, SiTelegram } from 'react-icons/si'
import { Mail, Smartphone } from 'lucide-react'
interface ContactSection {
title: string
texts: string[]
}
export default function Contact() {
const { t } = useTranslation();
const sections = [
const sections: ContactSection[] = [
{
title: t('contact.sections.busyPerson.title'),
texts: t('contact.sections.busyPerson.texts', { returnObjects: true }) as string[]
},
{
title: t('contact.sections.callingNote.title'),
texts: t('contact.sections.callingNote.texts', { returnObjects: true }) as string[]
title: "I'm a busy person",
texts: [
"I'm busy most of the time, so please be patient and understanding of my workload. I can tend to be offline for a few days when I'm busy, but I will respond as soon as I can.",
"For the best chance of a response, please send me a message on Telegram. If you've made a pull request on one of my repos, I will most likely respond by the next day. If you've sent me an email, I will most likely respond within three days or less."
]
}
];
]
const contactButtonLabels = [
"ihatenodejs",
"aidan",
"p0ntu5",
"+1 802-416-9516",
"aidan@p0ntus.com",
];
const contactButtonHrefs = [
"https://github.com/ihatenodejs",
"https://git.p0ntus.com/aidan",
"https://t.me/p0ntu5",
"tel:+18024169516",
"mailto:aidan@p0ntus.com"
];
const contactButtonIcons = [
<SiGithub key="github" />,
<SiForgejo key="forgejo" />,
<SiTelegram key="telegram" />,
<Smartphone key="smartphone" />,
<Mail key="mail" />
];
const contactButtons = [
{ label: "ihatenodejs", href: "https://github.com/ihatenodejs", icon: SiGithub },
{ label: "aidan", href: "https://git.p0ntus.com/aidan", icon: SiForgejo },
{ label: "p0ntu5", href: "https://t.me/p0ntu5", icon: SiTelegram },
{ label: "+1 857-295-2295", href: "tel:+18572952295", icon: Smartphone },
{ label: "aidan@p0ntus.com", href: "mailto:aidan@p0ntus.com", icon: Mail }
]
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow container mx-auto px-4 py-12">
<div className="grow container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto text-center">
<div className="flex flex-col gap-4">
<div className="flex justify-center">
<Phone size={60} />
</div>
<h1 className="text-4xl font-bold mt-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
{t('contact.title')}
</h1>
</div>
<PageHeader
icon={<Phone size={60} />}
title="Contact"
/>
<div className="flex flex-col gap-8 mt-8">
<div className="flex flex-wrap justify-center gap-3">
{contactButtonLabels.map((label, index) => (
{contactButtons.map((button) => (
<Button
key={index}
href={contactButtonHrefs[index]}
key={button.label}
href={button.href}
target="_blank"
variant="rounded"
icon={contactButtonIcons[index]}
icon={<button.icon />}
>
{label}
</Button>
{button.label}
</Button>
))}
</div>
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="flex flex-col gap-4">
{sections.map((section) => (
<div key={section.title} className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold text-gray-200">{section.title}</h2>
{section.texts.map((text, index) => (
<p key={index} className="text-gray-300">{text}</p>
@ -83,8 +60,6 @@ export default function Contact() {
))}
</div>
</div>
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,52 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import DevicePageShell from '@/components/device/DevicePageShell';
import { deviceSlugs, getDeviceBySlug } from '@/lib/devices';
interface DevicePageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return deviceSlugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: DevicePageProps): Promise<Metadata> {
const { slug } = await params;
const device = getDeviceBySlug(slug);
if (!device) {
return {};
}
const title = `${device.name} — Devices`;
const description = device.tagline ?? device.summary?.[0] ?? 'Device details';
const canonical = `/device/${device.slug}`;
return {
title,
description,
alternates: {
canonical,
},
openGraph: {
title,
description,
url: canonical,
images: device.heroImage.src,
type: 'article',
},
};
}
export default async function DevicePage({ params }: DevicePageProps) {
const { slug } = await params;
const device = getDeviceBySlug(slug);
if (!device) {
notFound();
}
return <DevicePageShell device={device} />;
}

View file

@ -1,199 +0,0 @@
import Header from "@/components/Header"
import Footer from "@/components/Footer"
import {
Cpu,
MemoryStick,
HardDrive,
Hash,
Music,
} from "lucide-react"
import { FaGoogle } from "react-icons/fa"
import { VscTerminalLinux } from "react-icons/vsc"
import { MdOutlineAndroid } from "react-icons/md"
import { LuPackageOpen } from "react-icons/lu"
import { RiTelegram2Fill } from "react-icons/ri"
import Image from "next/image"
import Link from "@/components/objects/Link"
export default function Bonito() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow px-6 py-12 md:py-16">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row items-start gap-12 lg:gap-16">
<div className="w-full lg:w-1/3 flex justify-center">
<Image
src="/img/bonito.png"
alt="Google Pixel 3a XL (bonito)"
width={450}
height={450}
className="w-full max-w-md h-auto"
/>
</div>
<div className="w-full lg:w-2/3">
<div className="text-center lg:text-left mb-12">
<h1 className="text-4xl font-semibold mb-3 text-gray-200 flex items-center justify-center lg:justify-start">
<FaGoogle size={30} className="mr-2" />
Pixel 3a XL
</h1>
<h3 className="text-xl font-semibold mb-8 text-slate-500">bonito</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16">
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Cpu className="mr-2" />
Specs
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Cpu className="mr-3" size={20} />
<b className="mr-2">Chipset:</b> Qualcomm Snapdragon 670
</p>
<p className="flex items-center justify-center lg:justify-start">
<HardDrive className="mr-3" size={20} />
<b className="mr-2">Storage:</b> 64GB
</p>
<p className="flex items-center justify-center lg:justify-start">
<MemoryStick className="mr-3" size={20} />
<b className="mr-2">RAM:</b> 4GB
</p>
</div>
</div>
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Hash className="mr-2" />
Modifications
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<VscTerminalLinux className="mr-3" size={20} />
<b className="mr-2">Kernel Version:</b>
4.9.337
</p>
<p className="flex items-center justify-center lg:justify-start">
<MdOutlineAndroid className="mr-3" size={20} />
<b className="mr-2">ROM:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://www.ubuntu-touch.io"
>
Ubuntu Touch
</Link>
</p>
{/*<p className="flex items-center justify-center lg:justify-start">
<Hammer className="mr-3" size={20} />
<b className="mr-2">Root:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/topjohnwu/Magisk"
>
Magisk
</Link>
N/A
</p>*/}
</div>
</div>
</div>
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<LuPackageOpen className="mr-2" />
Apps
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Music className="mr-3" size={20} />
<b className="mr-2">Music:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/arubislander/uSonic"
>
uSonic
</Link>
</p>
{/*<p className="flex items-center justify-center lg:justify-start">
<Folder className="mr-3" size={20} />
<b className="mr-2">Files:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://mixplorer.com/"
>
MiXplorer Beta
</Link>
N/A
</p>*/}
<p className="flex items-center justify-center lg:justify-start">
<RiTelegram2Fill className="mr-3" size={20} />
<b className="mr-2">Telegram Client:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://open-store.io/app/teleports.ubports"
>
TELEports
</Link>
</p>
{/*<p className="flex items-center justify-center lg:justify-start">
<FaYoutube className="mr-3" size={20} />
<b className="mr-2">YouTube:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/polymorphicshade/Tubular"
>
Tubular
</Link>
</p>*/}
</div>
</div>
{/*<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Layers className="mr-2" />
Modules
</h1>
<ul className="list-disc list-inside space-y-3">
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/bindhosts/bindhosts"
>
bindhosts
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Keinta15/Magisk-iOS-Emoji"
>
Magisk iOS Emoji
</Link>
</li>
</ul>
</div>*/}
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

View file

@ -1,249 +0,0 @@
import Header from "@/components/Header"
import Footer from "@/components/Footer"
import {
Cpu,
MemoryStick,
HardDrive,
Hash,
Hammer,
Music,
Folder,
Layers,
SquarePen
} from "lucide-react"
import { FaGoogle, FaYoutube } from "react-icons/fa"
import { VscTerminalLinux } from "react-icons/vsc"
import { MdOutlineAndroid } from "react-icons/md"
import { LuPackageOpen } from "react-icons/lu"
import { RiTelegram2Fill } from "react-icons/ri"
import Image from "next/image"
import Link from "@/components/objects/Link"
import { FaStarHalfStroke, FaStar } from "react-icons/fa6"
export default function Cheetah() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow px-6 py-12 md:py-16">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row items-start gap-12 lg:gap-16">
<div className="w-full lg:w-1/3 flex justify-center">
<Image
src="/img/cheetah.png"
alt="Google Pixel 7 Pro (cheetah)"
width={450}
height={450}
className="w-full max-w-md h-auto"
/>
</div>
<div className="w-full lg:w-2/3">
<div className="text-center lg:text-left mb-12">
<h1 className="text-4xl font-semibold mb-3 text-gray-200 flex items-center justify-center lg:justify-start">
<FaGoogle size={30} className="mr-2" />
Pixel 7 Pro
</h1>
<h3 className="text-xl font-semibold mb-8 text-slate-500">cheetah</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 lg:gap-16">
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Cpu className="mr-2" />
Specs
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Cpu className="mr-3" size={20} />
<b className="mr-2">CPU:</b> Google Tensor G2
</p>
<p className="flex items-center justify-center lg:justify-start">
<HardDrive className="mr-3" size={20} />
<b className="mr-2">Storage:</b> 128GB
</p>
<p className="flex items-center justify-center lg:justify-start">
<MemoryStick className="mr-3" size={20} />
<b className="mr-2">RAM:</b> 12GB
</p>
</div>
</div>
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Hash className="mr-2" />
Modifications
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<VscTerminalLinux className="mr-3" size={20} />
<b className="mr-2">Kernel:</b>
6.1.99-android14
</p>
<p className="flex items-center justify-center lg:justify-start">
<MdOutlineAndroid className="mr-3" size={20} />
<b className="mr-2">ROM:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://crdroid.net"
>
crDroid Android 11.6
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<Hammer className="mr-3" size={20} />
<b className="mr-2">Root:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/rifsxd/KernelSU-Next"
>
KernelSU-Next
</Link>
</p>
</div>
</div>
</div>
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<LuPackageOpen className="mr-2" />
Apps
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Music className="mr-3" size={20} />
<b className="mr-2">Music:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://tidal.com"
>
Tidal
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<Folder className="mr-3" size={20} />
<b className="mr-2">Files:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://mixplorer.com/"
>
MiXplorer
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<RiTelegram2Fill className="mr-3" size={20} />
<b className="mr-2">TG Client:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://t.me/AyuGramReleases"
>
AyuGram
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<FaYoutube className="mr-3" size={20} />
<b className="mr-2">YouTube:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://revanced.app"
>
ReVanced
</Link>
</p>
</div>
</div>
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Layers className="mr-2" />
Modules
</h1>
<ul className="list-disc list-inside space-y-3">
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/bindhosts/bindhosts"
>
bindhosts
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/EmojiReplacer/Emoji-Replacer"
>
Emoji Replacer
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/PerformanC/ReZygisk"
>
ReZygisk
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/JingMatrix/LSPosed"
>
LSPosed JingMatrix
</Link>
</li>
</ul>
</div>
</div>
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<SquarePen className="mr-2" />
Review
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<b className="mr-2">Rating:</b>
<span className="flex items-center gap-1">
<FaStar size={15} /> <FaStar size={15} /> <FaStar size={15} /> <FaStar size={15} /> <FaStarHalfStroke size={15} />
</span>
</p>
<div className="space-y-4 text-sm lg:text-base">
<p>
Coming from a Galaxy A32 5G, the Pixel 7 Pro is a massive upgrade. The Tensor chip is highly performant, and with 12GB of RAM, the device is extremely snappy.
</p>
<p>
I have had some issues with battery, although this may be due to Play Integrity Fix, which is known to consume battery. However, the camera has been a massive improvement, and the photos it is capable of taking are amazing.
</p>
<p>
While the volume buttons did fall off, I do not discredit them for this, as Android makes it easy to have customizable on-screen volume buttons, something iPhones do not have.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

View file

@ -1,247 +0,0 @@
import Header from "@/components/Header"
import Footer from "@/components/Footer"
import {
Cpu,
MemoryStick,
HardDrive,
Hash,
Hammer,
Music,
Folder,
Layers,
} from "lucide-react"
import { FaGoogle, FaYoutube } from "react-icons/fa"
import { VscTerminalLinux } from "react-icons/vsc"
import { MdOutlineAndroid } from "react-icons/md"
import { LuPackageOpen } from "react-icons/lu"
import { RiTelegram2Fill } from "react-icons/ri"
import Image from "next/image"
import Link from "@/components/objects/Link"
export default function Cheetah() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow px-6 py-12 md:py-16">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row items-start gap-12 lg:gap-16">
<div className="w-full lg:w-1/3 flex justify-center">
<Image
src="/img/komodo.png"
alt="Google Pixel 9 Pro XL (komodo)"
width={450}
height={450}
className="w-full max-w-md h-auto"
/>
</div>
<div className="w-full lg:w-2/3">
<div className="text-center lg:text-left mb-12">
<h1 className="text-4xl font-semibold mb-3 text-gray-200 flex items-center justify-center lg:justify-start">
<FaGoogle size={30} className="mr-2" />
Pixel 9 Pro XL
</h1>
<h3 className="text-xl font-semibold mb-8 text-slate-500">komodo</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 lg:gap-16">
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Cpu className="mr-2" />
Specs
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Cpu className="mr-3" size={20} />
<b className="mr-2">CPU:</b> Google Tensor G4
</p>
<p className="flex items-center justify-center lg:justify-start">
<HardDrive className="mr-3" size={20} />
<b className="mr-2">Storage:</b> 128GB
</p>
<p className="flex items-center justify-center lg:justify-start">
<MemoryStick className="mr-3" size={20} />
<b className="mr-2">RAM:</b> 16GB
</p>
</div>
</div>
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Hash className="mr-2" />
Modifications
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<VscTerminalLinux className="mr-3" size={20} />
<b className="mr-2">Kernel:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/WildKernels/GKI_KernelSU_SUSFS"
>
6.1.138-android14-SUSFS-Wild
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<MdOutlineAndroid className="mr-3" size={20} />
<b className="mr-2">ROM:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://developer.android.com/about/versions/16/qpr2"
>
Android 16 Beta QPR2
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<Hammer className="mr-3" size={20} />
<b className="mr-2">Root:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/rifsxd/KernelSU-Next"
>
KernelSU-Next
</Link>
</p>
</div>
</div>
</div>
<div className="space-y-8">
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<LuPackageOpen className="mr-2" />
Apps
</h1>
<div className="space-y-4">
<p className="flex items-center justify-center lg:justify-start">
<Music className="mr-3" size={20} />
<b className="mr-2">Music:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://tidal.com"
>
Tidal
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<Folder className="mr-3" size={20} />
<b className="mr-2">Files:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://mixplorer.com/"
>
MiXplorer
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<RiTelegram2Fill className="mr-3" size={20} />
<b className="mr-2">TG Client:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://t.me/AyuGramReleases"
>
AyuGram
</Link>
</p>
<p className="flex items-center justify-center lg:justify-start">
<FaYoutube className="mr-3" size={20} />
<b className="mr-2">YouTube:</b>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://revanced.app"
>
ReVanced
</Link>
</p>
</div>
</div>
<div className="text-center lg:text-left">
<h1 className="text-3xl font-semibold mb-6 text-gray-200 flex items-center justify-center lg:justify-start">
<Layers className="mr-2" />
Modules
</h1>
<ul className="list-disc list-inside space-y-3">
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://modules.lol/module/kowx712-bindhosts"
>
bindhosts
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/EmojiReplacer/Emoji-Replacer"
>
Emoji Replacer
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://modules.lol/module/entr0pia-f-droid-privileged-extension-installer"
>
F-Droid Privileged Extension
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://modules.lol/module/sidex15-susfs"
>
SUSFS-FOR-KERNELSU
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://modules.lol/module/5ec1cff-tricky-store"
>
Tricky Store
</Link>
</li>
<li>
<Link
className="underline hover:glow transition-all"
target="_blank"
rel="noopener noreferrer"
href="https://modules.lol/module/dpejoh-and-yuri-yurikey"
>
Yuri Keybox Manager
</Link>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

15
app/device/layout.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
export default function DeviceLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="w-full px-6 pt-16 md:pt-20 pb-6 md:pb-10">
<div className="max-w-7xl mx-auto">
{children}
</div>
</div>
);
}

311
app/docs/DocsPageClient.tsx Normal file
View file

@ -0,0 +1,311 @@
'use client'
import { useState, useMemo, useEffect, useCallback } from 'react'
import { BookText, Menu, Info } from 'lucide-react'
import DocsSidebar from '@/components/docs/DocsSidebar'
import DocsSearch from '@/components/docs/DocsSearch'
import FunctionDoc from '@/components/docs/FunctionDoc'
import TypeDoc from '@/components/docs/TypeDoc'
import { searchDocs } from '@/lib/docs/search'
import { cn } from '@/lib/utils'
import { surfaces, colors, effects } from '@/lib/theme'
import type { DocNavigation, DocItem } from '@/lib/docs/types'
interface DocsPageClientProps {
navigation: DocNavigation
allItems: DocItem[]
}
type ViewMode = 'parsed' | 'html'
const ITEMS_PER_PAGE = 20
export default function DocsPageClient({
navigation,
allItems,
}: DocsPageClientProps) {
const [viewMode, setViewMode] = useState<ViewMode>('parsed')
const [searchQuery, setSearchQuery] = useState('')
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)
const [visibleItems, setVisibleItems] = useState(ITEMS_PER_PAGE)
const [isLoading, setIsLoading] = useState(false)
// Create a set of all available type IDs for validation
const availableTypeIds = useMemo(() => {
return new Set(allItems.map(item => item.name))
}, [allItems])
useEffect(() => {
const saved = localStorage.getItem('docs-view-mode')
if (saved === 'parsed' || saved === 'html') {
setViewMode(saved)
}
}, [])
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode)
localStorage.setItem('docs-view-mode', mode)
}
const filteredItems = useMemo(() => {
let items = allItems
if (searchQuery) {
items = searchDocs(items, searchQuery)
}
return items
}, [allItems, searchQuery])
const displayedItems = useMemo(() => {
return filteredItems.slice(0, visibleItems)
}, [filteredItems, visibleItems])
const hasMoreItems = visibleItems < filteredItems.length
const loadMoreItems = useCallback(() => {
setIsLoading(true)
setTimeout(() => {
setVisibleItems(prev => Math.min(prev + ITEMS_PER_PAGE, filteredItems.length))
setIsLoading(false)
}, 300)
}, [filteredItems.length])
useEffect(() => {
setVisibleItems(ITEMS_PER_PAGE)
}, [searchQuery])
useEffect(() => {
if (!hasMoreItems || isLoading) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreItems()
}
},
{ threshold: 0.1 }
)
const sentinel = document.getElementById('scroll-sentinel')
if (sentinel) observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMoreItems, isLoading, loadMoreItems])
return (
<div className="w-full px-2 sm:px-6 pb-16">
<div className="my-12 text-center">
<div className="flex justify-center mb-6">
<BookText size={60} />
</div>
<h1 className="text-4xl font-bold mb-2 glow" style={{ color: colors.text.primary }}>Documentation</h1>
<p style={{ color: colors.text.muted }}>Complete API reference for aidxnCC</p>
<div className="mt-6 flex justify-center items-center gap-3">
<span className="text-sm font-medium" style={{ color: colors.text.secondary }}>
Parsed
</span>
<button
onClick={() => handleViewModeChange(viewMode === 'parsed' ? 'html' : 'parsed')}
className="relative inline-flex h-7 w-14 items-center rounded-full border-2 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
style={{
backgroundColor: viewMode === 'html' ? colors.accents.ai : colors.backgrounds.card,
borderColor: viewMode === 'html' ? colors.accents.ai : colors.borders.default
}}
>
<span className="sr-only">Toggle view mode</span>
<span
className={`${viewMode === 'parsed' ? 'translate-x-1' : 'translate-x-7'} inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-sm`}
/>
</button>
<span className="text-sm font-medium" style={{ color: colors.text.secondary }}>
HTML
</span>
</div>
</div>
<div className="container mx-auto max-w-7xl">
{viewMode === 'html' ? (
<iframe
src="/docs/html/index.html"
className="w-full border-0 rounded-lg"
style={{
height: 'calc(100vh - 300px)',
minHeight: '600px',
backgroundColor: colors.backgrounds.card,
}}
title="TypeDoc HTML Documentation"
/>
) : (
<>
<button
onClick={() => setIsMobileSidebarOpen(true)}
className={cn(
'lg:hidden fixed bottom-6 right-6 z-40',
'flex items-center gap-2 rounded-lg px-4 py-3',
'border-2 shadow-lg',
effects.transitions.colors
)}
style={{
backgroundColor: colors.backgrounds.card,
borderColor: colors.borders.default,
color: colors.text.secondary
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
e.currentTarget.style.borderColor = colors.borders.hover
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = colors.backgrounds.card
e.currentTarget.style.borderColor = colors.borders.default
}}
aria-label="Open navigation menu"
>
<Menu className="h-5 w-5" />
<span className="text-sm font-medium">Menu</span>
</button>
{isMobileSidebarOpen && (
<>
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setIsMobileSidebarOpen(false)}
aria-hidden="true"
/>
<div className="fixed inset-y-0 left-0 w-80 z-50 lg:hidden overflow-y-auto">
<DocsSidebar
navigation={navigation}
onClose={() => setIsMobileSidebarOpen(false)}
/>
</div>
</>
)}
<div className="flex gap-6">
<DocsSidebar navigation={navigation} className="hidden lg:block" />
<main className="flex-1 w-full lg:max-w-4xl space-y-6">
<DocsSearch
items={allItems}
onSearch={setSearchQuery}
/>
{!searchQuery && (
<section
className={cn(
'rounded-xl p-6 border-2 relative overflow-hidden',
effects.transitions.all
)}
style={{
backgroundColor: colors.accents.docsBg,
borderColor: colors.accents.docsBorder,
boxShadow: `0 0 20px ${colors.accents.docsGlow}`,
}}
>
<div
className="absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl opacity-20"
style={{ backgroundColor: colors.accents.docsBlur }}
/>
<div className="relative">
<div className="flex items-center gap-3 mb-4">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: colors.accents.docsIconBg,
color: colors.accents.docs
}}
>
<Info className="h-5 w-5" />
</div>
<h2
className="text-2xl font-bold"
style={{ color: colors.text.primary }}
>
Getting Started
</h2>
</div>
<p
className="leading-relaxed mb-6 text-base"
style={{ color: colors.text.body }}
>
Welcome to the aidxnCC documentation! This reference contains
detailed information about all services, utilities, types, and
components available in my codebase.
</p>
<div
className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5"
>
{[
{ label: 'Services', desc: 'Core business logic for AI, Device, and Domain management' },
{ label: 'Utils', desc: 'Utility functions for formatting, styling, and common operations' },
{ label: 'Types', desc: 'TypeScript type definitions and interfaces' },
{ label: 'Theme', desc: 'Design tokens, colors, and surface styles' },
{ label: 'Devices', desc: 'Device specifications and portfolio management' },
{ label: 'Domains', desc: 'Domain portfolio and DNS management utilities' },
{ label: 'Docs', desc: 'Documentation parsing and search functionality' },
].map((item) => (
<div
key={item.label}
className="p-3 rounded-lg border"
style={{
backgroundColor: colors.backgrounds.card,
borderColor: colors.borders.subtle,
}}
>
<strong
className="text-sm font-semibold block mb-1"
style={{ color: colors.accents.docs }}
>
{item.label}
</strong>
<span
className="text-xs leading-relaxed"
style={{ color: colors.text.muted }}
>
{item.desc}
</span>
</div>
))}
</div>
</div>
</section>
)}
{/* Documentation Items */}
{displayedItems.length > 0 ? (
<div className="space-y-6">
{displayedItems.map((item) => (
<section
key={item.id}
className={cn(surfaces.section.default)}
>
{item.kind === 'function' || item.kind === 'method' ? (
<FunctionDoc item={item} availableTypeIds={availableTypeIds} />
) : (
<TypeDoc item={item} availableTypeIds={availableTypeIds} />
)}
</section>
))}
</div>
) : searchQuery ? (
<section className={cn(surfaces.section.default, 'text-center')}>
<p style={{ color: colors.text.muted }}>
No results found for &quot;{searchQuery}&quot;
</p>
<p className="mt-2 text-sm" style={{ color: colors.text.disabled }}>
Try adjusting your search query
</p>
</section>
) : null}
</main>
</div>
</>
)}
</div>
</div>
)
}

19
app/docs/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Documentation | aidxnCC',
description:
'Complete API documentation for aidxnCC services, utilities, types, and theme system.',
}
export default function DocsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="w-full">
{children}
</div>
)
}

11
app/docs/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { loadDocumentation } from '@/lib/docs/loader'
import { buildNavigation, getAllItems } from '@/lib/docs/parser'
import DocsPageClient from './DocsPageClient'
export default function DocsPage() {
const sections = loadDocumentation()
const navigation = buildNavigation(sections)
const allItems = getAllItems(sections)
return <DocsPageClient navigation={navigation} allItems={allItems} />
}

View file

@ -0,0 +1,49 @@
import { notFound } from 'next/navigation'
import DomainTimeline from '@/components/domains/DomainTimeline'
import DomainDetails from '@/components/domains/DomainDetails'
import { ArrowLeft, Globe } from 'lucide-react'
import Link from 'next/link'
import { domains } from '@/lib/domains/data'
export async function generateStaticParams() {
return domains.map((domain) => ({
domain: domain.domain,
}))
}
export default async function DomainPage({ params }: { params: Promise<{ domain: string }> }) {
const { domain: domainParam } = await params
const domain = domains.find(d => d.domain === domainParam)
if (!domain) {
notFound()
}
return (
<div className="grow container mx-auto px-4 py-12">
<div className="max-w-5xl mx-auto">
<Link href="/domains" className="inline-flex items-center gap-2 text-gray-400 hover:text-gray-300 mb-8 transition-colors">
<ArrowLeft />
Back to Domains
</Link>
<div className="mb-8">
<div className="flex items-center gap-4 mb-4">
<Globe className="w-10 h-10 text-gray-400" />
<div>
<h1 className="text-4xl font-bold text-gray-200 glow">
{domain.domain}
</h1>
<p className="text-gray-400 mt-1">{domain.usage}</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<DomainDetails domain={domain} />
<DomainTimeline domain={domain} />
</div>
</div>
</div>
)
}

View file

@ -1,45 +1,144 @@
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import { Link } from "lucide-react"
import { TbCurrencyDollarOff } from "react-icons/tb";
import domains from "@/public/data/domains.json"
'use client'
import { useState, useMemo } from 'react'
import DomainCard from '@/components/domains/DomainCard'
import DomainFilters from '@/components/domains/DomainFilters'
import PageHeader from '@/components/objects/PageHeader'
import { Link, AlertCircle } from "lucide-react"
import { TbCurrencyDollarOff } from "react-icons/tb"
import { domains } from "@/lib/domains/data"
import { getDaysUntilExpiration, getOwnershipDuration, getOwnershipMonths } from '@/lib/domains/utils'
import type {
DomainCategory,
DomainStatus,
DomainRegistrarId,
DomainSortOption
} from '@/lib/types'
export default function Domains() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategories, setSelectedCategories] = useState<DomainCategory[]>([])
const [selectedStatuses, setSelectedStatuses] = useState<DomainStatus[]>([])
const [selectedRegistrars, setSelectedRegistrars] = useState<DomainRegistrarId[]>([])
const [sortBy, setSortBy] = useState<DomainSortOption>('name')
const uniqueRegistrars = useMemo<DomainRegistrarId[]>(() => {
return Array.from(new Set(domains.map(d => d.registrar))).sort()
}, [])
const filteredAndSortedDomains = useMemo(() => {
const filtered = domains.filter(domain => {
const matchesSearch = searchQuery === '' ||
domain.domain.toLowerCase().includes(searchQuery.toLowerCase()) ||
domain.usage.toLowerCase().includes(searchQuery.toLowerCase()) ||
domain.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesCategory = selectedCategories.length === 0 ||
selectedCategories.includes(domain.category)
const matchesStatus = selectedStatuses.length === 0 ||
selectedStatuses.includes(domain.status)
const matchesRegistrar = selectedRegistrars.length === 0 ||
selectedRegistrars.includes(domain.registrar)
return matchesSearch && matchesCategory && matchesStatus && matchesRegistrar
})
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.domain.localeCompare(b.domain)
case 'expiration':
return getDaysUntilExpiration(a) - getDaysUntilExpiration(b)
case 'ownership':
return getOwnershipDuration(b) - getOwnershipDuration(a)
case 'registrar':
return a.registrar.localeCompare(b.registrar)
default:
return 0
}
})
return filtered
}, [searchQuery, selectedCategories, selectedStatuses, selectedRegistrars, sortBy])
const stats = useMemo(() => {
const expiringSoon = domains.filter(d => getDaysUntilExpiration(d) <= 90).length
const totalDomains = domains.length
const activeDomains = domains.filter(d => d.status === 'active').length
const avgOwnershipYears = domains.reduce((acc, d) => acc + getOwnershipDuration(d), 0) / domains.length
const avgOwnershipMonths = domains.reduce((acc, d) => acc + getOwnershipMonths(d), 0) / domains.length
return { expiringSoon, totalDomains, activeDomains, avgOwnershipYears, avgOwnershipMonths }
}, [])
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto flex flex-col items-center text-center">
<div className="flex flex-col gap-4">
<div className="flex justify-center">
<Link size={60} />
</div>
<h1 className="text-4xl font-bold mt-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
My Domains
</h1>
</div>
<div className="grow container mx-auto px-4 py-12">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col items-center text-center mb-8">
<PageHeader
icon={<Link size={60} />}
title="My Domain Portfolio"
/>
<div className="mb-4 p-4 pt-8 flex flex-col items-center space-y-2">
<TbCurrencyDollarOff size={26} className="text-red-500" />
<span className="text-red-500 font-medium text-center mt-1 mb-0">
<TbCurrencyDollarOff size={26} className="text-gray-500" />
<span className="text-gray-400 font-medium text-center mt-1 mb-0">
These domains are not for sale.
</span>
<span className="text-red-500 font-medium text-center">
<span className="text-gray-400 font-medium text-center">
All requests to buy them will be declined.
</span>
</div>
<div className="p-6 pt-0 w-full">
{domains.map(domain => (
<div key={domain.id} className="mb-4">
<h2 className="text-2xl font-semibold text-gray-200">
{domain.domain}
</h2>
<p className="text-gray-300">{domain.usage}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full max-w-3xl mb-8">
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-lg p-4">
<div className="text-2xl font-bold text-gray-300">{stats.totalDomains}</div>
<div className="text-sm text-gray-500">Total Domains</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-lg p-4">
<div className="text-2xl font-bold text-gray-300">{stats.activeDomains}</div>
<div className="text-sm text-gray-500">Active</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-lg p-4">
<div className="text-2xl font-bold text-gray-300 flex items-center justify-center gap-1">
{stats.expiringSoon > 0 && <AlertCircle className="text-orange-500" />}
{stats.expiringSoon}
</div>
))}
<div className="text-sm text-gray-500">Expiring Soon</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-lg p-4">
<div className="text-2xl font-bold text-gray-300 flex items-center justify-center gap-1">
{stats.avgOwnershipYears < 1
? `${Math.round(stats.avgOwnershipMonths)} mo`
: `${stats.avgOwnershipYears.toFixed(1)} yr`}
</div>
<div className="text-sm text-gray-500">Avg Time Owned</div>
</div>
</div>
</div>
</main>
<Footer />
<DomainFilters
onSearchChange={setSearchQuery}
onCategoryChange={setSelectedCategories}
onStatusChange={setSelectedStatuses}
onRegistrarChange={setSelectedRegistrars}
onSortChange={setSortBy}
registrars={uniqueRegistrars}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredAndSortedDomains.map(domain => (
<DomainCard key={domain.domain} domain={domain} />
))}
</div>
{filteredAndSortedDomains.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No domains match your filters</p>
</div>
)}
</div>
</div>
)
}

View file

@ -34,18 +34,20 @@
@layer utilities {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--background-start-rgb: 31, 41, 55;
--background-end-rgb: 17, 24, 39;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
180deg,
rgb(var(--background-start-rgb)) 0%,
rgb(var(--background-end-rgb)) 100%
);
background-attachment: fixed;
background-size: 100% 100%;
background-repeat: no-repeat;
}
}
@ -66,6 +68,15 @@ html {
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hover\:glow {
transition: text-shadow 0.3s ease;
text-shadow: 0 0 0px rgba(255, 255, 255, 0);

View file

@ -1,21 +0,0 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import enUS from '../public/locales/en-US.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
'en-US': {
translation: enUS
}
},
fallbackLng: 'en-US',
interpolation: {
escapeValue: false
}
});
export default i18n

View file

@ -1,25 +1,43 @@
import React from 'react'
import { Metadata, Viewport } from 'next'
import './globals.css'
import { GeistSans } from 'geist/font/sans'
import AnimatedTitle from '../components/AnimatedTitle'
import I18nProvider from '../components/I18nProvider'
import AnimatedTitle from '../components/objects/AnimatedTitle'
import { Header, Footer } from '../components/navigation'
import { footerMessages } from '../components/objects/footerMessages'
export const dynamic = 'force-dynamic'
const getFooterMessageIndex = (): number | undefined => {
const totalMessages = footerMessages.length
if (!totalMessages) {
return undefined
}
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const buffer = new Uint32Array(1)
crypto.getRandomValues(buffer)
return buffer[0] % totalMessages
}
return Math.floor(Math.random() * totalMessages)
}
export const metadata: Metadata = {
title: 'aidxn.cc',
title: 'aidan.so',
description: "The Internet home of Aidan. Come on in!",
authors: [{ name: 'aidxn.cc' }],
authors: [{ name: 'aidan.so' }],
robots: 'index, follow',
metadataBase: new URL('https://aidxn.cc'),
metadataBase: new URL('https://aidan.so'),
openGraph: {
type: "website",
url: "https://aidxn.cc",
title: "aidxn.cc",
url: "https://aidan.so",
title: "aidan.so",
description: "The Internet home of Aidan. Come on in!",
siteName: "aidxn.cc",
siteName: "aidan.so",
images: [
{
url: "https://aidxn.cc/android-icon-192x192.png",
url: "https://aidan.so/android-icon-192x192.png",
width: 192,
height: 192,
},
@ -58,14 +76,18 @@ export default function RootLayout({
}: {
children: React.ReactNode
}) {
const footerMessageIndex = getFooterMessageIndex()
return (
<html lang="en" className="dark">
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
<html lang="en" className="dark h-full">
<body className={`${GeistSans.className} bg-gray-900 text-gray-100 flex min-h-screen flex-col`}>
<AnimatedTitle />
<I18nProvider>
<Header />
<main className="flex-1 w-full">
{children}
</I18nProvider>
</main>
<Footer footerMessageIndex={footerMessageIndex} />
</body>
</html>
);
}
}

View file

@ -1,21 +1,14 @@
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import PageHeader from '@/components/objects/PageHeader'
import { BookOpen } from 'lucide-react'
export default function Manifesto() {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="grow container mx-auto px-4 py-12">
<div className="grow container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto text-center">
<div className="flex flex-col gap-4">
<div className="flex justify-center">
<BookOpen size={60} />
</div>
<h1 className="text-4xl font-bold mt-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
Internet Manifesto
</h1>
</div>
<PageHeader
icon={<BookOpen size={60} />}
title="Internet Manifesto"
/>
<div className="px-6 pt-12">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
1. Empathy and Understanding
@ -74,8 +67,6 @@ export default function Manifesto() {
</p>
</div>
</div>
</main>
<Footer />
</div>
)
}

View file

@ -1,7 +1,5 @@
"use client"
import Header from '@/components/Header'
import Footer from '@/components/Footer'
import Button from '@/components/objects/Button'
import LastPlayed from '@/components/widgets/NowPlaying'
import LiveIndicator from '@/components/widgets/LiveIndicator'
@ -22,29 +20,37 @@ import {
SiPostgresql
} from 'react-icons/si'
import { useTranslation } from 'react-i18next'
import {TbHeartHandshake, TbUserHeart, TbMessage} from "react-icons/tb";
import {BiDonateHeart} from "react-icons/bi";
export default function Home() {
const { t } = useTranslation()
const mainStrings: string[][] = [
t('home.whoAmI', { returnObjects: true }) as string[],
t('home.whatIDo', { returnObjects: true }) as string[],
t('home.whereYouAre', { returnObjects: true }) as string[]
[
"Hey there! My name is Aidan, and I'm a systems administrator, full-stack developer, and student from the Boston area. I primarily work with Linux, Docker, Next.js, Tailwind CSS and TypeScript.",
"My favorite projects and hobbies revolve around web development and SysAdmin. Most of my work is released into the public domain.",
"I'm also a huge advocate for AI and it's practical applications to programming and life itself. I am fond of open-source models the most, specifically Qwen3!",
"When I'm not programming, I can be found re-flashing my phone with a new custom ROM and jumping between projects. I tend to be quite depressed, but I make do."
],
[
"I'm at my best when I'm doing system administration and development in TypeScript. I frequently implement AI into my workflow.",
"I manage three servers, including a mailserver (against my better judgement). I'm also crazy enough to self-host LLMs running on CPU.",
"My biggest project is p0ntus, a cloud services provider which I self-host and maintain. It features most services you would find from large companies like Google, although everything is free and open-source."
],
[
"I am not here to brag about my accomplishments or plug my shitty SaaS. That's why I've made every effort to make this website as personal and fun as possible.",
"I hope you find this website an interesting place to find more about me, but also learn something new; maybe inspire a new project or two.",
"In a technical sense, this site is hosted on my dedicated server hosted in Buffalo, New York by ColoCrossing."
]
]
const mainSections = [
t('home.sections.whoIAm'),
t('home.sections.whatIDo'),
t('home.sections.whereYouAre')
"Who I am",
"What I do",
"Where you are"
]
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="w-full">
<div className="w-full">
<div className="my-12 text-center">
<Image
src="/ihatenodejs.jpg"
@ -53,8 +59,8 @@ export default function Home() {
height={150}
className="rounded-full mx-auto mb-6 border-4 border-gray-700 hover:border-gray-600 transition-colors duration-300"
/>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">{t('home.profile.name')}</h1>
<p className="text-gray-400 text-xl">{t('home.profile.description')}</p>
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">Aidan Honor</h1>
<p className="text-gray-400 text-xl">SysAdmin, Developer, and Student</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
@ -69,23 +75,23 @@ export default function Home() {
{mainSections.map((section, secIndex) => (
<section key={secIndex} className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{section === t('home.sections.whereYouAre') ? (
<h2 className="text-2xl font-semibold mb-4 text-gray-200">{section === "Where you are" ? (
<div className="flex flex-row items-center gap-2">
<TbHeartHandshake />
<span className="align-middle">{section}</span>
</div>
) : section === t('home.sections.whoIAm') ? (
) : section === "Who I am" ? (
<div className="flex flex-row items-center gap-2">
<UserCircle />
<span className="align-middle">{section}</span>
</div>
) : section === t('home.sections.whatIDo') ? (
) : section === "What I do" ? (
<div className="flex flex-row items-center gap-2">
<TbUserHeart />
<span className="align-middle">{section}</span>
</div>
) : (section)}</h2>
{section === t('home.sections.whatIDo') && (
{section === "What I do" && (
<div className="flex flex-row items-center justify-center gap-4 my-8">
<SiNextdotjs size={38} />
<SiTypescript size={38} />
@ -107,76 +113,74 @@ export default function Home() {
<section id="contact" className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="flex flex-row items-center gap-2 text-2xl font-semibold mb-4 text-gray-200">
<TbMessage />
{t('home.contact.title')}
Send me a message
</h2>
<p className="text-gray-300 mb-6">{t('home.contact.description')}</p>
<p className="text-gray-300 mb-6">Feel free to reach out for feedback, collaborations, or just a hello! I aim to answer all of my messages in a timely fashion, but please have patience.</p>
<Button
href={'/contact'}
icon={<Mail size={16} />}
>
{t('home.contact.button')}
Contact Me
</Button>
</section>
<section id="donation" className="p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
<h2 className="flex flex-row items-center gap-2 text-2xl font-semibold mb-4 text-gray-200">
<BiDonateHeart />
{t('home.donation.title')}
Support my work
</h2>
<p className="text-gray-300 mb-6">{t('home.donation.description')}</p>
<h4 className="text-lg font-semibold mb-2 text-gray-200">{t('home.donation.charities.title')}</h4>
<p className="text-gray-300 mb-6">Feeling generous? Support me or one of the causes I support!</p>
<h4 className="text-lg font-semibold mb-2 text-gray-200">Charities</h4>
<div className="grid grid-cols-1 md:grid-cols-2 md:text-sm gap-3">
<Button
href="https://unsilenced.org"
icon={<FaHandcuffs />}
target="_blank"
>
{t('home.donation.charities.unsilenced')}
Unsilenced
</Button>
<Button
href="https://drugpolicy.org"
icon={<PillBottle size={16} />}
target="_blank"
>
{t('home.donation.charities.drugpolicy')}
Drug Policy Alliance
</Button>
<Button
href="https://www.aclu.org"
icon={<Scale size={16} />}
target="_blank"
>
{t('home.donation.charities.aclu')}
ACLU
</Button>
<Button
href="https://www.epicrestartfoundation.org"
icon={<BsArrowClockwise size={16} />}
target="_blank"
>
{t('home.donation.charities.epic-restart')}
EPIC Restart Foundation
</Button>
</div>
<h4 className="text-lg font-semibold mt-5 mb-2 text-gray-200">{t('home.donation.donate.title')}</h4>
<h4 className="text-lg font-semibold mt-5 mb-2 text-gray-200">Donate to Me</h4>
<div className="grid grid-cols-1 md:grid-cols-2 md:text-sm gap-3">
<Button
href="https://donate.stripe.com/6oEeWVcXs9L9ctW4gj"
icon={<CreditCard size={16} />}
target="_blank"
>
{t('home.donation.donate.stripe')}
Stripe
</Button>
<Button
href="https://github.com/sponsors/ihatenodejs"
icon={<SiGithubsponsors size={16} />}
target="_blank"
>
{t('home.donation.donate.github')}
GitHub Sponsors
</Button>
</div>
</section>
</div>
</main>
<Footer />
</div>
);
}

View file

@ -5,7 +5,7 @@ export const robots: MetadataRoute.Robots = {
userAgent: '*',
allow: '/',
},
sitemap: 'https://aidxn.cc/sitemap.xml',
sitemap: 'https://aidan.so/sitemap.xml',
}
export default function handler(): MetadataRoute.Robots {

View file

@ -3,64 +3,70 @@ import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://aidxn.cc',
url: 'https://aidan.so',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: 'https://aidxn.cc/about',
url: 'https://aidan.so/about',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: 'https://aidxn.cc/ai',
url: 'https://aidan.so/ai',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: 'https://aidxn.cc/ai/claude',
url: 'https://aidan.so/ai/usage',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: 'https://aidxn.cc/contact',
url: 'https://aidan.so/contact',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://aidxn.cc/domains',
url: 'https://aidan.so/domains',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://aidxn.cc/device/cheetah',
url: 'https://aidan.so/device/cheetah',
lastModified: new Date(),
changeFrequency: 'weekly' /* yes, i really re-flash roms this often */,
priority: 0.8,
},
{
url: 'https://aidxn.cc/device/bonito',
url: 'https://aidan.so/device/bonito',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: 'https://aidxn.cc/device/komodo',
url: 'https://aidan.so/device/komodo',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: 'https://aidxn.cc/manifesto',
url: 'https://aidan.so/device/jm21',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: 'https://aidan.so/manifesto',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.7,
},
]
}
}

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[test]
root = "."

View file

@ -1,21 +0,0 @@
import { TbCopyrightOff } from "react-icons/tb"
import { RxDividerVertical } from "react-icons/rx"
import Link from 'next/link'
import RandomFooterMsg from "./objects/RandomFooterMsg"
export default function Footer() {
return (
<footer className="bg-gray-800 text-gray-400 py-4">
<div className="flex flex-col sm:flex-row container mx-auto px-4 text-center items-center justify-center">
<Link href="https://git.p0ntus.com/aidan/aidxnCC" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
<div className="flex items-center justify-center">
<TbCopyrightOff className="text-md mr-2" />
Open Source and Copyright-Free
</div>
</Link>
<RxDividerVertical className="hidden sm:block mx-4"/>
<RandomFooterMsg />
</div>
</footer>
)
}

View file

@ -1,473 +0,0 @@
"use client"
import React, { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import {
House,
Link as LinkIcon,
User,
Phone,
BookOpen,
X,
Menu,
Globe,
ChevronDown,
ChevronRight,
Brain,
Smartphone
} from 'lucide-react'
import { TbUserHeart } from 'react-icons/tb'
import { SiClaude, SiGoogle } from 'react-icons/si'
import { useTranslation } from 'react-i18next'
interface NavItemProps {
href: string;
icon: React.ElementType;
children: React.ReactNode;
}
const NavItem = ({ href, icon, children }: NavItemProps) => (
<div className="nav-item">
<Link href={href} className="flex items-center text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300">
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
{children}
</Link>
</div>
);
interface DropdownNavItemProps {
id: string;
href: string;
icon: React.ElementType;
children: React.ReactNode;
dropdownContent: React.ReactNode;
isMobile?: boolean;
isOpen?: boolean;
onOpenChange?: (id: string | null) => void;
}
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onOpenChange?.(null);
}
};
if (isMobile && isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [isMobile, isOpen, onOpenChange]);
const handleMouseEnter = () => {
if (!isMobile) {
onOpenChange?.(id);
}
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (!isMobile) {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
onOpenChange?.(null);
}
};
const handleClick = (e: React.MouseEvent) => {
if (isMobile) {
e.preventDefault();
e.stopPropagation();
onOpenChange?.(isOpen ? null : id);
}
};
return (
<div
className="nav-item relative"
ref={dropdownRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Link
href={href}
onClick={isMobile ? handleClick : undefined}
className="flex items-center justify-between text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 w-full"
>
<span className="flex items-center flex-1">
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
<span>{children}</span>
</span>
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
</Link>
{isOpen && (
<>
{/* Invisible bridge to handle gap */}
{!isMobile && (
<div className="absolute left-0 top-full w-full h-1 z-50" />
)}
<div
className={`${
isMobile
? 'relative w-full mt-2 ml-5 pr-4'
: 'absolute left-0 mt-1 z-50 flex'
}`}
>
{dropdownContent}
</div>
</>
)}
</div>
);
};
interface NestedDropdownItemProps {
children: React.ReactNode;
nestedContent: React.ReactNode;
isMobile?: boolean;
}
const NestedDropdownItem = ({ children, nestedContent, isMobile = false }: NestedDropdownItemProps) => {
const [isOpen, setIsOpen] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = () => {
if (!isMobile) {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsOpen(true);
}
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (!isMobile) {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget && itemRef.current?.contains(relatedTarget)) {
return;
}
timeoutRef.current = setTimeout(() => setIsOpen(false), 100);
}
};
const handleClick = (e: React.MouseEvent) => {
if (isMobile) {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}
};
if (isMobile) {
return (
<div
className="relative"
ref={itemRef}
>
<button
onClick={handleClick}
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
>
<span className="flex items-center flex-1">
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
{children}
</span>
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
</button>
{isOpen && (
<div className="relative mt-2 ml-5 pr-4 space-y-1">
{nestedContent}
</div>
)}
</div>
);
}
return (
<div
className="relative"
ref={itemRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
onClick={handleClick}
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
>
<span className="flex items-center flex-1">
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
{children}
</span>
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
</button>
{isOpen && (
<>
{/* Invisible bridge to handle gap */}
<div className="absolute left-full top-0 w-2 h-full z-50" />
<div className="absolute left-full top-0 ml-2 w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
{nestedContent}
</div>
</>
)}
</div>
);
};
const LanguageSelector = () => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const languages = [
{ code: 'en-US', name: 'English' },
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const changeLanguage = async (lng: string) => {
await i18n.changeLanguage(lng);
setIsOpen(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isMobile) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isMobile]);
const handleMouseEnter = () => {
if (!isMobile) {
setIsOpen(true);
}
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (!isMobile) {
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
setIsOpen(false);
}
};
const handleClick = () => {
if (isMobile) {
setIsOpen(!isOpen);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
if (e.key === 'Enter' || e.key === ' ') {
setIsOpen(!isOpen);
}
};
return (
<div
className="relative"
ref={dropdownRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
onClick={handleClick}
onKeyDown={handleKeyDown}
className={`flex items-center ${isMobile ? 'justify-between' : ''} text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 ${isMobile ? 'w-full' : ''}`}
aria-expanded={isOpen}
aria-haspopup="true"
>
<span className="flex items-center flex-1">
<Globe className="text-md mr-2" strokeWidth={2.5} size={20} />
<span>{languages.find(lang => lang.code === i18n.language)?.name || 'English'}</span>
</span>
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
</button>
{isOpen && (
<>
{/* Invisible bridge to handle gap */}
{!isMobile && (
<div className="absolute right-0 top-full w-56 h-2 z-50" />
)}
<div
className={`${
isMobile
? 'relative w-full mt-2 ml-4 pr-4 space-y-1'
: 'absolute right-0 mt-2 w-56 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50'
}`}
role="menu"
aria-orientation="vertical"
aria-labelledby="language-menu"
>
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`block w-full text-left ${isMobile ? 'px-4 py-2.5' : 'px-5 py-3'} ${isMobile ? 'text-sm' : 'text-base'} rounded-md ${
i18n.language === lang.code
? 'text-white bg-gray-700/50'
: 'text-gray-300 hover:text-white hover:bg-gray-700/50'
} transition-all duration-300`}
role="menuitem"
>
{lang.name}
</button>
))}
</div>
</>
)}
</div>
);
};
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const toggleMenu = () => {
setIsOpen(!isOpen);
if (isOpen) {
setActiveDropdown(null);
}
};
const handleDropdownChange = (id: string | null) => {
setActiveDropdown(id);
};
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const aboutDropdownContent = (
<>
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
<Link href="/about" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300 cursor-pointer`}>
<TbUserHeart className="mr-3" size={18} />
Get to Know Me
</Link>
<NestedDropdownItem
isMobile={isMobile}
nestedContent={
<>
<Link href="/device/bonito" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
<SiGoogle className="mr-3" size={18} />
Pixel 3a XL (bonito)
</Link>
<Link href="/device/cheetah" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
<SiGoogle className="mr-3" size={18} />
Pixel 7 Pro (cheetah)
</Link>
<Link href="/device/komodo" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
<SiGoogle className="mr-3" size={18} />
Pixel 9 Pro (komodo)
</Link>
</>
}
>
Devices
</NestedDropdownItem>
</div>
</>
);
const aiDropdownContent = (
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
<Link href="/ai/claude" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
<SiClaude className="mr-3" size={18} />
Claude Usage
</Link>
</div>
);
return (
<>
<div
className={`fixed inset-0 z-30 pointer-events-none transition-all duration-300 ${
activeDropdown && !isMobile
? 'backdrop-blur-sm opacity-100'
: 'backdrop-blur-none opacity-0'
}`}
/>
<header className="bg-gray-800 relative">
{isOpen && (
<div
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
onClick={toggleMenu}
/>
)}
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
<Link href="/" className="text-gray-300 hover:text-white text-2xl font-bold transition-all duration-300 hover:glow">
aidxn.cc
</Link>
<button onClick={toggleMenu} className="lg:hidden text-gray-300 focus:outline-hidden">
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
</button>
<ul className={`flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4 absolute lg:static bg-gray-800 lg:bg-transparent w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50 ${isOpen ? 'flex' : 'hidden lg:flex'}`}>
<NavItem href="/" icon={House}>Home</NavItem>
<DropdownNavItem
id="about"
href="/about"
icon={User}
dropdownContent={aboutDropdownContent}
isMobile={isMobile}
isOpen={activeDropdown === 'about'}
onOpenChange={handleDropdownChange}
>
About Me
</DropdownNavItem>
<DropdownNavItem
id="ai"
href="/ai"
icon={Brain}
dropdownContent={aiDropdownContent}
isMobile={isMobile}
isOpen={activeDropdown === 'ai'}
onOpenChange={handleDropdownChange}
>
AI
</DropdownNavItem>
<NavItem href="/contact" icon={Phone}>Contact</NavItem>
<NavItem href="/domains" icon={LinkIcon}>Domains</NavItem>
<NavItem href="/manifesto" icon={BookOpen}>Manifesto</NavItem>
<div className="lg:hidden mt-2 pt-3 -mb-1.5 border-t border-gray-600/30">
<LanguageSelector />
</div>
</ul>
<div className="hidden lg:block">
<LanguageSelector />
</div>
</nav>
</header>
</>
);
}

View file

@ -1,8 +0,0 @@
"use client";
import { ReactNode } from "react";
import "../i18n";
export default function I18nProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View file

@ -0,0 +1,81 @@
import Image from 'next/image';
import type { DeviceHeroProps } from '@/lib/types';
import { deviceTypeLabels } from '@/lib/devices/config';
export default function DeviceHero({ device }: DeviceHeroProps) {
const imageWidth = device.heroImage.width ?? 540;
const imageHeight = device.heroImage.height ?? 540;
const metadata = [
{
label: 'Type',
value: deviceTypeLabels[device.type],
},
device.releaseYear
? {
label: 'Release',
value: device.releaseYear.toString(),
}
: undefined,
device.status
? {
label: 'Status',
value: device.status,
}
: undefined,
device.codename
? {
label: 'Codename',
value: device.codename,
}
: undefined,
].filter(Boolean) as Array<{ label: string; value: string }>;
return (
<section className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] gap-8 xl:gap-12">
<div className="bg-gray-900/60 border border-gray-800 rounded-2xl p-6 md:p-8 backdrop-blur-sm space-y-6">
<div className="space-y-3">
<h1 className="text-3xl md:text-4xl font-semibold text-gray-100">
{device.name}
</h1>
{device.tagline ? (
<p className="text-base md:text-lg text-gray-400 max-w-2xl">{device.tagline}</p>
) : null}
</div>
{device.summary?.length ? (
<div className="space-y-3 text-sm md:text-base text-gray-400 leading-relaxed max-w-2xl">
{device.summary.map((paragraph, idx) => (
<p key={idx}>{paragraph}</p>
))}
</div>
) : null}
{metadata.length ? (
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm text-gray-400">
{metadata.map((item) => (
<div key={item.label} className="flex flex-col">
<dt className="uppercase text-xs tracking-wide text-gray-600">{item.label}</dt>
<dd className="font-medium text-gray-200">{item.value}</dd>
</div>
))}
</dl>
) : null}
</div>
<div className="flex items-center justify-center">
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/60 p-6 md:p-8 flex items-center justify-center">
<Image
src={device.heroImage.src}
alt={device.heroImage.alt}
width={imageWidth}
height={imageHeight}
className="w-full h-auto object-contain drop-shadow-lg"
priority
/>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,283 @@
import type { ReactElement } from 'react';
import { ArrowUpRight, Star, StarHalf, StarOff } from 'lucide-react';
import Link from '@/components/objects/Link';
import type {
DevicePageShellProps,
DeviceStatGroup,
StatsGridProps,
StatItemProps,
SectionsGridProps,
SectionCardProps,
SectionRowProps,
RatingProps,
StarState,
} from '@/lib/types';
import { isExternalHref, externalLinkProps } from '@/lib/utils/styles';
import { iconSizes } from '@/lib/devices/config';
import DeviceHero from './DeviceHero';
export default function DevicePageShell({ device }: DevicePageShellProps): ReactElement {
return (
<div className="space-y-12">
<DeviceHero device={device} />
{device.stats.length ? <StatsGrid stats={device.stats} /> : null}
{device.sections.length ? <SectionsGrid sections={device.sections} /> : null}
</div>
);
}
function StatsGrid({ stats }: StatsGridProps): ReactElement {
return (
<section className="space-y-5">
<h2 className="text-xl font-semibold text-gray-100">At a glance</h2>
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
{stats.map((group) => (
<StatCard key={group.title} group={group} />
))}
</div>
</section>
);
}
function StatCard({ group }: { group: DeviceStatGroup }): ReactElement {
const Icon = group.icon;
return (
<article className="flex h-full flex-col gap-4 rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm">
<header className="flex items-center gap-3">
{Icon ? (
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
<Icon className="h-5 w-5" />
</span>
) : null}
<h3 className="text-lg font-semibold text-gray-100">{group.title}</h3>
</header>
<div className="grid gap-3 sm:grid-cols-2">
{group.items.map((item) => (
<StatItem
key={`${group.title}-${item.label ?? item.value}`}
item={item}
groupIcon={group.icon}
/>
))}
</div>
</article>
);
}
function StatItem({ item, groupIcon }: StatItemProps): ReactElement {
const isExternal = isExternalHref(item.href);
const linkProps = isExternal ? externalLinkProps : {};
const baseClasses =
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
const GroupIcon = groupIcon;
const content = (
<>
{GroupIcon ? (
<GroupIcon
aria-hidden
className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70"
size={iconSizes.stat}
/>
) : null}
{item.href && isExternal ? (
<ArrowUpRight
aria-hidden
className="pointer-events-none absolute bottom-4 right-4 z-20 text-gray-500"
/>
) : null}
<div className="relative z-10 space-y-2 pr-10">
{item.label ? (
<p className="text-xs uppercase tracking-wide text-gray-500">{item.label}</p>
) : null}
<div className="text-lg font-semibold leading-snug text-gray-100">{item.value}</div>
</div>
</>
);
if (item.href) {
return (
<Link
href={item.href}
className={`${baseClasses} block hover:text-white hover:no-underline`}
{...linkProps}
>
{content}
</Link>
);
}
return <div className={baseClasses}>{content}</div>;
}
function SectionsGrid({ sections }: SectionsGridProps): ReactElement {
return (
<section className="space-y-5">
<h2 className="text-xl font-semibold text-gray-100">Deep dive</h2>
<div className="grid gap-5 lg:grid-cols-2 xl:grid-cols-3 auto-rows-fr">
{sections.map((section) => (
<SectionCard key={section.id} section={section} />
))}
</div>
</section>
);
}
function SectionCard({ section }: SectionCardProps): ReactElement {
const Icon = section.icon;
const shouldSpanWide =
!!section.paragraphs?.length && (!section.rows || section.paragraphs.length > 1);
return (
<article
className={`rounded-2xl border border-gray-800 bg-gray-900/60 p-5 backdrop-blur-sm flex flex-col gap-4 ${
shouldSpanWide ? 'lg:col-span-2 xl:col-span-2' : ''
}`}
>
<header className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300">
<Icon className="h-5 w-5" />
</span>
<div>
<h3 className="text-lg font-semibold text-gray-100">{section.title}</h3>
{section.rating ? <Rating rating={section.rating} /> : null}
</div>
</header>
{section.rows?.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{section.rows.map((row) => (
<SectionRow key={row.label} row={row} />
))}
</div>
) : null}
{section.listItems?.length ? (
<ul className="grid gap-2 text-sm text-gray-300">
{section.listItems.map((item) => {
const isExternal = isExternalHref(item.href);
const linkProps = isExternal ? externalLinkProps : {};
return (
<li key={item.label}>
{item.href ? (
<Link
href={item.href}
className="relative block rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100 transition hover:text-white hover:no-underline"
{...linkProps}
>
<span className="block pr-10 font-medium">{item.label}</span>
{isExternal ? (
<ArrowUpRight
aria-hidden
className="pointer-events-none absolute bottom-2.5 right-3 text-gray-500"
/>
) : null}
</Link>
) : (
<div className="rounded-xl border border-gray-800 bg-gray-900/70 px-3 py-2 text-gray-100">
<span className="font-medium">{item.label}</span>
</div>
)}
{item.description ? (
<p className="mt-1 text-xs text-gray-500">{item.description}</p>
) : null}
</li>
);
})}
</ul>
) : null}
{section.paragraphs?.length ? (
<div className="space-y-3 text-sm leading-relaxed text-gray-400">
{section.paragraphs.map((paragraph) => (
<p key={`${section.id}-${paragraph}`}>{paragraph}</p>
))}
</div>
) : null}
</article>
);
}
function SectionRow({ row }: SectionRowProps): ReactElement {
const { icon: RowIcon } = row;
const isExternal = isExternalHref(row.href);
const linkProps = isExternal ? externalLinkProps : {};
const baseClasses =
'relative overflow-hidden rounded-2xl border border-gray-800 bg-gray-900/70 px-4 py-5 text-gray-100 transition';
const content = (
<>
{RowIcon ? (
<RowIcon className="pointer-events-none absolute -top-4 -right-4 text-gray-800/70" size={iconSizes.section} />
) : null}
{row.href && isExternal ? (
<ArrowUpRight
aria-hidden
className="pointer-events-none absolute bottom-4 right-4 z-20 h-4 w-4 text-gray-500"
/>
) : null}
<div className="relative z-10 space-y-2 pr-10">
<p className="text-xs uppercase tracking-wide text-gray-500">{row.label}</p>
<div className="text-lg font-semibold leading-snug text-gray-100">{row.value}</div>
{row.note ? <p className="text-xs text-gray-500">{row.note}</p> : null}
</div>
</>
);
if (row.href) {
return (
<Link
href={row.href}
className={`${baseClasses} block hover:text-white hover:no-underline`}
{...linkProps}
>
{content}
</Link>
);
}
return <div className={baseClasses}>{content}</div>;
}
function Rating({ rating }: RatingProps): ReactElement {
const stars = buildStars(rating.value, rating.scale ?? 5);
return (
<div className="mt-1 flex items-center gap-2 text-sm text-gray-400">
<span className="flex items-center text-gray-200">
{stars.map((state, idx) => {
const key = `${rating.label ?? rating.value}-${idx}`;
if (state === 'full') {
return <Star key={key} className="fill-current" />;
}
if (state === 'half') {
return <StarHalf key={key} className="fill-current" />;
}
return <StarOff key={key} className="text-gray-600" />;
})}
</span>
<span className="text-gray-300">{rating.value.toFixed(1)}</span>
{rating.label ? <span className="text-xs uppercase tracking-wide text-gray-600">{rating.label}</span> : null}
</div>
);
}
function buildStars(value: number, scale: number): StarState[] {
const stars: StarState[] = [];
const normalized = Math.max(0, Math.min(value, scale));
for (let i = 1; i <= scale; i += 1) {
if (normalized >= i) {
stars.push('full');
} else if (normalized > i - 1 && normalized < i) {
stars.push('half');
} else {
stars.push('empty');
}
}
return stars;
}

View file

@ -0,0 +1,257 @@
import { cn } from '@/lib/utils'
import { colors } from '@/lib/theme'
import type { APIEndpoint } from '@/lib/docs/types'
import CodeBlock from './CodeBlock'
import { LuLock } from 'react-icons/lu'
interface APIEndpointDocProps {
endpoint: APIEndpoint
className?: string
}
const methodStyles = {
GET: {
backgroundColor: 'rgba(16, 185, 129, 0.1)',
color: colors.accents.success,
borderColor: 'rgba(16, 185, 129, 0.3)',
},
POST: {
backgroundColor: 'rgba(59, 130, 246, 0.1)',
color: colors.accents.info,
borderColor: 'rgba(59, 130, 246, 0.3)',
},
PUT: {
backgroundColor: colors.accents.warningBg,
color: colors.accents.warning,
borderColor: 'rgba(245, 158, 11, 0.3)',
},
DELETE: {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
color: colors.accents.error,
borderColor: 'rgba(239, 68, 68, 0.3)',
},
PATCH: {
backgroundColor: 'rgba(168, 85, 247, 0.1)',
color: '#a855f7',
borderColor: 'rgba(168, 85, 247, 0.3)',
},
} as const
export default function APIEndpointDoc({
endpoint,
className,
}: APIEndpointDocProps) {
return (
<div id={endpoint.id} className={cn('scroll-mt-20', className)}>
<div className="space-y-6">
{/* Header */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<span
className="rounded-md border px-3 py-1 text-sm font-bold"
style={methodStyles[endpoint.method]}
>
{endpoint.method}
</span>
<code className="text-lg font-mono" style={{ color: colors.text.secondary }}>
{endpoint.path}
</code>
</div>
<p className="leading-relaxed" style={{ color: colors.text.body }}>{endpoint.description}</p>
{endpoint.auth?.required && (
<div
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm"
style={{
borderColor: 'rgba(245, 158, 11, 0.3)',
backgroundColor: colors.accents.warningBg,
color: colors.accents.warning,
}}
>
<LuLock className="h-4 w-4" />
<span>
Authentication required
{endpoint.auth.type && `: ${endpoint.auth.type}`}
</span>
</div>
)}
</div>
{/* Query Parameters */}
{endpoint.parameters?.query && endpoint.parameters.query.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>
Query Parameters
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: colors.borders.default }}>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Name
</th>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Type
</th>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Description
</th>
</tr>
</thead>
<tbody>
{endpoint.parameters.query.map((param, index) => (
<tr
key={index}
className="border-b last:border-0"
style={{ borderColor: colors.borders.subtle }}
>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
{param.name}
{!param.optional && (
<span className="ml-1" style={{ color: colors.accents.error }}>*</span>
)}
</td>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.muted }}>
{param.type}
</td>
<td className="px-4 py-3" style={{ color: colors.text.body }}>
{param.description}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Request Body */}
{endpoint.parameters?.body && endpoint.parameters.body.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>Request Body</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b" style={{ borderColor: colors.borders.default }}>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Field
</th>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Type
</th>
<th className="px-4 py-2 text-left font-medium" style={{ color: colors.text.muted }}>
Description
</th>
</tr>
</thead>
<tbody>
{endpoint.parameters.body.map((param, index) => (
<tr
key={index}
className="border-b last:border-0"
style={{ borderColor: colors.borders.subtle }}
>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
{param.name}
{!param.optional && (
<span className="ml-1" style={{ color: colors.accents.error }}>*</span>
)}
</td>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.muted }}>
{param.type}
</td>
<td className="px-4 py-3" style={{ color: colors.text.body }}>
{param.description}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Responses */}
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>Responses</h4>
{endpoint.responses.map((response, index) => {
const isSuccess = response.status >= 200 && response.status < 300
const isError = response.status >= 400
const statusStyle = isSuccess
? { backgroundColor: 'rgba(16, 185, 129, 0.1)', color: colors.accents.success }
: isError
? { backgroundColor: 'rgba(239, 68, 68, 0.1)', color: colors.accents.error }
: { backgroundColor: 'rgba(59, 130, 246, 0.1)', color: colors.accents.info }
return (
<div
key={index}
className="space-y-2 rounded-lg border p-4"
style={{
borderColor: colors.borders.default,
backgroundColor: colors.backgrounds.card,
}}
>
<div className="flex items-center gap-3">
<span
className="rounded px-2 py-1 text-sm font-mono font-semibold"
style={statusStyle}
>
{response.status}
</span>
<span className="text-sm" style={{ color: colors.text.body }}>
{response.description}
</span>
</div>
{response.example && (
<CodeBlock
code={JSON.stringify(response.example, null, 2)}
language="json"
title="Example Response"
/>
)}
</div>
)
})}
</div>
{/* Examples */}
{endpoint.examples && endpoint.examples.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.body }}>
Request Examples
</h4>
{endpoint.examples.map((example, index) => (
<div key={index} className="space-y-3">
{example.title && (
<h5 className="text-sm font-medium" style={{ color: colors.text.muted }}>
{example.title}
</h5>
)}
<div className="grid gap-3 lg:grid-cols-2">
<CodeBlock
code={
typeof example.request === 'string'
? example.request
: JSON.stringify(example.request, null, 2)
}
language="bash"
title="Request"
/>
<CodeBlock
code={
typeof example.response === 'string'
? example.response
: JSON.stringify(example.response, null, 2)
}
language="json"
title="Response"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { cn } from '@/lib/utils'
import { colors, effects } from '@/lib/theme'
import { Copy, Check } from 'lucide-react'
/**
* Supported syntax highlighting languages for code blocks.
*
* @remarks
* This list includes the most commonly used languages in the codebase.
* Languages are validated and normalized to ensure proper syntax highlighting.
*/
const SUPPORTED_LANGUAGES = [
'typescript',
'javascript',
'tsx',
'jsx',
'ts',
'js',
'json',
'bash',
'shell',
'css',
'scss',
'html',
'markdown',
'yaml',
'sql',
] as const
type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number]
/**
* Normalizes language identifiers to their canonical forms.
*
* @param language - Raw language identifier from code fence
* @returns Normalized language identifier for syntax highlighting
*
* @remarks
* **Normalization rules:**
* - 'ts' 'typescript'
* - 'js' 'javascript'
* - Invalid languages 'typescript' (safe default)
* - All other valid languages unchanged
*
* This ensures consistent syntax highlighting even when JSDoc
* examples use shorthand language identifiers.
*
* @example
* ```ts
* normalizeLanguage('ts') // Returns: 'typescript'
* normalizeLanguage('tsx') // Returns: 'tsx'
* normalizeLanguage('invalid') // Returns: 'typescript'
* ```
*
* @private
*/
function normalizeLanguage(language: string): SupportedLanguage {
const normalized = language.toLowerCase()
// Map common shorthands to full names
if (normalized === 'ts') return 'typescript'
if (normalized === 'js') return 'javascript'
// Validate against supported languages
if (SUPPORTED_LANGUAGES.includes(normalized as SupportedLanguage)) {
return normalized as SupportedLanguage
}
// Default to typescript for unknown languages
return 'typescript'
}
interface CodeBlockProps {
code: string
language?: string
title?: string
showLineNumbers?: boolean
className?: string
}
export default function CodeBlock({
code,
language = 'typescript',
title,
showLineNumbers = false,
className,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false)
const normalizedLanguage = normalizeLanguage(language)
const handleCopy = async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className={cn('group relative', className)}>
{title && (
<div
className="flex items-center justify-between rounded-t-lg border-2 border-b-0 px-4 py-2.5"
style={{
borderColor: colors.borders.default,
backgroundColor: colors.backgrounds.card
}}
>
<span className="text-sm font-medium" style={{ color: colors.text.secondary }}>
{title}
</span>
<span className="text-xs font-mono" style={{ color: colors.text.disabled }}>
{normalizedLanguage}
</span>
</div>
)}
<div
className={cn(
'relative overflow-x-auto',
title ? 'rounded-b-lg' : 'rounded-lg',
'border-2'
)}
style={{
borderColor: colors.borders.default,
backgroundColor: colors.backgrounds.cardSolid
}}
>
<button
onClick={handleCopy}
className={cn(
'absolute right-3 top-3 z-10',
'rounded-md px-3 py-1.5',
'text-xs font-medium',
'flex items-center gap-1.5',
'opacity-0 transition-all duration-200',
'group-hover:opacity-100',
copied && 'opacity-100',
effects.transitions.all
)}
style={{
backgroundColor: colors.backgrounds.card,
color: copied ? colors.accents.success : colors.text.muted,
borderWidth: '2px',
borderColor: copied ? colors.accents.success : colors.borders.default
}}
onMouseEnter={(e) => {
if (!copied) {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
e.currentTarget.style.borderColor = colors.borders.hover
e.currentTarget.style.color = colors.text.secondary
}
}}
onMouseLeave={(e) => {
if (!copied) {
e.currentTarget.style.backgroundColor = colors.backgrounds.card
e.currentTarget.style.borderColor = colors.borders.default
e.currentTarget.style.color = colors.text.muted
}
}}
aria-label="Copy code"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5" />
Copied!
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
Copy
</>
)}
</button>
<SyntaxHighlighter
language={normalizedLanguage}
style={vscDarkPlus}
showLineNumbers={showLineNumbers}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'transparent',
}}
codeTagProps={{
style: {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
},
}}
>
{code}
</SyntaxHighlighter>
</div>
</div>
)
}

View file

@ -0,0 +1,144 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { colors, effects } from '@/lib/theme'
import { Search, X } from 'lucide-react'
import type { DocItem } from '@/lib/docs/types'
interface DocsSearchProps {
items: DocItem[]
onSearch: (query: string) => void
className?: string
}
export default function DocsSearch({
items,
onSearch,
className,
}: DocsSearchProps) {
const [query, setQuery] = useState('')
const [isFocused, setIsFocused] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Keyboard shortcut (Cmd/Ctrl + K)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
if (e.key === 'Escape') {
inputRef.current?.blur()
setQuery('')
onSearch('')
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onSearch])
const handleChange = (value: string) => {
setQuery(value)
onSearch(value)
}
const handleClear = () => {
setQuery('')
onSearch('')
inputRef.current?.focus()
}
return (
<div className={cn('relative', className)}>
<div
className={cn(
'relative flex items-center',
'rounded-lg border-2',
effects.transitions.colors
)}
style={{
borderColor: isFocused ? colors.borders.hover : colors.borders.default,
backgroundColor: colors.backgrounds.card
}}
onMouseEnter={(e) => {
if (!isFocused) {
e.currentTarget.style.borderColor = colors.borders.hover
}
}}
onMouseLeave={(e) => {
if (!isFocused) {
e.currentTarget.style.borderColor = colors.borders.default
}
}}
>
<Search
className="absolute left-3 h-5 w-5"
style={{ color: colors.text.disabled }}
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Search documentation..."
className={cn(
'w-full bg-transparent px-10 py-3',
'text-sm outline-none'
)}
style={{
color: colors.text.primary,
caretColor: colors.text.secondary
}}
/>
{query ? (
<button
onClick={handleClear}
className={cn(
'absolute right-3 rounded p-1',
effects.transitions.colors
)}
style={{ color: colors.text.disabled }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
e.currentTarget.style.color = colors.text.secondary
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = colors.text.disabled
}}
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
) : (
<kbd
className={cn(
'absolute right-3',
'rounded border px-2 py-1 text-xs font-mono'
)}
style={{
borderColor: colors.borders.default,
backgroundColor: colors.backgrounds.cardSolid,
color: colors.text.disabled
}}
>
K
</kbd>
)}
</div>
{query && (
<div
className="mt-2 text-xs"
style={{ color: colors.text.disabled }}
>
{items.length} result{items.length !== 1 ? 's' : ''} found
</div>
)}
</div>
)
}

View file

@ -0,0 +1,210 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { colors } from '@/lib/theme'
import type { DocNavigation, DocCategory } from '@/lib/docs/types'
import { Settings, Wrench, FileText, Palette, Globe, Package, ChevronDown, ChevronRight, X, Smartphone, Network, BookOpen } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface DocsSidebarProps {
navigation: DocNavigation
currentItemId?: string
className?: string
onClose?: () => void
}
const categoryIcons: Record<DocCategory, LucideIcon> = {
Services: Settings,
Utils: Wrench,
Types: FileText,
Theme: Palette,
Devices: Smartphone,
Domains: Network,
Docs: BookOpen,
API: Globe,
Other: Package,
}
export default function DocsSidebar({
navigation,
currentItemId,
className,
onClose,
}: DocsSidebarProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(navigation.sections.map((s) => s.title))
)
const isMobileDrawer = !!onClose
const toggleSection = (title: string) => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(title)) {
newExpanded.delete(title)
} else {
newExpanded.add(title)
}
setExpandedSections(newExpanded)
}
return (
<aside
className={cn(
isMobileDrawer
? 'h-full w-full overflow-y-auto'
: 'sticky top-20 h-[calc(100vh-8rem)] overflow-y-auto w-64',
isMobileDrawer ? 'border-r-0' : 'border-r-2',
className
)}
style={{
borderColor: isMobileDrawer ? 'transparent' : colors.borders.default,
backgroundColor: isMobileDrawer ? colors.backgrounds.cardSolid : 'transparent'
}}
>
{/* Mobile Header with Close Button */}
{isMobileDrawer && (
<div
className="sticky top-0 z-10 flex items-center justify-between p-4 border-b-2"
style={{
backgroundColor: colors.backgrounds.cardSolid,
borderColor: colors.borders.default
}}
>
<h2 className="text-lg font-semibold" style={{ color: colors.text.primary }}>
Navigation
</h2>
<button
onClick={onClose}
className={cn(
'rounded-md p-2',
'transition-colors duration-300'
)}
style={{ color: colors.text.muted }}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
e.currentTarget.style.color = colors.text.secondary
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = colors.text.muted
}}
aria-label="Close navigation"
>
<X className="h-5 w-5" />
</button>
</div>
)}
<nav className="p-4 space-y-2">
{navigation.sections.map((section) => {
const isExpanded = expandedSections.has(section.title)
const Icon = categoryIcons[section.category]
return (
<div key={section.title} className="space-y-1">
<button
onClick={() => toggleSection(section.title)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2',
'text-sm font-medium',
'transition-colors duration-300'
)}
style={{
color: colors.text.secondary,
backgroundColor: isExpanded ? colors.backgrounds.hover : 'transparent',
}}
onMouseEnter={(e) => {
if (!isExpanded) {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
}
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.style.backgroundColor = 'transparent'
}
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 flex-shrink-0" />
)}
<Icon className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">{section.title}</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
color: colors.text.disabled,
backgroundColor: colors.backgrounds.card
}}
>
{section.items.length}
</span>
</button>
{isExpanded && (
<div className="ml-6 space-y-0.5">
{section.items.map((item) => {
const isActive = item.id === currentItemId
return (
<a
key={item.id}
href={`#${item.id}`}
onClick={isMobileDrawer ? onClose : undefined}
className={cn(
'block rounded-md px-3 py-1.5',
'text-sm transition-colors duration-300'
)}
style={{
color: isActive ? colors.text.primary : colors.text.muted,
backgroundColor: isActive ? colors.backgrounds.hover : 'transparent',
fontWeight: isActive ? 500 : 400
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = colors.backgrounds.hover
e.currentTarget.style.color = colors.text.secondary
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = colors.text.muted
}
}}
>
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs font-mono px-1.5 py-0.5 rounded flex-shrink-0'
)}
style={{
backgroundColor: colors.backgrounds.card,
color: colors.text.disabled
}}
>
{item.kind === 'function' && 'fn'}
{item.kind === 'method' && 'fn'}
{item.kind === 'class' && 'class'}
{item.kind === 'interface' && 'interface'}
{item.kind === 'type' && 'type'}
{item.kind === 'variable' && 'const'}
{item.kind === 'property' && 'prop'}
{item.kind === 'enum' && 'enum'}
</span>
<span className="truncate">{item.name}</span>
</div>
</a>
)
})}
</div>
)}
</div>
)
})}
</nav>
</aside>
)
}

View file

@ -0,0 +1,296 @@
import { cn } from '@/lib/utils'
import { colors, surfaces, effects } from '@/lib/theme'
import type { DocItem } from '@/lib/docs/types'
import CodeBlock from './CodeBlock'
import TypeLink from './TypeLink'
import { ExternalLink, TriangleAlert } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface FunctionDocProps {
item: DocItem
className?: string
availableTypeIds?: Set<string>
}
export default function FunctionDoc({ item, className, availableTypeIds }: FunctionDocProps) {
return (
<div id={item.id} className={cn('scroll-mt-20', className)}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-2xl font-bold" style={{ color: colors.text.primary }}>
{item.name}
</h3>
<span
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.backgrounds.card,
color: colors.text.secondary
}}
>
{item.kind}
</span>
<span
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.accents.docsBg,
color: colors.accents.docs,
borderWidth: '1px',
borderColor: colors.accents.docsBorder
}}
>
{item.category}
</span>
{item.deprecated && (
<span
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.accents.warningBg,
color: colors.accents.warning
}}
>
<TriangleAlert className="h-3 w-3" />
Deprecated
</span>
)}
</div>
{item.description && (
<p className="leading-relaxed" style={{ color: colors.text.body }}>
{item.description}
</p>
)}
</div>
{item.source && (
<a
href={`https://github.com/ihatenodejs/aidxnCC/blob/main/${item.source.file}#L${item.source.line}`}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-2',
'text-xs border-2',
effects.transitions.colors,
'flex-shrink-0'
)}
style={{
color: colors.text.muted,
borderColor: colors.borders.default
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = colors.borders.hover
e.currentTarget.style.color = colors.text.secondary
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = colors.borders.default
e.currentTarget.style.color = colors.text.muted
}}
>
<ExternalLink className="h-3.5 w-3.5" />
Source
</a>
)}
</div>
{/* Remarks */}
{item.remarks && (
<div
className={cn(
'rounded-lg border-l-4 pl-4 py-2',
'space-y-2'
)}
style={{
borderColor: colors.accents.ai,
backgroundColor: colors.backgrounds.card
}}
>
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Remarks
</h4>
<div className="text-sm leading-relaxed prose prose-invert prose-sm max-w-none" style={{ color: colors.text.body }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{item.remarks}
</ReactMarkdown>
</div>
</div>
)}
{/* Signature */}
{item.signature && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Signature
</h4>
<CodeBlock code={item.signature} language="typescript" />
</div>
)}
{/* Parameters */}
{item.parameters && item.parameters.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Parameters
</h4>
<div className="overflow-x-auto rounded-lg border-2" style={{ borderColor: colors.borders.default }}>
<table className="w-full text-sm">
<thead>
<tr className="border-b-2" style={{ borderColor: colors.borders.default, backgroundColor: colors.backgrounds.card }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Name
</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Type
</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Description
</th>
</tr>
</thead>
<tbody>
{item.parameters.map((param, index) => (
<tr
key={index}
className="border-b last:border-0"
style={{ borderColor: colors.borders.subtle }}
>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
{param.name}
{param.optional && (
<span style={{ color: colors.text.disabled }}>?</span>
)}
</td>
<td className="px-4 py-3">
<TypeLink type={param.type} className="text-sm" availableTypeIds={availableTypeIds} />
</td>
<td className="px-4 py-3" style={{ color: colors.text.body }}>
{param.description || '—'}
{param.defaultValue && (
<div className="mt-1 text-xs" style={{ color: colors.text.disabled }}>
Default: <code>{param.defaultValue}</code>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Returns */}
{item.returns && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Returns
</h4>
<div
className={cn(
'rounded-lg border-2 p-4',
'space-y-2'
)}
style={{
borderColor: colors.borders.default,
backgroundColor: colors.backgrounds.card
}}
>
<TypeLink type={item.returns.type} className="text-sm" availableTypeIds={availableTypeIds} />
{item.returns.description && (
<p className="text-sm" style={{ color: colors.text.body }}>
{item.returns.description}
</p>
)}
</div>
</div>
)}
{/* Throws */}
{item.throws && item.throws.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Throws
</h4>
<div className="space-y-2">
{item.throws.map((throwsDoc, index) => (
<div
key={index}
className={cn(
'rounded-lg border-2 p-4'
)}
style={{
borderColor: colors.accents.warningBg,
backgroundColor: colors.backgrounds.card
}}
>
<p className="text-sm" style={{ color: colors.text.body }}>
{throwsDoc}
</p>
</div>
))}
</div>
</div>
)}
{/* Examples */}
{item.examples && item.examples.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Examples
</h4>
<div className="space-y-4">
{item.examples.map((example, index) => (
<CodeBlock
key={index}
code={example.code}
language={example.language}
showLineNumbers
/>
))}
</div>
</div>
)}
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={tag}
className={cn(surfaces.badge.muted)}
>
{tag}
</span>
))}
</div>
)}
{/* See Also */}
{item.see && item.see.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
See Also
</h4>
<div className="space-y-2">
{item.see.map((ref, index) => (
<div
key={index}
className="text-sm"
style={{ color: colors.text.body }}
>
{ref}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

246
components/docs/TypeDoc.tsx Normal file
View file

@ -0,0 +1,246 @@
import { cn } from '@/lib/utils'
import { colors, surfaces, effects } from '@/lib/theme'
import type { DocItem } from '@/lib/docs/types'
import CodeBlock from './CodeBlock'
import TypeLink from './TypeLink'
import { ExternalLink, TriangleAlert } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface TypeDocProps {
item: DocItem
className?: string
availableTypeIds?: Set<string>
}
export default function TypeDoc({ item, className, availableTypeIds }: TypeDocProps) {
return (
<div id={item.id} className={cn('scroll-mt-20', className)}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-2xl font-bold" style={{ color: colors.text.primary }}>
{item.name}
</h3>
<span
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.backgrounds.card,
color: colors.text.secondary
}}
>
{item.kind}
</span>
<span
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.accents.docsBg,
color: colors.accents.docs,
borderWidth: '1px',
borderColor: colors.accents.docsBorder
}}
>
{item.category}
</span>
{item.deprecated && (
<span
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium'
)}
style={{
backgroundColor: colors.accents.warningBg,
color: colors.accents.warning
}}
>
<TriangleAlert className="h-3 w-3" />
Deprecated
</span>
)}
</div>
{item.description && (
<p className="leading-relaxed" style={{ color: colors.text.body }}>
{item.description}
</p>
)}
</div>
{item.source && (
<a
href={`https://github.com/ihatenodejs/aidxnCC/blob/main/${item.source.file}#L${item.source.line}`}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-2',
'text-xs border-2',
effects.transitions.colors,
'flex-shrink-0'
)}
style={{
color: colors.text.muted,
borderColor: colors.borders.default
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = colors.borders.hover
e.currentTarget.style.color = colors.text.secondary
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = colors.borders.default
e.currentTarget.style.color = colors.text.muted
}}
>
<ExternalLink className="h-3.5 w-3.5" />
Source
</a>
)}
</div>
{/* Remarks */}
{item.remarks && (
<div
className={cn(
'rounded-lg border-l-4 pl-4 py-2',
'space-y-2'
)}
style={{
borderColor: colors.accents.ai,
backgroundColor: colors.backgrounds.card
}}
>
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Remarks
</h4>
<div className="text-sm leading-relaxed prose prose-invert prose-sm max-w-none" style={{ color: colors.text.body }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{item.remarks}
</ReactMarkdown>
</div>
</div>
)}
{/* Type Definition */}
{item.signature && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Definition
</h4>
<CodeBlock
code={item.kind === 'interface' ? `interface ${item.name} ${item.signature}` : `${item.kind} ${item.name} = ${item.signature}`}
language="typescript"
/>
</div>
)}
{/* Interface Properties */}
{item.kind === 'interface' && item.parameters && item.parameters.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Properties
</h4>
<div className="overflow-x-auto rounded-lg border-2" style={{ borderColor: colors.borders.default }}>
<table className="w-full text-sm">
<thead>
<tr className="border-b-2" style={{ borderColor: colors.borders.default, backgroundColor: colors.backgrounds.card }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Property
</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Type
</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: colors.text.muted }}>
Description
</th>
</tr>
</thead>
<tbody>
{item.parameters.map((prop, index) => (
<tr
key={index}
className="border-b last:border-0"
style={{ borderColor: colors.borders.subtle }}
>
<td className="px-4 py-3 font-mono" style={{ color: colors.text.secondary }}>
{prop.name}
{prop.optional && (
<span style={{ color: colors.text.disabled }}>?</span>
)}
</td>
<td className="px-4 py-3">
<TypeLink type={prop.type} className="text-xs" availableTypeIds={availableTypeIds} />
</td>
<td className="px-4 py-3" style={{ color: colors.text.body }}>
{prop.description || '—'}
{prop.defaultValue && (
<div className="mt-1 text-xs" style={{ color: colors.text.disabled }}>
Default: <code>{prop.defaultValue}</code>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Examples */}
{item.examples && item.examples.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
Examples
</h4>
<div className="space-y-4">
{item.examples.map((example, index) => (
<CodeBlock
key={index}
code={example.code}
language={example.language}
showLineNumbers
/>
))}
</div>
</div>
)}
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={tag}
className={cn(surfaces.badge.muted)}
>
{tag}
</span>
))}
</div>
)}
{/* See Also */}
{item.see && item.see.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold" style={{ color: colors.text.secondary }}>
See Also
</h4>
<div className="space-y-2">
{item.see.map((ref, index) => (
<div
key={index}
className="text-sm"
style={{ color: colors.text.body }}
>
{ref}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,126 @@
'use client'
import { colors, effects } from '@/lib/theme'
import { cn } from '@/lib/utils'
interface TypeLinkProps {
type: string
className?: string
availableTypeIds?: Set<string>
}
/**
* Parses a type string and converts type references into clickable links
* that scroll to the corresponding type definition in the documentation.
*
* Supports:
* - Simple types: Domain, User, etc.
* - Generic types: Array<Domain>, Promise<User>
* - Union types: string | number
* - Complex types: Record<string, Domain>
*/
export default function TypeLink({ type, className, availableTypeIds }: TypeLinkProps) {
const parseTypeString = (typeStr: string): React.ReactNode[] => {
const parts: React.ReactNode[] = []
let currentIndex = 0
const typeNamePattern = /\b([A-Z][a-zA-Z0-9]*)\b/g
const builtInTypes = new Set([
'string', 'number', 'boolean', 'void', 'null', 'undefined', 'any', 'unknown',
'never', 'object', 'symbol', 'bigint', 'Array', 'Promise', 'Record', 'Partial',
'Required', 'Readonly', 'Pick', 'Omit', 'Exclude', 'Extract', 'NonNullable',
'ReturnType', 'InstanceType', 'ThisType', 'Parameters', 'ConstructorParameters',
'Date', 'Error', 'RegExp', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Function',
'ReadonlyArray', 'String', 'Number', 'Boolean', 'Symbol', 'Object'
])
let match: RegExpExecArray | null
while ((match = typeNamePattern.exec(typeStr)) !== null) {
const typeName = match[1]
const matchStart = match.index
const matchEnd = typeNamePattern.lastIndex
if (matchStart > currentIndex) {
parts.push(
<span key={`text-${currentIndex}`}>
{typeStr.substring(currentIndex, matchStart)}
</span>
)
}
if (builtInTypes.has(typeName)) {
parts.push(
<span key={`builtin-${matchStart}`}>
{typeName}
</span>
)
} else {
// Check if this type exists in the documentation
const typeExists = availableTypeIds?.has(typeName) ?? false
if (typeExists) {
parts.push(
<button
key={`link-${matchStart}`}
onClick={(e) => {
e.preventDefault()
const targetId = typeName
const element = document.getElementById(targetId)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
element.classList.add('ring-2', 'ring-blue-400', 'ring-offset-2', 'ring-offset-gray-900')
setTimeout(() => {
element.classList.remove('ring-2', 'ring-blue-400', 'ring-offset-2', 'ring-offset-gray-900')
}, 2000)
}
}}
className={cn(
'hover:underline cursor-pointer',
effects.transitions.colors
)}
style={{
color: colors.accents.link,
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = colors.accents.linkHover
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = colors.accents.link
}}
>
{typeName}
</button>
)
} else {
// Type doesn't exist in docs, render as plain text
parts.push(
<span key={`text-${matchStart}`}>
{typeName}
</span>
)
}
}
currentIndex = matchEnd
}
if (currentIndex < typeStr.length) {
parts.push(
<span key={`text-${currentIndex}`}>
{typeStr.substring(currentIndex)}
</span>
)
}
return parts
}
return (
<span className={cn('font-mono', className)}>
{parseTypeString(type)}
</span>
)
}

View file

@ -0,0 +1,98 @@
'use client'
import {
getExpirationDate,
getDaysUntilExpiration,
getOwnershipDuration,
getOwnershipMonths,
isExpiringSoon,
formatDate,
getNextRenewalDate
} from '@/lib/domains/utils'
import Link from 'next/link'
import {
Calendar,
Clock,
ChevronRight,
RefreshCw
} from 'lucide-react'
import type { DomainCardProps } from '@/lib/types'
import { domainVisualConfig } from '@/lib/domains/config'
export default function DomainCard({ domain }: DomainCardProps) {
const expirationDate = getExpirationDate(domain)
const nextRenewalDate = getNextRenewalDate(domain)
const daysUntilExpiration = getDaysUntilExpiration(domain)
const ownershipYears = getOwnershipDuration(domain)
const ownershipMonths = getOwnershipMonths(domain)
const expiringSoon = isExpiringSoon(domain)
const statusVisual = domainVisualConfig.status[domain.status]
const categoryVisual = domainVisualConfig.category[domain.category]
const StatusIcon = statusVisual.icon
return (
<Link href={`/domains/${domain.domain}`}>
<div className="group relative h-full bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl hover:border-gray-700 transition-all hover:shadow-xl hover:shadow-black/20 cursor-pointer overflow-hidden flex flex-col">
{expiringSoon && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-500"></div>
)}
<div className="p-6 flex flex-col flex-1">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`${statusVisual.color}`}>
<StatusIcon className="w-4 h-4" />
</span>
<h3 className="text-lg font-semibold text-gray-100 group-hover:text-white transition-colors">
{domain.domain}
</h3>
</div>
<p className="text-sm text-gray-500 line-clamp-2 min-h-[2.5rem]">{domain.usage}</p>
</div>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-gray-400 transition-all group-hover:translate-x-1" />
</div>
<div className="flex items-center gap-4 text-xs text-gray-400 mb-3">
<span className={`${categoryVisual.color} font-medium uppercase tracking-wide`}>
{categoryVisual.label}
</span>
<span className="text-gray-600"></span>
<span>{domain.registrar}</span>
{domain.autoRenew && (
<>
<span className="text-gray-600"></span>
<span className="text-slate-500/80">Auto-renew</span>
</>
)}
</div>
<div className="flex flex-col gap-2 pt-3 border-t border-gray-800/50 mt-auto">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-gray-500" />
<span className="text-gray-400">
{ownershipYears < 1 ? `${ownershipMonths}mo owned` : `${ownershipYears}y owned`}
</span>
</div>
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-gray-500" />
<span className={expiringSoon ? 'text-gray-300 font-medium' : 'text-gray-400'}>
{expiringSoon ? `${daysUntilExpiration}d left` : formatDate(expirationDate)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs">
<RefreshCw className="w-3.5 h-3.5 text-gray-500" />
<span className="text-gray-400">
Next renewal: {formatDate(nextRenewalDate)}
</span>
</div>
</div>
</div>
</div>
</Link>
)
}

View file

@ -0,0 +1,180 @@
import {
getRegistrationDate,
getExpirationDate,
getDaysUntilExpiration,
getOwnershipDuration,
getOwnershipMonths,
formatDate,
isExpiringSoon,
getRenewalProgress,
getOwnershipDays
} from '@/lib/domains/utils'
import { registrars } from '@/lib/domains/data'
import { domainVisualConfig } from '@/lib/domains/config'
import {
Shield,
Tag,
AlertCircle,
ToggleLeft,
ToggleRight,
Activity
} from 'lucide-react'
import type { DomainDetailsProps } from '@/lib/types'
export default function DomainDetails({ domain }: DomainDetailsProps) {
const registrationDate = getRegistrationDate(domain)
const expirationDate = getExpirationDate(domain)
const daysUntilExpiration = getDaysUntilExpiration(domain)
const ownershipYears = getOwnershipDuration(domain)
const ownershipMonths = getOwnershipMonths(domain)
const ownershipDays = getOwnershipDays(domain)
const expiringSoon = isExpiringSoon(domain)
const renewalProgress = getRenewalProgress(domain)
const registrarConfig = registrars[domain.registrar]
const statusVisual = domainVisualConfig.status[domain.status]
const categoryVisual = domainVisualConfig.category[domain.category]
const StatusIcon = statusVisual.icon
const CategoryIcon = categoryVisual.icon
return (
<div className="space-y-4">
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">Status</p>
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg ${statusVisual.bg} ${statusVisual.border} border`}>
<span className={statusVisual.color}>
<StatusIcon className="w-5 h-5" />
</span>
<span className={`font-medium ${statusVisual.color}`}>
{statusVisual.label}
</span>
</div>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">Category</p>
<div className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg ${categoryVisual.bg} ${categoryVisual.border} border`}>
<span className={categoryVisual.color}>
<CategoryIcon className="w-5 h-5" />
</span>
<span className={`font-medium ${categoryVisual.color}`}>
{categoryVisual.label}
</span>
</div>
</div>
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Domain Lifecycle</h3>
<div className="flex items-center gap-3 text-xs text-gray-500">
Owned for {ownershipDays} days ({ownershipYears < 1 ? `${ownershipMonths} months` : `${ownershipYears} years`})
</div>
</div>
<div className="relative mb-4">
<div className="flex justify-between text-xs text-gray-500 mb-2">
<span>Registered</span>
<span>Expires</span>
</div>
<div className="relative h-4 bg-gray-800 rounded-full overflow-hidden">
<div
className="absolute left-0 top-0 h-full bg-slate-500 rounded-full transition-all duration-500"
style={{ width: `${renewalProgress}%` }}
/>
{expiringSoon && (
<div className="absolute right-0 top-0 h-full w-24 bg-gray-600/30" />
)}
</div>
<div className="flex justify-between text-xs mt-2">
<span className="text-gray-400">{formatDate(registrationDate)}</span>
<span className={`font-medium ${
expiringSoon ? 'text-gray-300' : renewalProgress > 75 ? 'text-slate-400' : 'text-gray-400'
}`}>
{formatDate(expirationDate)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-center">
<div className="p-2 bg-gray-800/50 rounded-lg">
<div className="text-lg font-bold text-slate-400">{Math.floor(renewalProgress)}%</div>
<div className="text-xs text-gray-500">Period Used</div>
</div>
<div className={`p-2 rounded-lg ${expiringSoon ? 'bg-gray-800/70' : 'bg-gray-800/50'}`}>
<div className={`text-lg font-bold ${expiringSoon ? 'text-gray-300' : 'text-slate-400'}`}>
{daysUntilExpiration}
</div>
<div className="text-xs text-gray-500">Days Left</div>
</div>
</div>
{expiringSoon && (
<div className="flex items-center gap-2 p-3 mt-3 bg-gray-800/50 border border-gray-700 rounded-lg">
<AlertCircle className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">
Domain expires soon
</span>
</div>
)}
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
<div className="grid grid-cols-2 gap-6">
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-gray-500" />
<p className="text-xs text-gray-500 uppercase tracking-wide">Registrar</p>
</div>
<div className="flex items-center gap-2">
{registrarConfig && (
<div className={`w-8 h-8 bg-gray-800 rounded-lg flex items-center justify-center ${registrarConfig.color}`}>
<registrarConfig.icon className="w-4 h-4" />
</div>
)}
<span className="text-gray-200 font-medium">{domain.registrar}</span>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-3">
<Activity className="w-4 h-4 text-gray-500" />
<p className="text-xs text-gray-500 uppercase tracking-wide">Auto-Renewal</p>
</div>
<button className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 cursor-default">
{domain.autoRenew ? (
<>
<ToggleRight className="w-5 h-5 text-slate-400" />
<span className="text-sm text-slate-400 font-medium">Enabled</span>
</>
) : (
<>
<ToggleLeft className="w-5 h-5 text-gray-500" />
<span className="text-sm text-gray-500 font-medium">Disabled</span>
</>
)}
</button>
</div>
</div>
</div>
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Tag className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-gray-300">Tags</h3>
</div>
<div className="flex flex-wrap gap-2">
{domain.tags.map(tag => (
<span
key={tag}
className="px-3 py-1.5 bg-gray-800/50 text-gray-300 rounded-full text-sm hover:bg-gray-800 transition-colors border border-gray-700/50"
>
#{tag}
</span>
))}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import { Search, Filter, X } from 'lucide-react'
import type {
DomainFiltersProps,
DomainCategory,
DomainStatus,
DomainRegistrarId,
DomainSortOption
} from '@/lib/types'
import { sortOptions } from '@/lib/domains/config'
export default function DomainFilters({
onSearchChange,
onCategoryChange,
onStatusChange,
onRegistrarChange,
onSortChange,
registrars
}: DomainFiltersProps) {
const [search, setSearch] = useState('')
const [selectedCategories, setSelectedCategories] = useState<DomainCategory[]>([])
const [selectedStatuses, setSelectedStatuses] = useState<DomainStatus[]>([])
const [selectedRegistrars, setSelectedRegistrars] = useState<DomainRegistrarId[]>([])
const [sortBy, setSortBy] = useState<DomainSortOption>('name')
const [showFilters, setShowFilters] = useState(false)
const categories: DomainCategory[] = ['personal', 'service', 'project', 'fun', 'legacy']
const statuses: DomainStatus[] = ['active', 'parked', 'reserved']
const handleSearchChange = (value: string) => {
setSearch(value)
onSearchChange(value)
}
const toggleCategory = (category: DomainCategory) => {
const updated = selectedCategories.includes(category)
? selectedCategories.filter(c => c !== category)
: [...selectedCategories, category]
setSelectedCategories(updated)
onCategoryChange(updated)
}
const toggleStatus = (status: DomainStatus) => {
const updated = selectedStatuses.includes(status)
? selectedStatuses.filter(s => s !== status)
: [...selectedStatuses, status]
setSelectedStatuses(updated)
onStatusChange(updated)
}
const toggleRegistrar = (registrar: DomainRegistrarId) => {
const updated = selectedRegistrars.includes(registrar)
? selectedRegistrars.filter(r => r !== registrar)
: [...selectedRegistrars, registrar]
setSelectedRegistrars(updated)
onRegistrarChange(updated)
}
const handleSortChange = (value: DomainSortOption) => {
setSortBy(value)
onSortChange(value)
}
const clearFilters = () => {
setSearch('')
setSelectedCategories([])
setSelectedStatuses([])
setSelectedRegistrars([])
setSortBy('name')
onSearchChange('')
onCategoryChange([])
onStatusChange([])
onRegistrarChange([])
onSortChange('name')
}
const hasActiveFilters = search || selectedCategories.length > 0 || selectedStatuses.length > 0 || selectedRegistrars.length > 0
return (
<div className="mb-8 space-y-4">
<div className="flex gap-4 items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-5 h-5" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search domains..."
className="w-full pl-10 pr-4 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-700"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
showFilters || hasActiveFilters
? 'bg-gray-800 border-gray-700 text-white'
: 'bg-gray-900/50 border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-300'
}`}
>
<Filter className="w-5 h-5" />
Filters
{hasActiveFilters && (
<span className="ml-1 px-2 py-0.5 text-xs bg-slate-500/20 text-slate-400 rounded-full">
Active
</span>
)}
</button>
<select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value as DomainSortOption)}
className="px-4 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-gray-200 focus:outline-none focus:border-gray-700"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{showFilters && (
<div className="p-4 bg-gray-900/30 border border-gray-800 rounded-lg space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-gray-300">Filter Options</h3>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-gray-500 hover:text-gray-400 flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear all
</button>
)}
</div>
<div>
<h4 className="text-xs text-gray-500 mb-2">Category</h4>
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => toggleCategory(category)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
selectedCategories.includes(category)
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
}`}
>
{category}
</button>
))}
</div>
</div>
<div>
<h4 className="text-xs text-gray-500 mb-2">Status</h4>
<div className="flex flex-wrap gap-2">
{statuses.map(status => (
<button
key={status}
onClick={() => toggleStatus(status)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
selectedStatuses.includes(status)
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
}`}
>
{status}
</button>
))}
</div>
</div>
<div>
<h4 className="text-xs text-gray-500 mb-2">Registrar</h4>
<div className="flex flex-wrap gap-2">
{registrars.map(registrar => (
<button
key={registrar}
onClick={() => toggleRegistrar(registrar)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
selectedRegistrars.includes(registrar)
? 'bg-slate-500/20 text-slate-400 border-slate-500/40'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:border-gray-600'
}`}
>
{registrar}
</button>
))}
</div>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,88 @@
import { getRenewalTimeline, formatDate, getNextRenewalDate } from '@/lib/domains/utils'
import { Calendar, RefreshCw, Star } from 'lucide-react'
import type { DomainTimelineProps } from '@/lib/types'
export default function DomainTimeline({ domain }: DomainTimelineProps) {
const timeline = getRenewalTimeline(domain)
const nextRenewalDate = getNextRenewalDate(domain)
return (
<div className="bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl p-6">
<div className="relative">
<div className="absolute left-6 top-8 bottom-0 w-0.5 bg-gray-700"></div>
<div className="space-y-8">
{timeline.map((event, index) => {
const isLatest = index === timeline.length - 1
const isRegistration = event.type === 'registration'
return (
<div key={index} className="relative flex items-start gap-4">
<div className={`relative z-10 flex items-center justify-center w-12 h-12 rounded-full ${
isRegistration || isLatest
? 'bg-gray-800 border-2 border-slate-400/50'
: 'bg-gray-800 border-2 border-gray-700'
}`}>
{isRegistration ? (
<Star className="w-6 h-6 text-slate-300" />
) : (
<RefreshCw className={`w-5 h-5 ${isLatest ? 'text-slate-300' : 'text-gray-500'}`} />
)}
</div>
<div className="flex-1 pb-8">
<div className={`rounded-lg p-4 border transition-colors ${
isRegistration || isLatest
? 'bg-gray-800/50 border-gray-700/50 hover:border-gray-600/50'
: 'bg-slate-400/5 border-slate-400/20 hover:border-slate-400/30'
}`}>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${
isRegistration || isLatest ? 'text-slate-300' : 'text-gray-400'
}`}>
{isRegistration ? 'Domain Registered' : 'Domain Renewed'}
</span>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
{formatDate(event.date)}
</div>
</div>
<div className="text-sm text-gray-300">
{isRegistration ? (
<span>Initial registration for {event.years} {event.years === 1 ? 'year' : 'years'}</span>
) : (
<span>Renewed for {event.years} {event.years === 1 ? 'year' : 'years'}</span>
)}
</div>
</div>
</div>
</div>
)
})}
<div className="relative flex items-start gap-4">
<div className="relative z-10 flex items-center justify-center w-12 h-12 rounded-full bg-gray-900 border-2 border-dashed border-gray-700">
<Calendar className="w-5 h-5 text-gray-600" />
</div>
<div className="flex-1">
<div className="bg-gray-900/30 rounded-lg p-4 border border-dashed border-gray-700/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-500">
Next Renewal
</span>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
{formatDate(nextRenewalDate)}
</div>
</div>
<div className="text-xs text-gray-600">
{domain.autoRenew ? 'Auto-renewal enabled' : 'Manual renewal required'}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,18 @@
import React from 'react'
interface DynadotIconProps {
className?: string
}
export default function DynadotIcon({ className }: DynadotIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 850 968"
fill="currentColor"
className={className}
>
<path d="M0,718.435l0,-34.062c6.563,-84.813 48.281,-160.781 117.188,-210.219c12.031,-8.656 31.874,-19.969 59.531,-33.937c17.687,-8.938 37.969,-19.438 60.812,-31.532c0.532,-0.281 0.719,-0.937 0.438,-1.468l-87.531,-168.063c-0.438,-0.844 -0.094,-1.906 0.75,-2.344c186.406,-96.906 318.687,-165.656 396.875,-206.281c96.187,-49.937 216.875,-37.875 301.843,30.781c135.438,109.469 128.219,320.375 -11.468,424.25c-13.094,9.719 -34.344,22.094 -63.688,37.125c-35.875,18.344 -57.781,29.657 -65.719,33.907c-0.656,0.343 -0.906,1.156 -0.562,1.781l87.375,167.812c0.437,0.844 0.094,1.907 -0.75,2.344c-224.313,116.531 -345.219,179.469 -362.719,188.813c-39.594,21.093 -67.937,34.093 -84.969,38.968c-167.312,47.75 -333.031,-64.437 -347.406,-237.875Zm330.094,-357.75c2,-0.812 18.75,-9.593 50.25,-26.312c24.219,-12.875 41.875,-20.781 52.969,-23.719c61.593,-16.375 124.468,2.031 166.937,49.438c9.25,10.312 19.969,27.5 32.156,51.5c14.282,28.187 23.625,46.187 27.938,54c0.437,0.781 1.437,1.062 2.219,0.625c27.125,-14.438 47.781,-25.188 61.937,-32.25c29.031,-14.563 48.969,-26.469 59.75,-35.688c60.219,-51.594 79.594,-136.937 42.781,-207.562c-36,-69.094 -113.843,-105.344 -190.093,-86.969c-13.407,3.219 -33.532,11.844 -60.375,25.906c-94.157,49.313 -190.344,99.469 -288.625,150.5c-0.844,0.469 -1.188,1.531 -0.719,2.375l40.406,77.281c0.469,0.907 1.563,1.282 2.469,0.875Zm220.031,122.032c0,-42.5 -34.469,-76.969 -76.969,-76.969c-42.5,-0 -76.968,34.469 -76.968,76.969c-0,42.5 34.468,76.968 76.968,76.968c42.5,0 76.969,-34.468 76.969,-76.968Zm66.281,122.187c-2,0.813 -18.781,9.563 -50.375,26.25c-24.281,12.844 -42,20.75 -53.093,23.688c-61.719,16.281 -124.657,-2.25 -167.094,-49.813c-9.25,-10.344 -19.969,-27.562 -32.125,-51.625c-14.281,-28.25 -23.563,-46.281 -27.906,-54.125c-0.438,-0.781 -1.438,-1.094 -2.219,-0.656c-27.188,14.406 -47.875,25.156 -62.063,32.219c-29.125,14.531 -49.094,26.406 -59.906,35.625c-60.406,51.562 -79.969,137 -43.219,207.812c35.938,69.25 113.844,105.688 190.25,87.438c13.406,-3.219 33.594,-11.813 60.5,-25.844c94.375,-49.219 190.813,-99.313 289.313,-150.25c0.875,-0.438 1.187,-1.5 0.75,-2.375l-40.344,-77.469c-0.469,-0.906 -1.563,-1.281 -2.469,-0.875Z" />
</svg>
)
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import { SiGoogle } from 'react-icons/si'
interface GoogleIconProps {
className?: string
strokeWidth?: number
size?: number
}
export default function GoogleIcon({ className, size }: GoogleIconProps) {
return <SiGoogle className={className} size={size} />
}

View file

@ -0,0 +1,25 @@
import React from 'react'
interface KowalskiIconProps {
className?: string
strokeWidth?: number
size?: number
}
export default function KowalskiIcon({ className, size = 24 }: KowalskiIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 400 400"
fill="currentColor"
width={size}
height={size}
className={className}
>
<path d="M179.297 50.376 C 164.092 53.168,147.855 61.349,131.479 74.468 C 113.775 88.651,105.218 103.233,92.361 141.126 C 78.387 182.313,68.874 223.118,48.583 328.906 C 42.461 360.825,38.004 394.166,39.214 398.989 L 39.468 400.000 218.765 400.000 L 398.062 400.000 397.809 398.926 C 395.000 386.997,393.091 381.753,389.221 375.330 C 386.867 371.423,384.640 368.274,373.310 352.842 C 365.699 342.475,359.054 331.666,347.665 311.133 C 341.277 299.615,327.304 275.792,319.140 262.500 C 301.796 234.261,299.201 227.435,298.428 208.024 C 297.409 182.454,294.676 167.052,285.498 135.156 C 278.422 110.564,269.344 94.344,254.114 79.080 C 233.735 58.655,201.519 46.295,179.297 50.376 M248.799 106.489 C 261.588 113.493,267.969 126.130,269.712 147.904 C 270.757 160.959,271.922 164.811,277.307 173.024 C 287.186 188.091,288.511 195.505,285.231 217.383 C 282.919 232.807,283.079 236.616,286.314 243.164 C 288.475 247.539,298.449 263.364,300.698 265.988 C 306.079 272.264,307.804 275.534,311.451 286.369 C 313.507 292.477,314.275 295.779,316.038 306.092 C 318.955 323.145,323.794 340.998,328.706 352.832 C 329.053 353.668,328.477 353.717,327.031 352.973 C 317.514 348.079,306.139 347.859,297.011 352.392 L 293.046 354.362 290.008 353.902 C 285.368 353.198,278.329 351.147,269.938 348.053 C 240.075 337.042,227.498 340.095,217.498 360.781 C 216.210 363.445,215.063 365.625,214.949 365.625 C 214.835 365.625,212.348 363.747,209.422 361.451 C 206.496 359.155,197.773 352.358,190.039 346.345 C 168.528 329.625,163.860 325.786,148.421 312.120 C 144.652 308.784,138.722 303.594,135.241 300.586 C 125.704 292.343,125.397 290.429,130.287 269.695 C 134.458 252.013,134.120 248.138,127.335 235.860 C 118.910 220.615,116.802 212.186,118.504 200.543 C 119.671 192.555,120.387 190.606,124.348 184.630 C 130.549 175.276,130.610 174.884,127.001 167.653 C 120.735 155.103,119.360 142.129,123.311 132.841 C 126.621 125.061,135.901 110.371,138.603 108.638 C 149.303 101.772,171.655 109.150,195.910 127.554 C 209.712 138.026,217.301 140.791,222.032 137.070 C 223.212 136.141,226.543 129.441,229.196 122.656 C 235.502 106.533,240.841 102.130,248.799 106.489 M236.340 143.691 C 230.683 149.637,232.688 170.703,238.910 170.703 C 244.798 170.703,247.446 154.748,243.048 145.766 C 241.118 141.824,238.798 141.107,236.340 143.691 M166.625 145.801 C 161.260 151.182,162.200 169.876,167.956 172.260 C 171.312 173.650,174.196 169.334,174.921 161.837 C 176.037 150.290,171.292 141.119,166.625 145.801 M210.742 166.483 C 210.313 166.727,208.139 168.661,205.912 170.780 C 192.832 183.226,177.161 190.913,152.344 197.056 C 142.302 199.542,142.081 199.608,141.269 200.331 C 138.124 203.130,139.040 206.449,145.002 213.857 C 153.978 225.011,161.812 227.818,190.234 230.066 C 204.734 231.213,213.693 232.795,230.469 237.170 C 241.058 239.932,240.906 239.903,242.542 239.463 C 245.231 238.739,245.970 237.767,249.800 229.930 C 251.807 225.822,254.812 220.352,256.478 217.773 C 274.939 189.203,275.362 186.052,260.938 184.575 C 241.342 182.569,228.672 178.037,219.653 169.807 C 215.348 165.879,213.210 165.081,210.742 166.483 M216.988 180.273 C 224.271 189.549,229.777 201.830,235.700 222.012 C 237.035 226.558,237.043 226.636,236.205 226.410 C 217.682 221.409,205.456 219.237,189.844 218.172 C 174.731 217.142,167.844 216.033,162.791 213.815 C 156.379 211.002,149.455 205.078,152.577 205.078 C 154.767 205.078,173.978 199.068,180.753 196.263 C 191.616 191.766,201.332 185.333,208.970 177.578 L 211.886 174.618 213.210 175.883 C 213.938 176.579,215.638 178.555,216.988 180.273 M241.016 188.474 C 245.324 189.716,252.551 191.076,257.715 191.617 C 259.058 191.758,260.156 191.965,260.156 192.077 C 260.156 192.456,257.282 197.041,252.129 204.883 C 246.676 213.181,243.750 217.912,243.750 218.432 C 243.750 219.784,242.931 218.154,242.193 215.332 C 239.604 205.442,234.470 192.782,230.002 185.275 L 229.259 184.026 232.891 185.603 C 234.889 186.471,238.545 187.763,241.016 188.474"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import React from 'react'
interface NameIconProps {
className?: string
}
export default function NameIcon({ className }: NameIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
fill="currentColor"
className={className}
>
<path d="m9.7 75v-47.1l13.5-3.3-2.1 15.2h1.5c.8-3.5 2.2-6.5 3.9-9 1.8-2.4 4.1-4.3 6.9-5.6 2.9-1.3 6.1-2 9.9-2 4.9 0 9.1 1.1 12.7 3.4 3.5 2.2 6.2 5.4 8.1 9.7 2 4.2 2.9 9.2 2.9 15v23.7h-13.4v-20.2c0-3.9-.6-7.2-1.8-9.9-1.2-2.8-2.9-4.9-5.2-6.3-2.3-1.5-5-2.2-8.2-2.2-4.8 0-8.5 1.6-11.2 4.8s-4 7.7-4 13.6v20.2z"/>
<circle cx="75" cy="68.5" r="5.7"/>
</svg>
)
}

View file

@ -0,0 +1,310 @@
import React from 'react'
import Link from 'next/link'
import Image from 'next/image'
import {
TbCopyrightOff,
TbMail,
TbBrandGithub,
TbBrandX,
} from "react-icons/tb"
import { ChevronRight } from 'lucide-react'
import RandomFooterMsg from "../objects/RandomFooterMsg"
import { cn } from '@/lib/utils'
import { colors, surfaces } from '@/lib/theme'
import { getRecentGitHubRepos } from '@/lib/github'
import {
footerNavigationLinks,
footerSupportLinks,
} from './footer-config'
import type {
FooterMenuRenderContext,
FooterMenuSection,
NavigationIcon,
} from '@/lib/types/navigation'
const FOOTER_MENU_SECTIONS: FooterMenuSection[] = [
{
type: 'links',
title: 'Navigation',
links: footerNavigationLinks,
},
{
type: 'custom',
title: 'Latest Projects',
render: ({ githubRepos, githubUsername }: FooterMenuRenderContext) => (
githubRepos.length > 0
? githubRepos.map((repo) => (
<FooterLink
key={repo.id}
href={repo.url}
icon={TbBrandGithub}
external
>
<span className="truncate" title={repo.name}>
{repo.name}
</span>
</FooterLink>
))
: (
<FooterLink
href={`https://github.com/${githubUsername}`}
icon={TbBrandGithub}
external
>
Projects unavailable visit GitHub
</FooterLink>
)
),
},
{
type: 'links',
title: 'Support Me',
links: footerSupportLinks,
},
]
interface FooterLinkProps {
href: string
children: React.ReactNode
external?: boolean
icon?: NavigationIcon
}
const FooterLink = ({ href, children, external = false, icon: Icon }: FooterLinkProps) => {
const linkProps = external ? { target: "_blank", rel: "noopener noreferrer" } : {}
return (
<Link
href={href}
{...linkProps}
className={cn(
"flex items-center transition-colors duration-300 group",
"hover:text-white"
)}
style={{ color: colors.text.muted }}
>
{Icon && (
<span className="mr-1.5 group-hover:scale-110 transition-transform">
<Icon size={14} />
</span>
)}
{children}
{external && <ChevronRight size={14} className="ml-0.5 opacity-50 group-hover:opacity-100 transition-opacity" />}
</Link>
)
}
interface FooterSectionProps {
title: string
children: React.ReactNode
}
const FooterSection = ({ title, children }: FooterSectionProps) => (
<div className="flex flex-col space-y-4">
<h3
className="font-semibold text-sm uppercase tracking-wider"
style={{ color: colors.text.secondary }}
>{title}</h3>
<div className="flex flex-col space-y-2.5">
{children}
</div>
</div>
)
type Persona = {
role: string
description: string
}
const personaOptions: Persona[] = [
{
role: 'Chief Synergy Evangelist',
description: 'Drives enterprise-wide alignment through scalable cross-functional touchpoints.'
},
{
role: 'Director of Strategic Buzzwords',
description: 'Operationalizes high-impact vocabulary to maximize stakeholder resonance.'
},
{
role: 'Vice President of Change Management',
description: 'Leads transformational roadmaps that empower teams to pivot at scale.'
},
{
role: 'Global KPI Whisperer',
description: 'Ensures metric integrity through proactive dashboard storytelling.'
},
{
role: 'Head of Agile Communications',
description: 'Facilitates sprint cadence narratives for executive-level consumption.'
},
{
role: 'VP of Continuous Optimization',
description: 'Champions always-on iteration loops to unlock compounding efficiency gains.'
},
{
role: 'Principal Narrative Architect',
description: 'Synthesizes cross-team input into unified, board-ready success frameworks.'
},
{
role: 'Lead Alignment Strategist',
description: 'Converts strategic pivots into measurable OKR cascades and culture moments.'
},
{
role: 'Chief Risk Mitigator',
description: 'De-risks enterprise bets through proactive dependency orchestration.'
},
{
role: 'Director of Value Realization',
description: 'Translates initiatives into quantifiable ROI across all stakeholder tiers.'
}
]
const defaultPersona: Persona = personaOptions[0] ?? {
role: 'Developer & Creator',
description: 'Building thoughtful digital experiences and exploring the intersection of technology, music, and creativity. Currently focused on web development and AI integration.'
}
const getPersonaByIndex = (index: number | undefined): Persona => {
if (!personaOptions.length) {
return defaultPersona
}
if (typeof index !== 'number' || Number.isNaN(index)) {
return defaultPersona
}
const safeIndex = ((Math.floor(index) % personaOptions.length) + personaOptions.length) % personaOptions.length
return personaOptions[safeIndex] ?? defaultPersona
}
interface FooterProps {
footerMessageIndex?: number
}
export default async function Footer({ footerMessageIndex }: FooterProps) {
const persona = getPersonaByIndex(footerMessageIndex)
const { username: githubUsername, repos: githubRepos } = await getRecentGitHubRepos()
return (
<footer
className={cn(surfaces.panel.overlay, "mt-auto border-t")}
style={{ color: colors.text.muted }}
>
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[1.2fr_repeat(3,minmax(0,1fr))] gap-x-10 gap-y-12 lg:gap-x-16">
<div className="col-span-1 md:col-span-2 lg:col-span-1">
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-4">
<div
className="relative w-16 h-16 rounded-full overflow-hidden ring-2"
style={{
backgroundColor: colors.borders.default,
borderColor: colors.borders.hover
}}
>
<Image
src="/ihatenodejs.jpg"
alt="Aidan"
width={64}
height={64}
className="object-cover"
/>
</div>
<div>
<h3
className="font-bold text-lg"
style={{ color: colors.text.primary }}
>Aidan</h3>
<p
className="text-sm"
style={{ color: colors.text.muted }}
>{persona.role}</p>
</div>
</div>
<p
className="text-sm leading-relaxed"
style={{ color: colors.text.muted }}
>{persona.description}</p>
<div className="flex items-center space-x-4 pt-2">
<Link
href={`https://github.com/${githubUsername}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
style={{ color: colors.text.muted }}
aria-label="GitHub"
>
<TbBrandGithub size={20} />
</Link>
<Link
href="https://x.com/aidxnn"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
style={{ color: colors.text.muted }}
aria-label="X (Twitter)"
>
<TbBrandX size={20} />
</Link>
<Link
href="/contact"
className="hover:text-white transition-colors"
style={{ color: colors.text.muted }}
aria-label="Email"
>
<TbMail size={20} />
</Link>
</div>
</div>
</div>
{FOOTER_MENU_SECTIONS.map((section) => (
<FooterSection key={section.title} title={section.title}>
{section.type === 'links'
? section.links.map(({ href, label, icon, external }) => (
<FooterLink key={href} href={href} icon={icon} external={external}>
{label}
</FooterLink>
))
: section.render({ githubUsername, githubRepos })}
</FooterSection>
))}
</div>
</div>
<div
className="border-t"
style={{
borderColor: colors.borders.muted,
backgroundColor: colors.backgrounds.card
}}
>
<div className="container mx-auto px-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] items-center gap-y-2">
<div
className="flex items-center justify-center sm:justify-start text-sm"
style={{ color: colors.text.disabled }}
>
<TbCopyrightOff className="mr-2" size={16} />
<span>Open Source and Copyright-Free</span>
</div>
<div className="flex items-center justify-center space-x-2 text-sm">
<RandomFooterMsg index={footerMessageIndex} />
</div>
{/* soon ->
<div className="flex items-center justify-center sm:justify-end space-x-4 text-sm">
<span className="flex items-center">
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
<span style={{ color: colors.text.disabled }}>All Systems Operational</span>
</span>
</div>*/}<div></div>
</div>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,495 @@
"use client"
import React, { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import {
X,
Menu,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { colors, surfaces } from '@/lib/theme'
import type {
NavigationIcon,
NavigationMenuItem,
NavigationDropdownConfig,
NavigationDropdownGroup,
} from '@/lib/types/navigation'
import { headerNavigationConfig } from './header-config'
const NAVIGATION_CONFIG: NavigationMenuItem[] = headerNavigationConfig
interface NavItemProps {
href: string
icon: NavigationIcon
children: React.ReactNode
}
const NavItem = ({ href, icon, children }: NavItemProps) => (
<div className="nav-item">
<Link href={href} className={cn("flex items-center", surfaces.button.nav)}>
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
{children}
</Link>
</div>
);
interface DropdownNavItemProps {
id: string
href: string
icon: NavigationIcon
children: React.ReactNode
dropdownContent: React.ReactNode
isMobile?: boolean
isOpen?: boolean
onOpenChange?: (id: string | null, immediate?: boolean) => void
}
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onOpenChange?.(null, true);
}
};
if (isMobile && isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [isMobile, isOpen, onOpenChange]);
const handleMouseEnter = () => {
if (!isMobile) {
onOpenChange?.(id, true);
}
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (!isMobile) {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget instanceof Node && dropdownRef.current?.contains(relatedTarget)) {
return;
}
onOpenChange?.(null);
}
};
const handleClick = (e: React.MouseEvent) => {
if (isMobile) {
e.preventDefault();
e.stopPropagation();
onOpenChange?.(isOpen ? null : id, true);
}
};
return (
<div
className="nav-item relative"
ref={dropdownRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Link
href={href}
onClick={isMobile ? handleClick : undefined}
className={cn("flex items-center justify-between w-full", surfaces.button.nav)}
>
<span className="flex items-center flex-1">
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
<span>{children}</span>
</span>
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
</Link>
{isOpen && (
<>
{!isMobile && <div className="absolute left-0 top-full w-full h-1 z-50" />}
<div className={isMobile ? 'relative w-full mt-2 ml-5 pr-4' : 'absolute left-0 mt-1 z-50 flex'}>
{dropdownContent}
</div>
</>
)}
</div>
);
};
interface NestedDropdownItemProps {
children: React.ReactNode
nestedContent: React.ReactNode
icon: NavigationIcon
isMobile?: boolean
itemKey: string
activeNested: string | null
onNestedChange: (key: string | null, immediate?: boolean) => void
}
const NestedDropdownItem = ({ children, nestedContent, icon: Icon, isMobile = false, itemKey, activeNested, onNestedChange }: NestedDropdownItemProps) => {
const itemRef = useRef<HTMLDivElement>(null);
const isOpen = activeNested === itemKey;
const handleMouseEnter = () => {
if (!isMobile) {
onNestedChange(itemKey, true);
}
};
const handleMouseLeave = (e: React.MouseEvent) => {
if (!isMobile) {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) {
return;
}
onNestedChange(null);
}
};
const handleClick = (e: React.MouseEvent) => {
if (isMobile) {
e.preventDefault();
e.stopPropagation();
onNestedChange(isOpen ? null : itemKey, true);
}
};
if (isMobile) {
return (
<div
className="relative"
ref={itemRef}
>
<button
onClick={handleClick}
className={cn("flex items-center justify-between w-full text-left px-4 py-3 text-sm", surfaces.button.dropdownItem)}
>
<span className="flex items-center flex-1">
<Icon className="mr-3" strokeWidth={2.5} size={18} />
{children}
</span>
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
</button>
{isOpen && (
<div className="relative mt-2 ml-5 pr-4 space-y-1">
{nestedContent}
</div>
)}
</div>
);
}
return (
<div
className="relative"
ref={itemRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
onClick={handleClick}
className={cn(
"flex items-center justify-between w-full text-left px-4 py-3 text-sm",
isOpen ? "bg-gray-700/40 text-white" : surfaces.button.dropdownItem
)}
>
<span className="flex items-center flex-1">
<Icon className="mr-3" strokeWidth={2.5} size={18} />
{children}
</span>
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
</button>
{isOpen && (
<>
<div className="absolute left-full top-0 w-4 h-full z-50" />
<div
className={cn(
"absolute left-full top-0 ml-1 w-64 z-50",
"animate-in fade-in-0 zoom-in-95 slide-in-from-left-2 duration-200",
surfaces.panel.dropdown
)}
onMouseEnter={() => onNestedChange(itemKey, true)}
onMouseLeave={(e) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) return;
onNestedChange(null);
}}
>
{nestedContent}
</div>
</>
)}
</div>
);
};
const renderNestedGroups = (groups: NavigationDropdownGroup[], isMobile: boolean) => {
const hasAnyTitle = groups.some(group => group.title);
return (
<div className={hasAnyTitle ? 'py-2' : ''}>
{groups.map((group, index) => (
<div key={group.title || `group-${index}`}>
{group.title && (
<div
className={cn(
"text-[11px] uppercase tracking-wide",
isMobile ? 'px-4 pt-1 pb-2' : 'px-5 pt-2 pb-2'
)}
style={{ color: colors.text.muted }}
>
{group.title}
</div>
)}
{group.links.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"flex items-center text-sm",
isMobile ? 'px-4 py-2.5' : 'px-5 py-3',
surfaces.button.dropdownItem
)}
{...(link.external && { target: '_blank', rel: 'noopener noreferrer' })}
>
{React.createElement(link.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
{link.label}
</Link>
))}
</div>
))}
</div>
);
}
const renderDropdownContent = (config: NavigationDropdownConfig, isMobile: boolean, activeNested: string | null, onNestedChange: (key: string | null, immediate?: boolean) => void) => (
<div className={cn(isMobile ? 'w-full' : cn('w-64', surfaces.panel.dropdown))}>
{config.items.map((item) => {
if (item.type === 'link') {
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center px-4 text-sm",
isMobile ? 'py-2.5' : 'py-3',
surfaces.button.dropdownItem
)}
onMouseEnter={() => {
if (!isMobile && activeNested) {
onNestedChange(null, true);
}
}}
>
{React.createElement(item.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
{item.label}
</Link>
)
}
return (
<NestedDropdownItem
key={`nested-${item.label}`}
itemKey={`nested-${item.label}`}
icon={item.icon}
isMobile={isMobile}
activeNested={activeNested}
onNestedChange={onNestedChange}
nestedContent={renderNestedGroups(item.groups, isMobile)}
>
{item.label}
</NestedDropdownItem>
)
})}
</div>
)
export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const [activeNested, setActiveNested] = useState<string | null>(null);
const [showDesktopOverlay, setShowDesktopOverlay] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(false);
const overlayCloseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const overlayOpenFrameRef = useRef<number | null>(null);
const dropdownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const nestedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const toggleMenu = () => {
setIsOpen(!isOpen);
if (isOpen) {
setActiveDropdown(null);
setActiveNested(null);
}
};
const handleDropdownChange = (id: string | null, immediate: boolean = false) => {
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
dropdownTimeoutRef.current = null;
}
if (nestedTimeoutRef.current) {
clearTimeout(nestedTimeoutRef.current);
nestedTimeoutRef.current = null;
}
if (id !== null || immediate) {
setActiveDropdown(id);
setActiveNested(null);
} else {
dropdownTimeoutRef.current = setTimeout(() => {
setActiveDropdown(null);
setActiveNested(null);
dropdownTimeoutRef.current = null;
}, 300);
}
};
const handleNestedChange = (key: string | null, immediate: boolean = false) => {
if (nestedTimeoutRef.current) {
clearTimeout(nestedTimeoutRef.current);
nestedTimeoutRef.current = null;
}
if (key !== null || immediate) {
setActiveNested(key);
} else {
nestedTimeoutRef.current = setTimeout(() => {
setActiveNested(null);
nestedTimeoutRef.current = null;
}, 300);
}
};
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (isMobile) {
if (overlayOpenFrameRef.current !== null) {
cancelAnimationFrame(overlayOpenFrameRef.current);
overlayOpenFrameRef.current = null;
}
if (overlayCloseTimeoutRef.current !== null) {
clearTimeout(overlayCloseTimeoutRef.current);
overlayCloseTimeoutRef.current = null;
}
setOverlayVisible(false);
setShowDesktopOverlay(false);
} else if (activeDropdown) {
if (overlayCloseTimeoutRef.current !== null) {
clearTimeout(overlayCloseTimeoutRef.current);
overlayCloseTimeoutRef.current = null;
}
setShowDesktopOverlay(true);
overlayOpenFrameRef.current = requestAnimationFrame(() => {
setOverlayVisible(true);
overlayOpenFrameRef.current = null;
});
} else {
if (overlayOpenFrameRef.current !== null) {
cancelAnimationFrame(overlayOpenFrameRef.current);
overlayOpenFrameRef.current = null;
}
setOverlayVisible(false);
overlayCloseTimeoutRef.current = setTimeout(() => {
setShowDesktopOverlay(false);
overlayCloseTimeoutRef.current = null;
}, 300);
}
return () => {
if (overlayOpenFrameRef.current !== null) {
cancelAnimationFrame(overlayOpenFrameRef.current);
overlayOpenFrameRef.current = null;
}
if (overlayCloseTimeoutRef.current !== null) {
clearTimeout(overlayCloseTimeoutRef.current);
overlayCloseTimeoutRef.current = null;
}
};
}, [activeDropdown, isMobile]);
return (
<>
{showDesktopOverlay && (
<div
className={cn(
'fixed inset-0 z-30 pointer-events-none transition-all duration-300 opacity-0 backdrop-blur-none',
overlayVisible && 'opacity-100 backdrop-blur-sm'
)}
aria-hidden="true"
/>
)}
<header className={cn(surfaces.panel.overlay, "sticky top-0 z-50 border-b")}>
{isOpen && (
<div
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
onClick={toggleMenu}
/>
)}
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
<Link
href="/"
className={cn(
"text-2xl font-bold transition-all duration-300 hover:glow",
"hover:text-white"
)}
style={{ color: colors.text.body }}
>
aidan.so
</Link>
<button
onClick={toggleMenu}
className="lg:hidden focus:outline-hidden"
style={{ color: colors.text.body }}
>
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
</button>
<ul className={cn(
"flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4",
"absolute lg:static w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto",
"px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50",
"lg:bg-transparent",
isOpen ? 'flex' : 'hidden lg:flex'
)}
style={{ backgroundColor: isMobile ? colors.backgrounds.cardSolid : undefined }}
>
{NAVIGATION_CONFIG.map((item) => {
if (item.type === 'link') {
return (
<NavItem key={item.id} href={item.href} icon={item.icon}>
{item.label}
</NavItem>
)
}
return (
<DropdownNavItem
key={item.id}
id={item.id}
href={item.href}
icon={item.icon}
dropdownContent={renderDropdownContent(item.dropdown, isMobile, activeNested, handleNestedChange)}
isMobile={isMobile}
isOpen={activeDropdown === item.id}
onOpenChange={handleDropdownChange}
>
{item.label}
</DropdownNavItem>
)
})}
</ul>
</nav>
</header>
</>
);
}

View file

@ -0,0 +1,47 @@
import {
House,
User,
Phone,
BookOpen,
CreditCard,
} from 'lucide-react'
import type { NavigationLink } from '@/lib/types/navigation'
import { SiGithubsponsors } from 'react-icons/si'
export const footerNavigationLinks: NavigationLink[] = [
{
href: '/',
label: 'Home',
icon: House
},
{
href: '/about',
label: 'About Me',
icon: User
},
{
href: '/contact',
label: 'Contact',
icon: Phone
},
{
href: '/manifesto',
label: 'Manifesto',
icon: BookOpen
},
]
export const footerSupportLinks: NavigationLink[] = [
{
href: 'https://donate.stripe.com/6oEeWVcXs9L9ctW4gj',
label: 'Donate via Stripe',
icon: CreditCard,
external: true,
},
{
href: 'https://github.com/sponsors/ihatenodejs',
label: 'GitHub Sponsors',
icon: SiGithubsponsors,
external: true,
},
]

View file

@ -0,0 +1,165 @@
import {
House,
Link as LinkIcon,
User,
Phone,
BookOpen,
Brain,
Smartphone,
Headphones,
Briefcase,
Package,
Cloud,
FileText,
} from 'lucide-react'
import { TbUserHeart } from 'react-icons/tb'
import KowalskiIcon from '@/components/icons/KowalskiIcon'
import GoogleIcon from '@/components/icons/GoogleIcon'
import type { NavigationMenuItem } from '@/lib/types/navigation'
export const headerNavigationConfig: NavigationMenuItem[] = [
{
type: 'link',
id: 'home',
label: 'Home',
href: '/',
icon: House,
},
{
type: 'dropdown',
id: 'about',
label: 'About Me',
href: '/about',
icon: User,
dropdown: {
items: [
{
type: 'link',
label: 'Get to Know Me',
href: '/about',
icon: TbUserHeart,
},
{
type: 'nested',
label: 'Devices',
icon: Smartphone,
groups: [
{
title: 'Phones',
links: [
{
type: 'link',
label: 'Pixel 3a XL (bonito)',
href: '/device/bonito',
icon: GoogleIcon,
},
{
type: 'link',
label: 'Pixel 7 Pro (cheetah)',
href: '/device/cheetah',
icon: GoogleIcon,
},
{
type: 'link',
label: 'Pixel 9 Pro (komodo)',
href: '/device/komodo',
icon: GoogleIcon,
},
],
},
{
title: 'DAPs',
links: [
{
type: 'link',
label: 'JM21',
href: '/device/jm21',
icon: Headphones,
},
],
},
],
},
{
type: 'nested',
label: 'Projects',
icon: Briefcase,
groups: [
{
title: '',
links: [
{
type: 'link',
label: 'modules',
href: 'https://modules.lol/',
icon: Package,
external: true,
},
{
type: 'link',
label: 'Kowalski',
href: 'https://kowalski.social/',
icon: KowalskiIcon,
external: true,
},
{
type: 'link',
label: 'p0ntus',
href: 'https://p0ntus.com/',
icon: Cloud,
external: true,
},
],
},
],
},
],
},
},
{
type: 'dropdown',
id: 'ai',
label: 'AI',
href: '/ai',
icon: Brain,
dropdown: {
items: [
{
type: 'link',
label: 'AI Usage',
href: '/ai/usage',
icon: Brain,
},
],
},
},
{
type: 'link',
id: 'contact',
label: 'Contact',
href: '/contact',
icon: Phone,
},
{
type: 'link',
id: 'domains',
label: 'Domains',
href: '/domains',
icon: LinkIcon,
},
{
type: 'link',
id: 'manifesto',
label: 'Manifesto',
href: '/manifesto',
icon: BookOpen,
},
{
type: 'link',
id: 'docs',
label: 'Docs',
href: '/docs',
icon: FileText,
},
]

View file

@ -0,0 +1,4 @@
export { default as Header } from './Header'
export { default as Footer } from './Footer'
export { headerNavigationConfig } from './header-config'
export { footerNavigationLinks, footerSupportLinks } from './footer-config'

View file

@ -4,7 +4,7 @@ import { useEffect } from "react";
export default function AnimatedTitle() {
useEffect(() => {
const title = 'aidxn.cc';
const title = 'aidan.so';
let index = 1;
let forward = true;
const interval = setInterval(() => {

View file

@ -1,22 +1,42 @@
import { default as NextLink } from 'next/link'
import { cn } from '@/lib/theme'
import { externalLinkProps } from '@/lib/utils/styles'
interface LinkProps {
href: string
className?: string
target?: string
rel?: string
variant?: 'default' | 'nav' | 'muted'
external?: boolean
children: React.ReactNode
}
export default function Link(props: LinkProps) {
export default function Link({
href,
className,
target,
rel,
variant = 'default',
external,
children
}: LinkProps) {
const isExternal = external || href.startsWith('http')
const variantStyles = {
default: 'text-blue-400 hover:underline',
nav: 'text-gray-300 hover:text-white',
muted: 'text-gray-400 hover:text-gray-300'
}
return (
<NextLink
href={props.href}
className={`text-blue-400 hover:underline ${props.className}`}
target={props.target}
rel={props.rel}
href={href}
className={cn(variantStyles[variant], className)}
target={target || (isExternal ? externalLinkProps.target : undefined)}
rel={rel || (isExternal ? externalLinkProps.rel : undefined)}
>
{props.children}
{children}
</NextLink>
)
}

View file

@ -0,0 +1,26 @@
import { ReactNode } from 'react'
interface PageHeaderProps {
icon: ReactNode
title: string
subtitle?: string
className?: string
}
export default function PageHeader({ icon, title, subtitle, className }: PageHeaderProps) {
return (
<div className={className}>
<div className="flex flex-col gap-4">
<div className="flex justify-center">
{icon}
</div>
<h1 className="text-4xl font-bold mt-2 text-center text-gray-200 glow">
{title}
</h1>
{subtitle && (
<p className="text-gray-400 text-center">{subtitle}</p>
)}
</div>
</div>
)
}

View file

@ -1,83 +1,46 @@
"use client"
import {
SiNextdotjs,
SiLucide,
SiVercel,
SiSimpleicons,
SiFontawesome,
SiShadcnui,
SiTailwindcss
} from "react-icons/si"
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { footerMessages } from './footerMessages'
export const footerMessages = [
[
"Built with Next.js",
"https://nextjs.org",
<SiNextdotjs key="nextjs" className="text-md mr-2" />
],
[
"Icons by Lucide",
"https://lucide.dev/",
<SiLucide key="lucide" className="text-md mr-2" />
],
[
"Icons by Simple Icons",
"https://simpleicons.org/",
<SiSimpleicons key="simpleicons" className="text-md mr-2" />
],
[
"Font by Vercel",
"https://vercel.com/font",
<SiVercel key="vercel" className="text-md mr-2" />
],
[
"Icons by Font Awesome",
"https://fontawesome.com/",
<SiFontawesome key="fontawesome" className="text-md mr-2" />
],
[
"Components by Shadcn",
"https://ui.shadcn.com/",
<SiShadcnui key="shadcn" className="text-md mr-2" />
],
[
"Styled with Tailwind",
"https://tailwindcss.com/",
<SiTailwindcss key="tailwind" className="text-md mr-2" />
]
]
interface RandomFooterMsgProps {
index?: number
}
export default function RandomFooterMsg() {
const [randomIndex, setRandomIndex] = useState(0)
const [isMounted, setIsMounted] = useState(false)
const fallbackMessage = footerMessages[0] ?? null
useEffect(() => {
setIsMounted(true)
setRandomIndex(Math.floor(Math.random() * footerMessages.length))
}, [])
if (!isMounted) {
const [message, url, icon] = footerMessages[0]
return (
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
<div className="flex items-center justify-center">
{icon}
{message}
</div>
</Link>
)
const getMessageByIndex = (index: number | undefined) => {
if (!footerMessages.length) {
return null
}
const [message, url, icon] = footerMessages[randomIndex]
if (typeof index !== 'number' || Number.isNaN(index)) {
return fallbackMessage
}
const safeIndex = ((Math.floor(index) % footerMessages.length) + footerMessages.length) % footerMessages.length
return footerMessages[safeIndex] ?? fallbackMessage
}
export default function RandomFooterMsg({ index }: RandomFooterMsgProps) {
const message = getMessageByIndex(index)
if (!message) {
return null
}
const { text, url, Icon } = message
return (
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors mb-2 sm:mb-0"
>
<div className="flex items-center justify-center">
{icon}
{message}
<Icon className="text-md mr-2" />
{text}
</div>
</Link>
)

View file

@ -0,0 +1,54 @@
import type { IconType } from 'react-icons'
import {
SiFontawesome,
SiLucide,
SiNextdotjs,
SiShadcnui,
SiSimpleicons,
SiTailwindcss,
SiVercel
} from 'react-icons/si'
export type FooterMessage = {
text: string
url: string
Icon: IconType
}
export const footerMessages: FooterMessage[] = [
{
text: 'Built with Next.js',
url: 'https://nextjs.org',
Icon: SiNextdotjs
},
{
text: 'Icons by Lucide',
url: 'https://lucide.dev/',
Icon: SiLucide
},
{
text: 'Icons by Simple Icons',
url: 'https://simpleicons.org/',
Icon: SiSimpleicons
},
{
text: 'Font by Vercel',
url: 'https://vercel.com/font',
Icon: SiVercel
},
{
text: 'Icons by Font Awesome',
url: 'https://fontawesome.com/',
Icon: SiFontawesome
},
{
text: 'Components by Shadcn',
url: 'https://ui.shadcn.com/',
Icon: SiShadcnui
},
{
text: 'Styled with Tailwind',
url: 'https://tailwindcss.com/',
Icon: SiTailwindcss
}
]

72
components/ui/Card.tsx Normal file
View file

@ -0,0 +1,72 @@
import { ReactNode } from 'react'
import { cn, surfaces } from '@/lib/theme'
type CardVariant = keyof typeof surfaces.card
type SectionVariant = keyof typeof surfaces.section
interface CardProps {
children: ReactNode
title?: ReactNode
variant?: CardVariant | SectionVariant
className?: string
spanCols?: number
onClick?: () => void
}
/**
* Versatile card component with optional title and column spanning.
*
* Supports both card and section variants from the theme system.
* Can display an optional title (string or ReactNode with icons) and span multiple grid columns.
*
* @example
* ```tsx
* // Simple card
* <Card variant="default">Content</Card>
*
* // Section card with title
* <Card variant="default" title="My Section">Content</Card>
*
* // Card with icon in title
* <Card title={<div className="flex items-center gap-2"><Icon />Title</div>}>
* Content
* </Card>
*
* // Card spanning 2 columns
* <Card spanCols={2}>Wide content</Card>
* ```
*/
export function Card({
children,
title,
variant = 'default',
className,
spanCols,
onClick
}: CardProps) {
let variantClass: string
if (variant in surfaces.card) {
variantClass = surfaces.card[variant as CardVariant]
} else if (variant in surfaces.section) {
variantClass = surfaces.section[variant as SectionVariant]
} else {
variantClass = surfaces.card.default
}
const colSpanClass = spanCols ? `lg:col-span-${spanCols}` : ''
return (
<div
className={cn(variantClass, colSpanClass, className)}
onClick={onClick}
>
{title && (
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
{title}
</h2>
)}
{children}
</div>
)
}

View file

@ -0,0 +1,41 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface CardGridProps {
children: ReactNode
cols?: '2' | '3' | '4'
className?: string
}
/**
* Responsive card grid layout component.
*
* Provides a consistent grid system for card layouts with mobile-first responsive breakpoints.
* Default is 3 columns (1 on mobile, 2 on tablet, 3 on desktop).
*
* @example
* ```tsx
* <CardGrid cols="3">
* <Card>Card 1</Card>
* <Card>Card 2</Card>
* <Card>Card 3</Card>
* </CardGrid>
* ```
*/
export function CardGrid({
children,
cols = '3',
className
}: CardGridProps) {
const gridClasses = {
'2': 'grid grid-cols-1 md:grid-cols-2 gap-4 p-4',
'3': 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4',
'4': 'grid grid-cols-2 md:grid-cols-4 gap-4'
}
return (
<div className={cn(gridClasses[cols], className)}>
{children}
</div>
)
}

View file

@ -0,0 +1,95 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useState, useMemo, type ReactNode } from 'react'
interface PaginatedCardListProps<T> {
items: T[]
renderItem: (item: T, index: number) => ReactNode
itemsPerPage: number
title: string
icon?: ReactNode
subtitle?: string
/** Function to extract unique key from item */
getItemKey?: (item: T, index: number) => string | number
}
export default function PaginatedCardList<T>({
items,
renderItem,
itemsPerPage,
title,
icon,
subtitle,
getItemKey
}: PaginatedCardListProps<T>) {
const [currentPage, setCurrentPage] = useState(1)
const { totalPages, currentItems, startIndex } = useMemo(() => {
const totalPages = Math.ceil(items.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentItems = items.slice(startIndex, endIndex)
return { totalPages, currentItems, startIndex }
}, [items, itemsPerPage, currentPage])
const goToNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
const goToPreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
return (
<section className="p-4 sm:p-6 lg:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 flex flex-col min-h-[500px] sm:min-h-[600px]">
<div className="flex flex-col sm:flex-row sm:justify-between gap-2 mb-4 sm:mb-6">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-200 flex items-center gap-2">
{icon}
{title}
</h2>
{subtitle && (
<p className="text-muted-foreground italic text-xs sm:text-sm">{subtitle}</p>
)}
</div>
<div className="space-y-3 sm:space-y-4 flex-grow mb-4 sm:mb-6 min-h-[300px] sm:min-h-[400px]">
{currentItems.map((item, index) => {
const globalIndex = startIndex + index
const key = getItemKey ? getItemKey(item, globalIndex) : globalIndex
return <div key={key}>{renderItem(item, globalIndex)}</div>
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-auto pt-4 sm:pt-6 pb-1 sm:pb-2 border-t border-gray-700">
<button
onClick={goToPreviousPage}
disabled={currentPage === 1}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-gray-300 hover:text-gray-100 disabled:text-gray-600 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={14} className="sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Previous</span>
<span className="sm:hidden">Prev</span>
</button>
<span className="text-xs sm:text-sm text-gray-400">
Page {currentPage} of {totalPages}
</span>
<button
onClick={goToNextPage}
disabled={currentPage === totalPages}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm text-gray-300 hover:text-gray-100 disabled:text-gray-600 disabled:cursor-not-allowed transition-colors"
>
Next
<ChevronRight size={14} className="sm:w-4 sm:h-4" />
</button>
</div>
)}
</section>
)
}

32
components/ui/Section.tsx Normal file
View file

@ -0,0 +1,32 @@
import { ReactNode } from 'react'
import { cn, surfaces } from '@/lib/theme'
interface SectionProps {
children: ReactNode
variant?: keyof typeof surfaces.section
className?: string
id?: string
title?: ReactNode
}
export function Section({
children,
variant = 'default',
className,
id,
title
}: SectionProps) {
return (
<section
id={id}
className={cn(surfaces.section[variant], className)}
>
{title && (
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
{title}
</h2>
)}
{children}
</section>
)
}

20
components/ui/Surface.tsx Normal file
View file

@ -0,0 +1,20 @@
import { ReactNode } from 'react'
import { cn, surfaces } from '@/lib/theme'
interface SurfaceProps {
children: ReactNode
variant?: keyof typeof surfaces.panel
className?: string
}
export function Surface({
children,
variant = 'dropdown',
className
}: SurfaceProps) {
return (
<div className={cn(surfaces.panel[variant], className)}>
{children}
</div>
)
}

View file

@ -1,17 +1,22 @@
import { SiGithub, SiForgejo } from "react-icons/si"
import { TbStar, TbGitBranch } from "react-icons/tb"
import featuredProjects from "@/public/data/featured.json"
import Link from "next/link"
import { cn } from "@/lib/utils"
import type { FeaturedProject } from "@/lib/github"
export default function GitHubFeatured({ className }: { className?: string }) {
interface FeaturedReposProps {
projects: FeaturedProject[]
className?: string
}
export default function FeaturedRepos({ projects, className }: FeaturedReposProps) {
return (
<div className={cn("grid grid-cols-1 md:grid-cols-2 gap-4", className)}>
{featuredProjects.map((project) => (
{projects.map((project) => (
<div key={project.id} className="bg-gray-800 p-6 rounded-lg shadow-md min-h-[200px] flex flex-col">
<div className="flex-1">
<h3 className="flex items-center justify-center text-xl font-bold text-gray-100 mb-3">
{project.github ? <SiGithub className="mr-2" /> : <SiForgejo className="mr-2" />} {project.name}
{project.platform === 'github' ? <SiGithub className="mr-2" /> : <SiForgejo className="mr-2" />} {project.name}
</h3>
<p className="text-gray-300 grow">{project.description}</p>
</div>

View file

@ -0,0 +1,41 @@
"use client"
import Image from 'next/image'
import { useState } from 'react'
interface GitHubStatsImageProps {
username: string
}
export default function GitHubStatsImage({ username }: GitHubStatsImageProps) {
const [imageError, setImageError] = useState(false)
if (imageError) { return null }
return (
<div className="flex flex-col justify-center items-center w-full mt-4 gap-4">
<Image
src={`https://github-readme-stats.vercel.app/api?username=${username}&theme=dark&show_icons=true&hide_border=true&count_private=true`}
alt={`${username}'s Stats`}
width={420}
height={200}
onError={() => setImageError(true)}
loading="eager"
priority
unoptimized
className="max-w-full h-auto"
/>
<Image
src={`https://github-readme-stats.vercel.app/api/top-langs/?username=${username}&theme=dark&show_icons=true&hide_border=true&layout=compact`}
alt={`${username}'s Top Languages`}
width={300}
height={200}
onError={() => setImageError(true)}
loading="eager"
priority
unoptimized
className="max-w-full h-auto"
/>
</div>
)
}

View file

@ -12,6 +12,7 @@ import { Progress } from "@/components/ui/progress"
import Link from "@/components/objects/Link"
import ScrollTxt from "@/components/objects/MusicText"
import { connectSocket, disconnectSocket } from "@/lib/socket"
import { effects } from '@/lib/theme/effects'
interface LastFmResponse {
album?: {
@ -148,7 +149,7 @@ const NowPlaying: React.FC = () => {
href={nowPlaying.mbid ? `https://musicbrainz.org/release/${nowPlaying.mbid}` : `https://listenbrainz.org/user/p0ntus`}
target="_blank"
rel="noopener noreferrer"
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
className="border-b border-gray-700 px-2 py-0 block" style={{background: effects.gradients.musicPlayer}}
>
<div className="text-center leading-none pb-1">
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" className="-mt-0.5" />
@ -196,7 +197,7 @@ const NowPlaying: React.FC = () => {
{/* Virtual screen */}
<div className="mx-2 mt-2 flex-1 bg-black overflow-hidden flex flex-col">
{screenOn && (
<div className="bg-gradient-to-b from-gray-700 via-gray-800 to-gray-900 border-b border-gray-700" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
<div className="border-b border-gray-700" style={{background: effects.gradients.musicPlayer}}>
<div className="relative flex items-center pr-1 py-0.5">
<FaBluetoothB size={14} className="text-gray-400" />
<div className="absolute left-1/2 transform -translate-x-1/2 text-white text-xs font-medium">{formatTime(currentTime)}</div>
@ -212,7 +213,7 @@ const NowPlaying: React.FC = () => {
)}
{/* Player controls and seekbar */}
{screenOn && nowPlaying.track_name && (
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
<div className={`${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: effects.gradients.musicPlayer}}>
<div className="flex justify-center items-center gap-0 px-2">
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">

20
i18n.ts
View file

@ -1,20 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
'en-US': {
translation: require('./public/locales/en-US.json')
}
},
fallbackLng: 'en-US',
interpolation: {
escapeValue: false
}
});
export default i18n;

View file

@ -0,0 +1,41 @@
export interface FeaturedRepoConfig {
id: number
owner: string
repo: string
description: string
platform: 'github' | 'forgejo'
forgejoUrl?: string // Base URL for Forgejo instance
}
export const featuredRepos: FeaturedRepoConfig[] = [
{
id: 1,
owner: 'aidan',
repo: 'aidxnCC',
description: 'aidxnCC is the third version of my personal website',
platform: 'forgejo',
forgejoUrl: 'git.p0ntus.com',
},
{
id: 2,
owner: 'abocn',
repo: 'TelegramBot',
description: 'Landing page for p0ntus mail',
platform: 'github',
},
{
id: 3,
owner: 'abocn',
repo: 'modules',
description: 'A Magisk/KernelSU module repository',
platform: 'github',
},
{
id: 4,
owner: 'pontus',
repo: 'pontus-front',
description: 'The frontend and API for p0ntus, my free privacy-focused service provider',
platform: 'forgejo',
forgejoUrl: 'git.p0ntus.com',
},
]

61
lib/devices/config.ts Normal file
View file

@ -0,0 +1,61 @@
/**
* Device configuration constants and display labels.
*
* @remarks
* This module provides configuration constants used throughout the device showcase
* for consistent labeling and sizing of UI elements.
*
* @module lib/devices/config
* @category Devices
* @public
*/
/**
* Human-readable labels for device types.
*
* @remarks
* Maps device type identifiers to their display labels for use in UI components.
*
* @example
* ```tsx
* import { deviceTypeLabels } from '@/lib/devices/config'
*
* const DeviceTypeBadge = ({ type }: { type: keyof typeof deviceTypeLabels }) => (
* <span>{deviceTypeLabels[type]}</span>
* )
* ```
*
* @category Devices
* @public
*/
export const deviceTypeLabels = {
/** Label for mobile phone devices */
mobile: 'Mobile device',
/** Label for digital audio player devices */
dap: 'Digital audio player'
} as const
/**
* Standard icon sizes for device components.
*
* @remarks
* Provides consistent icon sizing across device stat displays and section headers.
* All sizes are in pixels.
*
* @example
* ```tsx
* import { iconSizes } from '@/lib/devices/config'
* import { Smartphone } from 'lucide-react'
*
* <Smartphone size={iconSizes.stat} />
* ```
*
* @category Devices
* @public
*/
export const iconSizes = {
/** Icon size for device stat displays (60px) */
stat: 60,
/** Icon size for device section headers (60px) */
section: 60
} as const

465
lib/devices/data.ts Normal file
View file

@ -0,0 +1,465 @@
import {
Battery,
Bluetooth,
Clock,
Cast,
Cpu,
Gauge,
HardDrive,
Hash,
Headphones,
Layers,
MemoryStick,
Monitor,
Music,
Package,
Radio,
Ruler,
ShieldCheck,
Smartphone,
Sparkles,
SquarePen,
Usb,
Wifi,
Zap
} from 'lucide-react';
import { FaYoutube } from 'react-icons/fa';
import { MdOutlineAndroid } from 'react-icons/md';
import { RiTelegram2Fill } from 'react-icons/ri';
import { TbDeviceSdCard } from 'react-icons/tb';
import { VscTerminalLinux } from 'react-icons/vsc';
import type { DeviceCollection } from '@/lib/types';
export const devices: DeviceCollection = {
komodo: {
slug: 'komodo',
name: 'Pixel 9 Pro XL',
shortName: 'Pixel 9 Pro XL',
codename: 'komodo',
type: 'mobile',
manufacturer: 'Google',
status: 'Android beta lab',
releaseYear: 2024,
heroImage: {
src: '/img/komodo.png',
alt: 'Google Pixel 9 Pro XL (komodo)',
},
tagline: 'Bleeding-edge Pixel tuned for experimentation and kernel tinkering.',
summary: [
'The Pixel 9 Pro XL is my sandbox for canary Android builds. It runs preview releases while staying rooted thanks to KernelSU-Next and a SUSFS-enabled kernel.',
'I lean on it for testing new modules and automation ideas before they touch my day-to-day setup.',
],
stats: [
{
title: 'Core silicon',
icon: Cpu,
items: [
{ label: 'SoC', value: 'Google Tensor G4' },
{ label: 'RAM', value: '16 GB LPDDR5X' },
{ label: 'Storage', value: '128 GB UFS 4.0' },
],
},
{
title: 'Software channel',
icon: MdOutlineAndroid,
items: [
{
label: 'ROM',
value: 'Android 16 QPR2',
href: 'https://developer.android.com/about/versions/16/qpr2',
},
{
label: 'Kernel',
value: '6.1.138 android14 (SUSFS Wild)',
href: 'https://github.com/WildKernels/GKI_KernelSU_SUSFS',
},
{
label: 'Root',
value: 'KernelSU-Next',
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
title: 'Media stack',
icon: Music,
items: [
{ label: 'Streaming', value: 'Tidal', href: 'https://tidal.com' },
{ label: 'Local files', value: 'MiXplorer', href: 'https://mixplorer.com/' },
{ label: 'Video', value: 'ReVanced', href: 'https://revanced.app' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Google Tensor G4', icon: Cpu },
{ label: 'RAM', value: '16 GB LPDDR5X', icon: MemoryStick },
{ label: 'Storage', value: '128 GB UFS 4.0', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'ROM',
value: 'Android 16 QPR2',
icon: MdOutlineAndroid,
href: 'https://developer.android.com/about/versions/16/qpr2',
},
{
label: 'Kernel',
value: '6.1.138 android14 (SUSFS Wild)',
icon: VscTerminalLinux,
href: 'https://github.com/WildKernels/GKI_KernelSU_SUSFS',
},
{
label: 'Root',
value: 'KernelSU-Next',
icon: ShieldCheck,
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
id: 'apps',
title: 'Daily Apps',
icon: Package,
rows: [
{ label: 'Music', value: 'Tidal', icon: Music, href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', icon: Package, href: 'https://mixplorer.com/' },
{ label: 'Telegram', value: 'AyuGram', icon: RiTelegram2Fill, href: 'https://t.me/AyuGramReleases' },
{ label: 'YouTube', value: 'ReVanced', icon: FaYoutube, href: 'https://revanced.app' },
],
},
{
id: 'modules',
title: 'Module Suite',
icon: Layers,
listItems: [
{ label: 'bindhosts', href: 'https://modules.lol/module/kowx712-bindhosts' },
{ label: 'Emoji Replacer', href: 'https://github.com/EmojiReplacer/Emoji-Replacer' },
{ label: 'F-Droid Privileged Extension', href: 'https://modules.lol/module/entr0pia-f-droid-privileged-extension-installer' },
{ label: 'SUSFS for KernelSU', href: 'https://modules.lol/module/sidex15-susfs' },
{ label: 'Tricky Store', href: 'https://modules.lol/module/5ec1cff-tricky-store' },
{ label: 'Yuri Keybox Manager', href: 'https://modules.lol/module/dpejoh-and-yuri-yurikey' },
],
}
],
},
cheetah: {
slug: 'cheetah',
name: 'Pixel 7 Pro',
shortName: 'Pixel 7 Pro',
codename: 'cheetah',
type: 'mobile',
manufacturer: 'Google',
status: 'Daily driver',
releaseYear: 2022,
heroImage: {
src: '/img/cheetah.png',
alt: 'Google Pixel 7 Pro (cheetah)',
},
tagline: 'Reliable flagship tuned for rooted daily use.',
summary: [
'My everyday carry balances performance and battery life with a stable crDroid build and KernelSU-Next for system-level tweaks.',
'The camera stack and Tensor-only optimizations still impress, especially when paired with my media workflow.',
],
stats: [
{
title: 'Core hardware',
icon: Cpu,
items: [
{ label: 'SoC', value: 'Google Tensor G2' },
{ label: 'RAM', value: '12 GB LPDDR5' },
{ label: 'Storage', value: '128 GB' },
],
},
{
title: 'Current build',
icon: MdOutlineAndroid,
items: [
{ label: 'ROM', value: 'crDroid 11.6', href: 'https://crdroid.net' },
{ label: 'Kernel', value: '6.1.99 android14' },
{ label: 'Root', value: 'KernelSU-Next', href: 'https://github.com/rifsxd/KernelSU-Next' },
],
},
{
title: 'Media kit',
icon: Music,
items: [
{ label: 'Streaming', value: 'Tidal', href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', href: 'https://mixplorer.com/' },
{ label: 'YouTube', value: 'ReVanced', href: 'https://revanced.app' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Google Tensor G2', icon: Cpu },
{ label: 'RAM', value: '12 GB LPDDR5', icon: MemoryStick },
{ label: 'Storage', value: '128 GB', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'ROM',
value: 'crDroid Android 11.6',
icon: MdOutlineAndroid,
href: 'https://crdroid.net',
},
{
label: 'Kernel',
value: '6.1.99 android14',
icon: VscTerminalLinux,
},
{
label: 'Root',
value: 'KernelSU-Next',
icon: ShieldCheck,
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
id: 'apps',
title: 'App Loadout',
icon: Package,
rows: [
{ label: 'Music', value: 'Tidal', icon: Music, href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', icon: Package, href: 'https://mixplorer.com/' },
{ label: 'Telegram', value: 'AyuGram', icon: RiTelegram2Fill, href: 'https://t.me/AyuGramReleases' },
{ label: 'YouTube', value: 'ReVanced', icon: FaYoutube, href: 'https://revanced.app' },
],
},
{
id: 'modules',
title: 'Module Suite',
icon: Layers,
listItems: [
{ label: 'bindhosts', href: 'https://github.com/bindhosts/bindhosts' },
{ label: 'Emoji Replacer', href: 'https://github.com/EmojiReplacer/Emoji-Replacer' },
{ label: 'ReZygisk', href: 'https://github.com/PerformanC/ReZygisk' },
{ label: 'LSPosed JingMatrix', href: 'https://github.com/JingMatrix/LSPosed' },
],
},
{
id: 'review',
title: 'Review',
icon: SquarePen,
rating: {
value: 4.5,
scale: 5,
label: 'Personal score',
},
paragraphs: [
'The jump from a Galaxy A32 5G was dramatic. Tensor silicon keeps the phone responsive, especially with 12 GB of RAM backing daily multitasking.',
'Battery life wavers when Play Integrity tweaks are active, but the photo pipeline more than compensates—the Pixel still wins for quick captures.',
'Hardware quirks aside (RIP volume rocker), Android makes on-screen controls painless, so the device stays an easy recommendation.',
],
},
],
},
bonito: {
slug: 'bonito',
name: 'Pixel 3a XL',
shortName: 'Pixel 3a XL',
codename: 'bonito',
type: 'mobile',
manufacturer: 'Google',
status: 'Ubuntu Touch testing',
releaseYear: 2019,
heroImage: {
src: '/img/bonito.png',
alt: 'Google Pixel 3a XL (bonito)',
},
tagline: 'Legacy Pixel reborn as a sandbox for Ubuntu Touch.',
summary: [
'Retired from Android duty, the Pixel 3a XL now explores the Ubuntu Touch ecosystem as a daily development mule.',
'It highlights what the community-driven OS can do on aging hardware while still handling lightweight messaging and media.',
],
stats: [
{
title: 'Core silicon',
icon: Cpu,
items: [
{ label: 'Chipset', value: 'Snapdragon 670' },
{ label: 'RAM', value: '4 GB' },
{ label: 'Storage', value: '64 GB' },
],
},
{
title: 'Current build',
icon: MdOutlineAndroid,
items: [
{
label: 'OS',
value: 'Ubuntu Touch stable',
href: 'https://www.ubuntu-touch.io',
},
{ label: 'Kernel', value: '4.9.337' },
],
},
{
title: 'Essentials',
icon: Package,
items: [
{ label: 'Music', value: 'uSonic', href: 'https://github.com/arubislander/uSonic' },
{ label: 'Messaging', value: 'TELEports', href: 'https://open-store.io/app/teleports.ubports' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Qualcomm Snapdragon 670', icon: Cpu },
{ label: 'RAM', value: '4 GB', icon: MemoryStick },
{ label: 'Storage', value: '64 GB', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'OS',
value: 'Ubuntu Touch',
icon: MdOutlineAndroid,
href: 'https://www.ubuntu-touch.io',
},
{ label: 'Kernel', value: '4.9.337', icon: VscTerminalLinux },
],
},
{
id: 'apps',
title: 'App Loadout',
icon: Package,
rows: [
{ label: 'Music', value: 'uSonic', icon: Music, href: 'https://github.com/arubislander/uSonic' },
{ label: 'Messaging', value: 'TELEports', icon: RiTelegram2Fill, href: 'https://open-store.io/app/teleports.ubports' },
],
}
],
},
jm21: {
slug: 'jm21',
name: 'FiiO JM21',
shortName: 'FiiO JM21',
codename: 'jm21',
type: 'dap',
manufacturer: 'FiiO',
status: 'Portable Hi-Fi rig',
releaseYear: 2024,
heroImage: {
src: '/img/jm21.png',
alt: 'FiiO JM21 digital audio player',
},
tagline: 'Compact Android DAP with a dual-DAC audio chain.',
summary: [
'The JM21 is my dedicated portable rig. Dual Cirrus Logic DACs and a balanced amp stage deliver more headroom than a typical phone stack.',
'Android 13 keeps app support flexible, so streaming and offline libraries live together without compromise.',
],
stats: [
{
title: 'Audio pipeline',
icon: Headphones,
items: [
{ label: 'DAC', value: 'Dual CS43198' },
{ label: 'Amp', value: 'Dual SGM8262' },
{ label: 'SNR', value: 'Up to 129 dB' },
],
},
{
title: 'Outputs',
icon: Radio,
items: [
{ label: 'Single-ended', value: '3.5 mm' },
{ label: 'Balanced', value: '4.4 mm' },
{ label: 'Digital', value: 'USB-C / coaxial' },
],
},
{
title: 'Power & runtime',
icon: Battery,
items: [
{ label: 'Power', value: '245 mW SE / 700 mW BAL @32Ω' },
{ label: 'Battery', value: '2400 mAh' },
{ label: 'Storage', value: '32 GB + microSD up to 2 TB' },
],
},
],
sections: [
{
id: 'core-specs',
title: 'Core Specs',
icon: Cpu,
rows: [
{ label: 'Processor', value: 'Qualcomm Snapdragon 680 (octa-core, 2.4 GHz)', icon: Cpu },
{ label: 'RAM', value: '3 GB', icon: MemoryStick },
{ label: 'Storage', value: '32 GB (≈22 GB usable)', icon: HardDrive },
{ label: 'Expansion', value: 'microSD up to 2 TB', icon: TbDeviceSdCard },
{ label: 'Display', value: '4.7" IPS, 1334 × 750', icon: Monitor },
{ label: 'Dimensions', value: '120.7 × 68 × 13 mm', icon: Ruler },
{ label: 'Weight', value: '156 g', icon: Gauge },
{ label: 'Chassis', value: 'Ultra-thin 13 mm frame', icon: Layers },
],
},
{
id: 'audio',
title: 'Audio Hardware',
icon: Headphones,
rows: [
{ label: 'DAC', value: 'Dual Cirrus Logic CS43198', icon: Headphones },
{ label: 'Amplifier', value: 'Dual SGM8262', icon: Layers },
{ label: 'Outputs', value: '3.5 mm SE, 4.4 mm BAL, coaxial, USB-C', icon: Radio },
{ label: 'Power output', value: '245 mW SE / 700 mW BAL @32Ω (THD+N <1%)', icon: Zap },
{ label: 'Impedance', value: '<1Ω SE, <1.5Ω BAL', icon: Hash },
{ label: 'Frequency response', value: '20 Hz 80 kHz (<0.7 dB)', icon: Music },
{ label: 'Formats', value: 'PCM 384 kHz/32-bit, DSD256, full MQA', icon: Sparkles },
],
},
{
id: 'connectivity',
title: 'Connectivity & OS',
icon: Wifi,
rows: [
{ label: 'OS', value: 'Custom Android 13', icon: MdOutlineAndroid },
{ label: 'Bluetooth', value: 'v5.0 (SBC, AAC, aptX, aptX HD, LDAC, LHDC)', icon: Bluetooth },
{ label: 'Wi-Fi', value: '802.11 a/b/g/n/ac, dual-band', icon: Wifi },
{ label: 'USB DAC', value: 'Asynchronous, 384 kHz/32-bit', icon: Usb },
{ label: 'AirPlay / DLNA', value: 'Supported', icon: Cast },
{ label: 'Battery', value: '2400 mAh', icon: Battery },
{ label: 'Charging', value: '≈2 h via 5V 2A', icon: Clock },
],
},
],
},
};
export const deviceSlugs = Object.keys(devices);
export const mobileDevices = Object.values(devices).filter((device) => device.type === 'mobile');
export const dapDevices = Object.values(devices).filter((device) => device.type === 'dap');
export function getDeviceBySlug(slug: string) {
return devices[slug];
}

13
lib/devices/index.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Device data exports for my portfolio showcase.
*
* @remarks
* This module re-exports all device-related data from the data module.
* Currently exports device specifications for display in the device showcase.
*
* @module lib/devices
* @category Devices
* @public
*/
export * from './data';

48
lib/docs/loader.ts Normal file
View file

@ -0,0 +1,48 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { parseTypeDocJSON } from './parser'
import type { TypeDocRoot, DocSection } from './types'
/**
* Loads and parses TypeDoc-generated API documentation from JSON file.
*
* @returns Array of documentation sections organized by category
*
* @remarks
* This function:
* 1. Reads the TypeDoc-generated JSON file from `public/docs/api.json`
* 2. Parses the raw TypeDoc data into structured documentation sections
* 3. Returns an empty array if the file is missing or invalid
*
* The TypeDoc JSON file should be generated by running:
* ```bash
* typedoc --options typedoc.json
* ```
*
* @example
* ```ts
* import { loadDocumentation } from '@/lib/docs/loader'
*
* // In a server component
* export default function DocsPage() {
* const sections = loadDocumentation()
* return <DocsList sections={sections} />
* }
* ```
*
* @throws Does not throw - errors are logged and an empty array is returned
*
* @category Documentation
* @public
*/
export function loadDocumentation(): DocSection[] {
try {
const filePath = join(process.cwd(), 'public/docs/api.json')
const fileContents = readFileSync(filePath, 'utf8')
const typeDocData: TypeDocRoot = JSON.parse(fileContents)
return parseTypeDocJSON(typeDocData)
} catch (error) {
console.error('Failed to load TypeDoc JSON:', error)
return []
}
}

614
lib/docs/parser.ts Normal file
View file

@ -0,0 +1,614 @@
/**
* TypeDoc JSON parser that transforms TypeDoc's reflection model into a
* simplified, searchable documentation structure.
*
* @remarks
* This module parses TypeDoc JSON output (generated with `typedoc --json`)
* and transforms it into a flattened, categorized structure optimized for:
* - Fast client-side search
* - Category-based navigation
* - Rich documentation display
*
* **Processing pipeline:**
* 1. Parse TypeDoc reflections recursively
* 2. Extract JSDoc metadata (descriptions, examples, tags)
* 3. Categorize items (Services, Utils, Types, Theme)
* 4. Generate type signatures and function signatures
* 5. Build navigation structure
*
* **Key features:**
* - Preserves JSDoc @example blocks with language detection
* - Filters out private/internal items
* - Handles complex TypeScript types (unions, intersections, generics)
* - Maintains source location references
*
* @module lib/docs/parser
* @category Docs
* @public
*/
import type {
TypeDocRoot,
TypeDocReflection,
TypeDocSignature,
TypeDocParameter,
DocItem,
DocSection,
DocNavigation,
DocCategory,
DocKind,
} from './types'
/**
* Maps TypeDoc's numeric kind identifiers to our simplified DocKind types.
*
* @remarks
* TypeDoc uses numeric identifiers (based on TypeScript's SymbolKind enum)
* to represent different declaration types. This map translates them to
* our simplified string-based kind system for easier consumption.
*
* **Common mappings:**
* - 1, 128: `'class'` (Class and Constructor)
* - 2: `'interface'`
* - 4, 16: `'enum'` (Enum and EnumMember)
* - 64, 512, 2048: `'function'` / `'method'`
* - 256, 1024, 2048: `'property'`
* - 4096, 8192, 16384: `'type'` (TypeAlias, TypeParameter)
*
* @internal
*/
const KIND_MAP: Record<number, DocKind> = {
1: 'class',
2: 'interface',
4: 'enum',
16: 'enum', // EnumMember
32: 'variable',
64: 'function',
128: 'class', // Constructor
256: 'property',
512: 'method',
1024: 'property',
2048: 'method',
4096: 'type',
8192: 'type',
16384: 'type', // TypeAlias
65536: 'method', // CallSignature
131072: 'method', // IndexSignature
262144: 'method', // ConstructorSignature
524288: 'property', // Parameter
1048576: 'type', // TypeParameter
2097152: 'property', // Accessor
4194304: 'property', // GetSignature
8388608: 'property', // SetSignature
16777216: 'type', // ObjectLiteral
33554432: 'type', // TypeLiteral
}
/**
* Parses TypeDoc JSON output into categorized documentation sections.
*
* @param json - TypeDoc JSON root object (generated with `typedoc --json`)
* @returns Array of documentation sections grouped by category (Services, Utils, Types, Theme, Other)
*
* @remarks
* This is the main entry point for the parser. It processes the entire TypeDoc
* reflection tree and produces a flat, categorized structure optimized for:
* - Client-side search and filtering
* - Category-based navigation
* - Alphabetically sorted items within categories
*
* **Processing steps:**
* 1. Recursively parse all top-level reflections
* 2. Filter out items without descriptions or in 'Other' category
* 3. Deduplicate items by ID
* 4. Group by category and sort items alphabetically
* 5. Sort sections by predefined category order
*
* **Category ordering:**
* Services Utils Types Theme Other
*
* @example
* ```ts
* import { parseTypeDocJSON } from '@/lib/docs/parser'
* import typedocJson from '@/public/docs/api.json'
*
* const sections = parseTypeDocJSON(typedocJson)
* // Returns: [
* // { title: 'Services', category: 'Services', items: [...] },
* // { title: 'Utils', category: 'Utils', items: [...] },
* // ...
* // ]
* ```
*
* @category Docs
* @public
*/
export function parseTypeDocJSON(json: TypeDocRoot): DocSection[] {
const sections: DocSection[] = []
const categoryMap = new Map<DocCategory, DocItem[]>()
if (!json.children) return sections
for (const child of json.children) {
const items = parseReflection(child, undefined, true)
for (const item of items) {
if (item.description || item.category !== 'Other') {
const existing = categoryMap.get(item.category) || []
existing.push(item)
categoryMap.set(item.category, existing)
}
}
}
for (const [category, items] of categoryMap.entries()) {
const uniqueItems = Array.from(
new Map(items.map(item => [item.id, item])).values()
)
sections.push({
title: category,
category,
items: uniqueItems.sort((a, b) => a.name.localeCompare(b.name)),
})
}
return sections.sort((a, b) => {
const order = ['Services', 'Utils', 'Types', 'Theme', 'Devices', 'Domains', 'Docs', 'API', 'Other']
return order.indexOf(a.category) - order.indexOf(b.category)
})
}
/**
* Recursively parses a TypeDoc reflection into one or more DocItem objects.
*
* @param reflection - TypeDoc reflection object to parse
* @param parentCategory - Inherited category from parent reflection
* @param topLevel - Whether this is a top-level reflection (controls child parsing)
* @returns Array of parsed DocItem objects
*
* @remarks
* This is a recursive parsing function that handles all TypeScript declaration types.
* It intelligently processes different reflection kinds (functions, classes, types, etc.)
* and extracts relevant metadata.
*
* **Filtering:**
* - Skips private items (isPrivate flag)
* - Skips external items (isExternal flag)
*
* **Parsing strategy:**
* - Functions with signatures Extract parameters, returns, examples
* - Classes/Interfaces/Types/Enums Create item with type signature
* - Variables/Properties Create simple item with type
* - Top-level items Parse children recursively
*
* @internal
*/
function parseReflection(
reflection: TypeDocReflection,
parentCategory?: DocCategory,
topLevel = false
): DocItem[] {
const items: DocItem[] = []
// Skip private/internal items
if (reflection.flags?.isPrivate || reflection.flags?.isExternal) {
return items
}
const kind = reflection.kindString
? (reflection.kindString.toLowerCase() as DocKind)
: KIND_MAP[reflection.kind] || 'variable'
const category = parentCategory || inferCategory(reflection)
const description = extractDescription(reflection.comment)
if (reflection.signatures && reflection.signatures.length > 0) {
for (const signature of reflection.signatures) {
items.push(createDocItemFromSignature(signature, reflection, category))
}
}
else if (
kind === 'class' ||
kind === 'interface' ||
kind === 'type' ||
kind === 'enum'
) {
const item: DocItem = {
id: createId(reflection),
name: reflection.name,
kind,
category,
description,
remarks: extractRemarks(reflection.comment),
see: extractSeeAlso(reflection.comment),
source: extractSource(reflection),
tags: extractTags(reflection.comment),
deprecated: isDeprecated(reflection.comment),
}
if (kind === 'type' || kind === 'interface') {
item.signature = formatTypeSignature(reflection)
if (kind === 'interface' && reflection.children && reflection.children.length > 0) {
item.parameters = reflection.children.map(child => ({
name: child.name,
type: child.type ? formatType(child.type) : 'any',
description: extractDescription(child.comment),
optional: child.flags?.isOptional || false,
defaultValue: child.defaultValue
}))
}
}
items.push(item)
if (topLevel && reflection.children) {
for (const child of reflection.children) {
items.push(...parseReflection(child, category, false))
}
}
}
else if (!parentCategory || topLevel) {
items.push({
id: createId(reflection),
name: reflection.name,
kind,
category,
description,
signature: reflection.type ? formatType(reflection.type) : undefined,
source: extractSource(reflection),
tags: extractTags(reflection.comment),
deprecated: isDeprecated(reflection.comment),
})
}
return items
}
/**
* Creates a complete DocItem from a function/method signature with metadata.
*
* @param signature - TypeDoc signature containing parameters, return type, and JSDoc
* @param parent - Parent reflection (for source location and naming)
* @param category - Documentation category for this item
* @returns Fully populated DocItem for a function/method
*
* @remarks
* This function extracts all relevant information from a function signature including:
* - Parameter names, types, and descriptions
* - Return type and description
* - Example code blocks with language identifiers
* - JSDoc tags and deprecation status
*
* @internal
*/
function createDocItemFromSignature(
signature: TypeDocSignature,
parent: TypeDocReflection,
category: DocCategory
): DocItem {
const description = extractDescription(signature.comment)
const parameters = signature.parameters?.map(parseParameter) || []
const returns = signature.type
? {
type: formatType(signature.type),
description: extractReturnDescription(signature.comment),
}
: undefined
return {
id: createId(parent),
name: parent.name,
kind: 'function',
category,
description,
remarks: extractRemarks(signature.comment),
signature: formatFunctionSignature(parent.name, parameters, returns?.type),
parameters,
returns,
examples: extractExamples(signature.comment),
throws: extractThrows(signature.comment),
see: extractSeeAlso(signature.comment),
source: extractSource(parent),
tags: extractTags(signature.comment),
deprecated: isDeprecated(signature.comment),
}
}
/**
* Parses a TypeDoc parameter into a simplified parameter object.
* @internal
*/
function parseParameter(param: TypeDocParameter) {
return {
name: param.name,
type: param.type ? formatType(param.type) : 'any',
description: extractDescription(param.comment),
optional: param.flags?.isOptional || false,
defaultValue: param.defaultValue,
}
}
/**
* Extract category from JSDoc @category tag
*/
function extractCategory(comment?: TypeDocReflection['comment']): DocCategory | undefined {
const categoryTag = comment?.blockTags?.find((tag) => tag.tag === '@category')
if (!categoryTag) return undefined
const categoryName = categoryTag.content.map((c) => c.text).join('').trim()
const categoryMap: Record<string, DocCategory> = {
'Services': 'Services',
'Utils': 'Utils',
'Types': 'Types',
'Theme': 'Theme',
'Devices': 'Devices',
'Domains': 'Domains',
'Docs': 'Docs',
'API': 'API',
}
return categoryMap[categoryName] || undefined
}
/**
* Infer category from reflection name and structure
*/
function inferCategory(reflection: TypeDocReflection): DocCategory {
const categoryFromTag = extractCategory(reflection.comment)
if (categoryFromTag) return categoryFromTag
const name = reflection.name.toLowerCase()
if (name.includes('service')) return 'Services'
if (name.includes('formatter') || name.includes('util')) return 'Utils'
if (name.includes('color') || name.includes('surface') || name.includes('theme'))
return 'Theme'
if (name.includes('device')) return 'Devices'
if (name.includes('domain')) return 'Domains'
if (reflection.kindString === 'Interface' || reflection.kindString === 'Type alias')
return 'Types'
// Check source file path
const source = reflection.sources?.[0]?.fileName
if (source) {
if (source.includes('/services/')) return 'Services'
if (source.includes('/utils/')) return 'Utils'
if (source.includes('/theme/')) return 'Theme'
if (source.includes('/types/')) return 'Types'
if (source.includes('/devices/')) return 'Devices'
if (source.includes('/domains/')) return 'Domains'
if (source.includes('/docs/')) return 'Docs'
}
return 'Other'
}
/**
* Extract description from TypeDoc comment
*/
function extractDescription(comment?: TypeDocReflection['comment']): string {
if (!comment?.summary) return ''
return comment.summary.map((s) => s.text).join('')
}
/**
* Extract return description from comment
*/
function extractReturnDescription(comment?: TypeDocSignature['comment']): string {
const returnTag = comment?.blockTags?.find((tag) => tag.tag === '@returns')
if (!returnTag) return ''
return returnTag.content.map((c) => c.text).join('')
}
/**
* Extract remarks (extended description) from comment
* @internal
*/
function extractRemarks(comment?: TypeDocReflection['comment']): string | undefined {
const remarksTag = comment?.blockTags?.find((tag) => tag.tag === '@remarks')
if (!remarksTag) return undefined
return remarksTag.content.map((c) => c.text).join('').trim()
}
/**
* Extract exception documentation from comment
* @internal
*/
function extractThrows(comment?: TypeDocReflection['comment']): string[] {
const throwsTags = comment?.blockTags?.filter((tag) => tag.tag === '@throws') || []
return throwsTags.map((tag) => tag.content.map((c) => c.text).join('').trim())
}
/**
* Extract see-also references from comment
* @internal
*/
function extractSeeAlso(comment?: TypeDocReflection['comment']): string[] {
const seeTags = comment?.blockTags?.filter((tag) => tag.tag === '@see') || []
return seeTags.map((tag) => tag.content.map((c) => c.text).join('').trim())
}
/**
* Extracts language identifier from markdown code fences and removes fence markers.
* @internal
*/
function extractCodeAndLanguage(code: string): { code: string; language: string } {
// Extract language from opening fence (e.g., ```ts, ```tsx, ```javascript)
const languageMatch = code.match(/^```(\w+)\n/)
const language = languageMatch?.[1] || 'typescript'
// Remove opening code fence with optional language identifier
let cleaned = code.replace(/^```(?:\w+)?\n/gm, '')
// Remove closing code fence
cleaned = cleaned.replace(/\n?```$/gm, '')
// Trim leading/trailing whitespace
return {
code: cleaned.trim(),
language,
}
}
/**
* Extracts example code blocks with language identifiers from TypeDoc comment tags.
* @internal
*/
function extractExamples(comment?: TypeDocSignature['comment']): Array<{ code: string; language: string }> {
const exampleTags = comment?.blockTags?.filter((tag) => tag.tag === '@example') || []
return exampleTags.map((tag) => {
const rawExample = tag.content.map((c) => c.text).join('')
return extractCodeAndLanguage(rawExample)
})
}
/**
* Extract tags from comment
*/
function extractTags(comment?: TypeDocReflection['comment']): string[] {
if (!comment?.blockTags) return []
return comment.blockTags
.map((tag) => tag.tag.replace('@', ''))
.filter((tag) => !['returns', 'param', 'example', 'remarks', 'throws', 'see', 'category'].includes(tag))
}
/**
* Check if item is deprecated
*/
function isDeprecated(comment?: TypeDocReflection['comment']): boolean {
return comment?.blockTags?.some((tag) => tag.tag === '@deprecated') || false
}
/**
* Extract source location
*/
function extractSource(reflection: TypeDocReflection) {
const source = reflection.sources?.[0]
if (!source) return undefined
return {
file: source.fileName,
line: source.line,
}
}
/**
* Format a type to string
*/
function formatType(type: TypeDocReflection['type']): string {
if (!type) return 'any'
switch (type.type) {
case 'intrinsic':
return type.name || 'any'
case 'reference':
return type.name || 'any'
case 'array':
return type.elementType ? `${formatType(type.elementType)}[]` : 'any[]'
case 'union':
return type.types ? type.types.map(formatType).join(' | ') : 'any'
case 'intersection':
return type.types ? type.types.map(formatType).join(' & ') : 'any'
case 'literal':
return JSON.stringify(type.value)
case 'reflection':
return 'object'
default:
return 'any'
}
}
/**
* Format function signature
*/
function formatFunctionSignature(
name: string,
parameters: Array<{
name: string
type: string
optional: boolean
defaultValue?: string
}>,
returnType?: string
): string {
const params = parameters
.map((p) => {
const opt = p.optional ? '?' : ''
const def = p.defaultValue ? ` = ${p.defaultValue}` : ''
return `${p.name}${opt}: ${p.type}${def}`
})
.join(', ')
return `${name}(${params})${returnType ? `: ${returnType}` : ''}`
}
/**
* Format type signature for interfaces/types
*/
function formatTypeSignature(reflection: TypeDocReflection): string {
if (!reflection.type) return ''
const type = reflection.type
if (type.type === 'reflection' && type.declaration?.children) {
const props = type.declaration.children
.map((child) => {
const opt = child.flags?.isOptional ? '?' : ''
const childType = child.type ? formatType(child.type) : 'any'
return ` ${child.name}${opt}: ${childType}`
})
.join('\n')
return `{\n${props}\n}`
}
return formatType(type)
}
/**
* Create a unique ID for a doc item
*/
function createId(reflection: TypeDocReflection): string {
const source = reflection.sources?.[0]
if (source) {
const file = source.fileName.replace(/^.*\/(lib|components)\//, '')
return `${file}-${reflection.name}-${reflection.id}`
}
return `${reflection.name}-${reflection.id}`
}
/**
* Build navigation structure from doc sections
*/
export function buildNavigation(sections: DocSection[]): DocNavigation {
return {
sections: sections.map((section) => ({
title: section.title,
category: section.category,
items: section.items.map((item) => ({
id: item.id,
name: item.name,
kind: item.kind,
})),
})),
}
}
/**
* Get all doc items flattened
*/
export function getAllItems(sections: DocSection[]): DocItem[] {
return sections.flatMap((section) => section.items)
}
/**
* Find a doc item by ID
*/
export function findItemById(sections: DocSection[], id: string): DocItem | undefined {
for (const section of sections) {
const item = section.items.find((i) => i.id === id)
if (item) return item
}
return undefined
}

344
lib/docs/search.ts Normal file
View file

@ -0,0 +1,344 @@
/**
* Documentation search engine with weighted scoring algorithm.
*
* @remarks
* This module provides fast, client-side search functionality for documentation items
* with a sophisticated scoring system that prioritizes different types of matches.
*
* **Features:**
* - Multi-term search with space-separated queries
* - Weighted scoring (exact matches > prefix matches > contains matches)
* - Category and kind filtering
* - Tag-based filtering
* - Search suggestions based on partial queries
* - Results grouping by category
*
* **Scoring system:**
* - Exact name match: 100 points
* - Name starts with term: 50 points
* - Name contains term: 30 points
* - Description contains term: 20 points
* - Signature contains term: 15 points
* - Tag contains term: 10 points
* - Parameter name contains term: 5 points
*
* @module lib/docs/search
* @category Docs
* @public
*/
import type { DocItem, DocFilters, APIEndpoint } from './types'
/**
* Searches through documentation items with filtering and scoring.
*
* @param items - Array of documentation items to search
* @param query - Search query string (space-separated terms)
* @param filters - Optional filters for category, kind, and tags
* @returns Filtered and scored array of documentation items, sorted by relevance
*
* @remarks
* This function implements a two-phase search:
* 1. **Filter phase**: Apply category, kind, and tag filters
* 2. **Search phase**: Score items based on query term matches
*
* **Empty query handling:**
* If query is empty or only whitespace, returns filtered items without scoring.
*
* **Multi-term queries:**
* Space-separated terms are searched independently and scores are accumulated.
* Example: "format date" searches for both "format" AND "date".
*
* @example
* ```ts
* import { searchDocs } from '@/lib/docs/search'
* import { getAllItems } from '@/lib/docs/parser'
*
* const allItems = getAllItems(sections)
*
* // Simple search
* const results = searchDocs(allItems, 'formatter')
*
* // Search with filters
* const serviceResults = searchDocs(allItems, 'get domain', {
* category: 'Services',
* kind: 'function'
* })
* ```
*
* @category Docs
* @public
*/
export function searchDocs(
items: DocItem[],
query: string,
filters?: DocFilters
): DocItem[] {
let results = items
// Apply filters
if (filters) {
if (filters.category) {
results = results.filter((item) => item.category === filters.category)
}
if (filters.kind) {
results = results.filter((item) => item.kind === filters.kind)
}
if (filters.tags && filters.tags.length > 0) {
results = results.filter((item) =>
filters.tags!.some((tag) => item.tags?.includes(tag))
)
}
}
// Apply search query
if (!query || query.trim() === '') {
return results
}
const searchTerms = query.toLowerCase().split(/\s+/)
return results
.map((item) => ({
item,
score: calculateSearchScore(item, searchTerms),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ item }) => item)
}
/**
* Calculates weighted search score for a documentation item.
* @internal
*/
function calculateSearchScore(item: DocItem, searchTerms: string[]): number {
let score = 0
const name = item.name.toLowerCase()
const description = item.description.toLowerCase()
const signature = item.signature?.toLowerCase() || ''
const tags = (item.tags || []).join(' ').toLowerCase()
for (const term of searchTerms) {
// Exact name match (highest score)
if (name === term) {
score += 100
continue
}
// Name starts with term
if (name.startsWith(term)) {
score += 50
continue
}
// Name contains term
if (name.includes(term)) {
score += 30
continue
}
// Description contains term
if (description.includes(term)) {
score += 20
}
// Signature contains term
if (signature.includes(term)) {
score += 15
}
// Tags contain term
if (tags.includes(term)) {
score += 10
}
// Parameter names contain term
if (item.parameters) {
for (const param of item.parameters) {
if (param.name.toLowerCase().includes(term)) {
score += 5
}
}
}
}
return score
}
/**
* Searches through API endpoints with weighted scoring.
*
* @param endpoints - Array of API endpoints to search
* @param query - Search query string (space-separated terms)
* @returns Filtered and scored array of API endpoints, sorted by relevance
*
* @remarks
* Similar to searchDocs but optimized for API endpoint structure.
* Searches path, method, and description fields.
*
* @category Docs
* @public
*/
export function searchAPIs(
endpoints: APIEndpoint[],
query: string
): APIEndpoint[] {
if (!query || query.trim() === '') {
return endpoints
}
const searchTerms = query.toLowerCase().split(/\s+/)
return endpoints
.map((endpoint) => ({
endpoint,
score: calculateAPIScore(endpoint, searchTerms),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ endpoint }) => endpoint)
}
/**
* Calculate search score for API endpoint
*/
function calculateAPIScore(endpoint: APIEndpoint, searchTerms: string[]): number {
let score = 0
const path = endpoint.path.toLowerCase()
const description = endpoint.description.toLowerCase()
const method = endpoint.method.toLowerCase()
for (const term of searchTerms) {
// Path exact match
if (path === term) {
score += 100
continue
}
// Path contains term
if (path.includes(term)) {
score += 50
}
// Method matches
if (method === term) {
score += 40
}
// Description contains term
if (description.includes(term)) {
score += 20
}
}
return score
}
/**
* Get search suggestions based on partial query
*/
export function getSearchSuggestions(
items: DocItem[],
query: string,
limit = 5
): string[] {
if (!query || query.trim() === '') {
return []
}
const queryLower = query.toLowerCase()
const suggestions = new Set<string>()
for (const item of items) {
// Suggest item names
if (item.name.toLowerCase().includes(queryLower)) {
suggestions.add(item.name)
}
// Suggest tags
if (item.tags) {
for (const tag of item.tags) {
if (tag.toLowerCase().includes(queryLower)) {
suggestions.add(tag)
}
}
}
if (suggestions.size >= limit) break
}
return Array.from(suggestions).slice(0, limit)
}
/**
* Group search results by category
*/
export function groupByCategory(items: DocItem[]): Map<string, DocItem[]> {
const grouped = new Map<string, DocItem[]>()
for (const item of items) {
const category = item.category
const existing = grouped.get(category) || []
existing.push(item)
grouped.set(category, existing)
}
return grouped
}
/**
* Highlight search terms in text
*/
export function highlightSearchTerms(
text: string,
searchTerms: string[]
): string {
let highlighted = text
for (const term of searchTerms) {
const regex = new RegExp(`(${escapeRegExp(term)})`, 'gi')
highlighted = highlighted.replace(regex, '<mark>$1</mark>')
}
return highlighted
}
/**
* Escape special regex characters
*/
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Create search index for faster lookups
*/
export function createSearchIndex(items: DocItem[]): Map<string, DocItem[]> {
const index = new Map<string, DocItem[]>()
for (const item of items) {
// Index by name tokens
const nameTokens = item.name.toLowerCase().split(/[_\-\s]+/)
for (const token of nameTokens) {
const existing = index.get(token) || []
if (!existing.includes(item)) {
existing.push(item)
index.set(token, existing)
}
}
// Index by tags
if (item.tags) {
for (const tag of item.tags) {
const existing = index.get(tag.toLowerCase()) || []
if (!existing.includes(item)) {
existing.push(item)
index.set(tag.toLowerCase(), existing)
}
}
}
}
return index
}

225
lib/docs/types.ts Normal file
View file

@ -0,0 +1,225 @@
/**
* Type definitions for documentation system
*/
export interface TypeDocReflection {
id: number
name: string
kind: number
kindString?: string
flags?: {
isExported?: boolean
isExternal?: boolean
isOptional?: boolean
isRest?: boolean
isPrivate?: boolean
isProtected?: boolean
isPublic?: boolean
isStatic?: boolean
isReadonly?: boolean
isAbstract?: boolean
}
comment?: {
summary?: Array<{ kind: string; text: string }>
blockTags?: Array<{
tag: string
content: Array<{ kind: string; text: string }>
}>
}
children?: TypeDocReflection[]
groups?: Array<{
title: string
children: number[]
}>
sources?: Array<{
fileName: string
line: number
character: number
}>
signatures?: TypeDocSignature[]
type?: TypeDocType
defaultValue?: string
parameters?: TypeDocParameter[]
}
export interface TypeDocSignature {
id: number
name: string
kind: number
kindString?: string
comment?: {
summary?: Array<{ kind: string; text: string }>
blockTags?: Array<{
tag: string
content: Array<{ kind: string; text: string }>
}>
}
parameters?: TypeDocParameter[]
type?: TypeDocType
}
export interface TypeDocParameter {
id: number
name: string
kind: number
kindString?: string
flags?: {
isOptional?: boolean
isRest?: boolean
}
comment?: {
summary?: Array<{ kind: string; text: string }>
}
type?: TypeDocType
defaultValue?: string
}
export interface TypeDocType {
type: string
name?: string
value?: string | number | boolean | null
types?: TypeDocType[]
typeArguments?: TypeDocType[]
elementType?: TypeDocType
declaration?: TypeDocReflection
target?: number
package?: string
qualifiedName?: string
}
export interface TypeDocRoot {
id: number
name: string
kind: number
kindString?: string
children?: TypeDocReflection[]
groups?: Array<{
title: string
children: number[]
}>
packageName?: string
packageVersion?: string
}
/**
* Processed documentation structure
*/
export interface DocItem {
id: string
name: string
kind: DocKind
category: DocCategory
description: string
remarks?: string
signature?: string
parameters?: DocParameter[]
returns?: {
type: string
description: string
}
examples?: Array<{
code: string
language: string
}>
throws?: string[]
see?: string[]
source?: {
file: string
line: number
}
tags?: string[]
deprecated?: boolean
}
export type DocKind =
| 'function'
| 'method'
| 'class'
| 'interface'
| 'type'
| 'variable'
| 'property'
| 'enum'
export type DocCategory = 'Services' | 'Utils' | 'Types' | 'Theme' | 'Devices' | 'Domains' | 'Docs' | 'API' | 'Other'
export interface DocParameter {
name: string
type: string
description: string
optional: boolean
defaultValue?: string
}
export interface DocSection {
title: string
items: DocItem[]
category: DocCategory
}
export interface DocNavigation {
sections: Array<{
title: string
category: DocCategory
items: Array<{
id: string
name: string
kind: DocKind
}>
}>
}
/**
* API endpoint documentation
*/
export interface APIEndpoint {
id: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
path: string
description: string
category: string
auth?: {
required: boolean
type?: string
description?: string
}
parameters?: {
query?: DocParameter[]
body?: DocParameter[]
headers?: DocParameter[]
}
responses: Array<{
status: number
description: string
schema?: Record<string, unknown>
example?: Record<string, unknown>
}>
examples?: Array<{
title: string
request: string | Record<string, unknown>
response: string | Record<string, unknown>
}>
}
/**
* Search result
*/
export interface SearchResult {
item: DocItem | APIEndpoint
matches: Array<{
key: string
value: string
indices: Array<[number, number]>
}>
score: number
}
/**
* Documentation filters
*/
export interface DocFilters {
category?: DocCategory
kind?: DocKind
search?: string
tags?: string[]
}

Some files were not shown because too many files have changed in this diff Show more