feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
|
|
@ -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'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'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're currently on is free and open source. It'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 "side piece," if you will. I've had it for about a year now, and it'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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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 },
|
||||
])
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
141
app/ai/theme.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
242
app/ai/usage/components/LoadingSkeleton.tsx
Normal file
242
app/ai/usage/components/LoadingSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
72
app/ai/usage/components/PageHeader.tsx
Normal file
72
app/ai/usage/components/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
app/ai/usage/components/ProviderFilter.tsx
Normal file
71
app/ai/usage/components/ProviderFilter.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
55
app/ai/usage/components/RecentSessions.tsx
Normal file
55
app/ai/usage/components/RecentSessions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
app/ai/usage/components/SegmentedControl.tsx
Normal file
71
app/ai/usage/components/SegmentedControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
app/ai/usage/components/StatsGrid.tsx
Normal file
48
app/ai/usage/components/StatsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
app/ai/usage/components/TimeRangeFilter.tsx
Normal file
47
app/ai/usage/components/TimeRangeFilter.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
75
app/ai/usage/components/TokenComposition.tsx
Normal file
75
app/ai/usage/components/TokenComposition.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
app/ai/usage/components/TokenType.tsx
Normal file
60
app/ai/usage/components/TokenType.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
app/ai/usage/components/utils.ts
Normal file
119
app/ai/usage/components/utils.ts
Normal 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
200
app/ai/usage/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
52
app/device/[slug]/page.tsx
Normal file
52
app/device/[slug]/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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
15
app/device/layout.tsx
Normal 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
311
app/docs/DocsPageClient.tsx
Normal 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 "{searchQuery}"
|
||||
</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
19
app/docs/layout.tsx
Normal 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
11
app/docs/page.tsx
Normal 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} />
|
||||
}
|
||||
49
app/domains/[domain]/page.tsx
Normal file
49
app/domains/[domain]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
21
app/i18n.ts
21
app/i18n.ts
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
74
app/page.tsx
74
app/page.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue