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

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,
},
]
}
}