Compare commits
1 commit
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b5fa7b85 |
72 changed files with 2764 additions and 5617 deletions
30
.gitea/workflows/lint.yml
Normal file
30
.gitea/workflows/lint.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Run ESLint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Run ESLint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '23'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
41
.gitea/workflows/push.yml
Normal file
41
.gitea/workflows/push.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Credits to https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/publishing-docker-images
|
||||
|
||||
name: Push to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: p0ntus/aidxncc
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: p0ntus/aidxncc:latest
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
34
Dockerfile
34
Dockerfile
|
|
@ -1,10 +1,16 @@
|
|||
FROM oven/bun:latest AS base
|
||||
FROM node:23-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN bun install
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
|
@ -13,7 +19,12 @@ COPY . .
|
|||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN bun run build
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
|
@ -21,16 +32,13 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN groupadd --system --gid 999 nodejs
|
||||
RUN useradd --system --uid 999 nextjs
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/public ./build/public
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./build/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./build/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
|
@ -39,4 +47,4 @@ EXPOSE 3000
|
|||
ENV PORT=3000
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["bun", "run", "server.ts"]
|
||||
CMD ["node", "build/server.js"]
|
||||
42
README.md
42
README.md
|
|
@ -1,35 +1,51 @@
|
|||
# aidxnCC
|
||||
|
||||
[](http://unlicense.org/)
|
||||
[](https://git.pontusmail.org/aidan/aidxnCC/actions/?workflow=push.yml)
|
||||
[](https://git.pontusmail.org/aidan/aidxnCC/actions/?workflow=lint.yml)
|
||||
|
||||
aidxnCC is the third version of my personal website.
|
||||
|
||||
It's built with Next.js and Tailwind CSS. aidxnCC will always be a work in progress, though completely functional.
|
||||
|
||||
## Deploy with Docker
|
||||
## Deploy
|
||||
|
||||
Docker is the easiest way to deploy aidxnCC. There are two example `docker-compose.yml` files for you to use.
|
||||
### Vercel
|
||||
|
||||
1. `docker-compose.yml` - Default, exposed on port 3000
|
||||
2. `docker-compose.nginx.yml` - Helpful for NGINX Proxy Manager usage w/ Docker networks
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fihatenodejs%2FaidxnCC&env=BRAINZ_USER_AGENT,LISTENBRAINZ_TOKEN&envDescription=You%20will%20need%20both%20a%20custom%20user%20agent%20(for%20identifying%20yourself%20to%20MusicBrainz)%2C%20and%20a%20ListenBrainz%20User%20Token.%20See%20the%20README%20for%20more%20information.&envLink=https%3A%2F%2Fgithub.com%2Fihatenodejs%2FaidxnCC&project-name=aidxn-cc&repository-name=aidxnCC)
|
||||
|
||||
Just create a `.env` file with the below variables, run `docker compose -d --build`, and you'll be all set.
|
||||
To deploy with Vercel, simply click the button above. When prompted for environment variables, see the section below.
|
||||
|
||||
## Environment Variables
|
||||
### Cloudflare
|
||||
|
||||
| Variable | Required? | Description |
|
||||
|----------------------|-----------|-------------------------------------------------------------------------------------|
|
||||
| `LISTENBRAINZ_TOKEN` | No | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
|
||||
| `LASTFM_API_KEY` | Yes | Get this from your Last.fm [API account](https://www.last.fm/api/account/create) |
|
||||
I currently host aidxnCC on Cloudflare Pages. They currently don't have a "Deploy to Cloudflare" button for Pages, but you can setup like so:
|
||||
|
||||
## MusicBrainz
|
||||
1. Fork `aidxnCC` to your own account
|
||||
2. Deploy to Pages from your fork
|
||||
|
||||
This project does not use a custom user agent when interacting with the MusicBrainz API. This is because the LastPlayed component is rendered client-side and user agent support is not universal.
|
||||
> [!NOTE]
|
||||
> Make sure to set your environment variables (see below!)
|
||||
>
|
||||
> You may also have to set the `nodejs_compat` compatibility flag in the Pages settings.
|
||||
|
||||
If bugs were to occur with my code, I believe it would be easier for MusicBrainz to block this way.
|
||||
### Self-Host
|
||||
|
||||
**Own a server? Deploy on your own!** F*** SaaS, check out [Coolify](https://coolify.io/), a free and open-source alternative to Vercel.
|
||||
|
||||
## Contributing
|
||||
|
||||
Any and all contributions are welcome! Simply create a pull request and I should have a response to you within a day.
|
||||
|
||||
Please use common sense when contributing :)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------|
|
||||
| `LISTENBRAINZ_TOKEN` | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
|
||||
|
||||
## MusicBrainz
|
||||
|
||||
This project does not use a custom user agent when interacting with the MusicBrainz API. This is because the LastPlayed component is rendered client-side and user agent support is not universal.
|
||||
|
||||
If bugs were to occur with my code, I believe it would be easier for MusicBrainz to block this way.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import Button from '@/components/objects/Button'
|
|||
import FeaturedRepos from '@/components/widgets/FeaturedRepos'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { SiGoogle } from 'react-icons/si'
|
||||
import { TbUserHeart } from 'react-icons/tb'
|
||||
import { User, Smartphone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -33,19 +32,19 @@ export default function About() {
|
|||
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} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-2 text-gray-100 glow">{t('about.title')}</h1>
|
||||
<main className="text-center py-12">
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<User size={60} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
{t('about.title')}
|
||||
</h1>
|
||||
|
||||
<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">
|
||||
<section key={index} className="p-8 border-2 border-gray-700 rounded-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">
|
||||
|
|
@ -57,19 +56,19 @@ export default function About() {
|
|||
)
|
||||
} 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">
|
||||
<section key={index} 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">{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) => {
|
||||
{text.split(/(ihatenodejs|LibreCloud Git|aidan)/).map((part, i) => {
|
||||
if (part === 'ihatenodejs') {
|
||||
return <Link key={i} href="https://github.com/ihatenodejs/">ihatenodejs</Link>
|
||||
return <Link key={i} href="https://github.com/ihatenodejs/">GitHub</Link>
|
||||
}
|
||||
if (part === 'p0ntus git') {
|
||||
return <Link key={i} href="https://git.p0ntus.com/">p0ntus git</Link>
|
||||
if (part === 'LibreCloud Git') {
|
||||
return <Link key={i} href="https://git.pontusmail.org/">LibreCloud Git</Link>
|
||||
}
|
||||
if (part === 'aidan') {
|
||||
return <Link key={i} href="https://git.p0ntus.com/aidan/">aidan</Link>
|
||||
return <Link key={i} href="https://git.pontusmail.org/aidan/">aidan</Link>
|
||||
}
|
||||
return part
|
||||
})}
|
||||
|
|
@ -77,19 +76,18 @@ export default function About() {
|
|||
))}
|
||||
{!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"
|
||||
<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}
|
||||
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"
|
||||
<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}
|
||||
|
|
@ -97,7 +95,6 @@ export default function About() {
|
|||
loading="eager"
|
||||
priority
|
||||
unoptimized
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -105,54 +102,31 @@ export default function About() {
|
|||
)
|
||||
} 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">
|
||||
<section key={index} 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">{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>
|
||||
<h3 className={cn("text-xl font-semibold mb-2 text-gray-200", key === "Laptop" && "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) => {
|
||||
{text.split(/(KernelSU-Next|LineageOS microG)/).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>
|
||||
if (part === 'LineageOS microG') {
|
||||
return <Link key={i} href="https://lineage.microg.org/">LineageOS microG</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>
|
||||
{key === "Phone" && (
|
||||
<Button
|
||||
href="/phone"
|
||||
label="My Phone"
|
||||
icon={Smartphone}
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -160,22 +134,16 @@ export default function About() {
|
|||
)
|
||||
} 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">
|
||||
<section key={index} 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">{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>
|
||||
{text.split(/(my Gitea instance|my phone)/).map((part, i) => {
|
||||
if (part === 'my Gitea instance') {
|
||||
return <Link key={i} href="https://git.pontusmail.org/">my Gitea instance</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 <Link key={i} href="/phone">my phone</Link>
|
||||
}
|
||||
return part
|
||||
})}
|
||||
|
|
@ -185,25 +153,13 @@ export default function About() {
|
|||
)
|
||||
} 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">
|
||||
<section key={index} 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">{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>
|
||||
{text.split(/(LibreCloud)/).map((part, i) => {
|
||||
if (part === 'LibreCloud') {
|
||||
return <Link key={i} href="https://librecloud.cc/">LibreCloud</Link>
|
||||
}
|
||||
return part
|
||||
})}
|
||||
|
|
@ -213,7 +169,7 @@ export default function About() {
|
|||
)
|
||||
} 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">
|
||||
<section key={index} 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">{mainSections[index]}</h2>
|
||||
{section.map((text, index) => (
|
||||
<p key={index} className="text-gray-300 leading-relaxed mt-2">
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { DailyData } from './types'
|
||||
import {
|
||||
buildDailyTrendData,
|
||||
formatCurrency,
|
||||
formatTokens,
|
||||
getHeatmapColor,
|
||||
prepareHeatmapData,
|
||||
} from './utils'
|
||||
|
||||
export default function Activity({ daily }: { daily: DailyData[] }) {
|
||||
const [viewMode, setViewMode] = useState<'heatmap' | 'chart'>('heatmap')
|
||||
const [selectedMetric, setSelectedMetric] = useState<'cost' | 'tokens'>('cost')
|
||||
|
||||
const dailyTrendData = useMemo(() => buildDailyTrendData(daily), [daily])
|
||||
const heatmapWeeks = useMemo(() => prepareHeatmapData(daily), [daily])
|
||||
const maxCost = useMemo(
|
||||
() => (daily.length ? Math.max(...daily.map(d => d.totalCost)) : 0),
|
||||
[daily]
|
||||
)
|
||||
|
||||
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">
|
||||
<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">{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"
|
||||
>
|
||||
<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`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'heatmap' ? (
|
||||
<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">
|
||||
{(() => {
|
||||
const monthLabels: { month: string; position: number }[] = []
|
||||
let lastMonth = -1
|
||||
heatmapWeeks.forEach((week, weekIndex) => {
|
||||
const firstDay = week.find(d => d !== null)
|
||||
if (firstDay) {
|
||||
const date = new Date(firstDay.date + 'T00:00:00Z')
|
||||
const month = date.getUTCMonth()
|
||||
if (month !== lastMonth) {
|
||||
monthLabels.push({
|
||||
month: date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }),
|
||||
position: weekIndex * 20
|
||||
})
|
||||
lastMonth = month
|
||||
}
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div className="flex relative">
|
||||
{monthLabels.map((label, idx) => (
|
||||
<div key={idx} style={{ position: 'absolute', left: label.position }} className="w-10">
|
||||
{label.month}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{heatmapWeeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
{week.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className="relative group">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm"
|
||||
style={{ backgroundColor: getHeatmapColor(maxCost, day?.value || 0) }}
|
||||
/>
|
||||
{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="text-gray-400 text-xs">Tokens: {(day.tokens / 1000000).toFixed(2)}M</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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'}`}
|
||||
>
|
||||
Cost
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedMetric('tokens')}
|
||||
className={`px-3 py-1 rounded ${selectedMetric === 'tokens' ? 'bg-[#c15f3c] text-white' : 'bg-gray-700 text-gray-300'}`}
|
||||
>
|
||||
Tokens
|
||||
</button>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart data={dailyTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis dataKey="date" stroke="#9ca3af" />
|
||||
<YAxis
|
||||
stroke="#9ca3af"
|
||||
tickFormatter={selectedMetric === 'cost' ? formatCurrency : formatTokens}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
|
||||
formatter={(value: number) => selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={selectedMetric === 'cost' ? 'cost' : 'tokens'}
|
||||
stroke="#c15f3c"
|
||||
fill="#c15f3c"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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,73 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'
|
||||
import { DailyData } from './types'
|
||||
import { COLORS, buildModelUsageData, formatCurrency } from './utils'
|
||||
|
||||
export default function ModelUsageCard({ daily, totalCost }: { daily: DailyData[]; totalCost: number }) {
|
||||
const modelUsageData = buildModelUsageData(daily)
|
||||
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>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={modelUsageData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{modelUsageData.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
itemStyle={{ color: '#fff' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center space-y-3">
|
||||
{modelUsageData.map((model, index) => {
|
||||
const percentage = ((model.value / Math.max(totalCost, 1)) * 100).toFixed(1)
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-gray-300 font-medium text-xs">{model.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 text-sm">{percentage}%</span>
|
||||
<span className="text-gray-200 font-semibold">${model.value.toFixed(2)}</span>
|
||||
</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>
|
||||
<span className="text-gray-200 font-bold">{modelUsageData.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-gray-400">Most Used</span>
|
||||
<span className="text-gray-200 font-bold text-xs">
|
||||
{modelUsageData[0]?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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,42 +0,0 @@
|
|||
export interface ModelBreakdown {
|
||||
modelName: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
export interface DailyData {
|
||||
date: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
totalCost: number
|
||||
modelsUsed: string[]
|
||||
modelBreakdowns: ModelBreakdown[]
|
||||
}
|
||||
|
||||
export interface CCData {
|
||||
daily: DailyData[]
|
||||
totals: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
totalCost: number
|
||||
totalTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeatmapDay {
|
||||
date: string
|
||||
value: number
|
||||
tokens: number
|
||||
cost: number
|
||||
day: number
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { TbStack2 } from 'react-icons/tb'
|
||||
import Link from '@/components/objects/Link'
|
||||
import type { AITool } from '../types'
|
||||
|
||||
interface AIStackProps {
|
||||
tools: AITool[]
|
||||
}
|
||||
|
||||
export default function AIStack({ tools }: AIStackProps) {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'primary': return 'text-green-400 border-green-400 bg-green-400/10'
|
||||
case 'active': return 'text-green-300 border-green-300 bg-green-300/10'
|
||||
case 'occasional': return 'text-orange-300 border-orange-300 bg-orange-300/10'
|
||||
default: return 'text-gray-400 border-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'primary': return 'Primary'
|
||||
case 'active': return 'Active Use'
|
||||
case 'occasional': return 'Occasional Use'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price === 0) return 'Free'
|
||||
if (price % 1 === 0) return `$${price}/mo`
|
||||
return `$${price.toFixed(2)}/mo`
|
||||
}
|
||||
|
||||
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} />
|
||||
My AI Stack
|
||||
</h2>
|
||||
<p className="text-muted-foreground">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">
|
||||
{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" />}
|
||||
{tool.svg && (
|
||||
<div className="w-6 h-6 text-gray-300 fill-current">
|
||||
{tool.svg}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-200">{tool.name}</h3>
|
||||
{tool.price !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
{tool.discountedPrice !== undefined ? (
|
||||
<>
|
||||
<span className="text-gray-500 line-through">
|
||||
{formatPrice(tool.price)}
|
||||
</span>
|
||||
<span className="text-gray-200">
|
||||
{formatPrice(tool.discountedPrice)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-200">
|
||||
{formatPrice(tool.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{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)}`}>
|
||||
{getStatusLabel(tool.status)}
|
||||
</span>
|
||||
<span className="flex flex-row items-center gap-4">
|
||||
{tool.link && (
|
||||
<Link href={tool.link} className="text-blue-400 hover:text-blue-300 text-sm" target="_blank" rel="noopener noreferrer">
|
||||
Visit →
|
||||
</Link>
|
||||
)}
|
||||
{tool.usage && (
|
||||
<Link href={tool.usage} className="text-blue-400 hover:text-blue-300 text-sm">
|
||||
Usage →
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { Brain, Star } from 'lucide-react'
|
||||
import type { FavoriteModel } from '../types'
|
||||
|
||||
interface FavoriteModelsProps {
|
||||
models: FavoriteModel[]
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{model.review}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { Star } from 'lucide-react'
|
||||
import { TbTool } from 'react-icons/tb'
|
||||
import type { AIReview } from '../types'
|
||||
|
||||
interface FavoriteToolsProps {
|
||||
reviews: AIReview[]
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Trophy, ChevronRight } from 'lucide-react'
|
||||
import { SiClaude } from 'react-icons/si'
|
||||
import Link from '@/components/objects/Link'
|
||||
|
||||
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>
|
||||
<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} />
|
||||
</Link>
|
||||
<Link href="/ai/claude" className="text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||
My Usage <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-300">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
app/ai/data.tsx
198
app/ai/data.tsx
|
|
@ -1,198 +0,0 @@
|
|||
import {
|
||||
SiClaude,
|
||||
SiGithubcopilot,
|
||||
SiGooglegemini,
|
||||
SiPerplexity,
|
||||
SiOpenai
|
||||
} from 'react-icons/si'
|
||||
import type { AITool, FavoriteModel, AIReview } from './types'
|
||||
|
||||
export const aiTools: AITool[] = [
|
||||
{
|
||||
name: "Claude Max 5x",
|
||||
icon: SiClaude,
|
||||
description: "My favorite model provider for general use and coding",
|
||||
status: "primary",
|
||||
usage: "/ai/claude",
|
||||
link: "https://claude.ai/",
|
||||
price: 100
|
||||
},
|
||||
{
|
||||
name: "ChatGPT Business",
|
||||
icon: SiOpenai,
|
||||
description: "Feature-rich and budget-friendly (for now)",
|
||||
status: "active",
|
||||
link: "https://chatgpt.com/",
|
||||
price: 60
|
||||
},
|
||||
{
|
||||
name: "GLM Coding Lite",
|
||||
svg: (
|
||||
/* Icon by lobe-icons: https://github.com/lobehub/lobe-icons */
|
||||
<svg fill="currentColor" fillRule="evenodd" height="1em" style={{ flex: 'none', lineHeight: 1 }} viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Z.ai</title>
|
||||
<path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path>
|
||||
</svg>
|
||||
),
|
||||
description: "Cheap, Claude-like model with a slow API",
|
||||
status: "active",
|
||||
link: "https://z.ai/",
|
||||
price: 3
|
||||
},
|
||||
{
|
||||
name: "Gemini Pro",
|
||||
icon: SiGooglegemini,
|
||||
description: "Chatting, asking questions, and image generation",
|
||||
status: "occasional",
|
||||
link: "https://gemini.google.com/",
|
||||
price: 20,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "Qwen Chat",
|
||||
svg: (
|
||||
<svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" className="size-8">
|
||||
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
|
||||
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
|
||||
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stopColor="#665CEE"/>
|
||||
<stop offset="1" stopColor="#332E91"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
|
||||
<stop stopColor="#665CEE"/>
|
||||
<stop offset="1" stopColor="#332E91"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
description: "My favorite open source LLM for chatting",
|
||||
status: "occasional",
|
||||
link: "https://chat.qwen.ai/",
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
name: "Perplexity Pro",
|
||||
icon: SiPerplexity,
|
||||
description: "Reliable for more in-depth searching",
|
||||
status: "occasional",
|
||||
link: "https://perplexity.ai/",
|
||||
price: 20,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "OpenCode",
|
||||
svg: (
|
||||
<svg viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg" className="size-7">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white"/>
|
||||
</svg>
|
||||
),
|
||||
description: "My favorite FOSS AI coding assistant",
|
||||
status: "occasional",
|
||||
link: "https://opencode.ai/",
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot Pro",
|
||||
icon: SiGithubcopilot,
|
||||
description: "Random edits when I don't want to start a Claude session",
|
||||
status: "occasional",
|
||||
link: "https://github.com/features/copilot",
|
||||
price: 10,
|
||||
discountedPrice: 0
|
||||
},
|
||||
{
|
||||
name: "v0 Free",
|
||||
svg: <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>v0</title><path d="M14.066 6.028v2.22h5.729q.075-.001.148.005l-5.853 5.752a2 2 0 0 1-.024-.309V8.247h-2.353v5.45c0 2.322 1.935 4.222 4.258 4.222h5.675v-2.22h-5.675q-.03 0-.059-.003l5.729-5.629q.006.082.006.166v5.465H24v-5.465a4.204 4.204 0 0 0-4.205-4.205zM0 8.245l8.28 9.266c.839.94 2.396.346 2.396-.914V8.245H8.19v5.44l-4.86-5.44Z"/></svg>,
|
||||
description: "Generating boilerplate UIs",
|
||||
status: "occasional",
|
||||
link: "https://v0.dev/",
|
||||
price: 0
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
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
|
||||
},
|
||||
{
|
||||
name: "Qwen3-235B-A22B",
|
||||
provider: "Alibaba",
|
||||
review: "The OG thinking model. Amazing, funny, and smart for chats. Surprisingly good at coding too.",
|
||||
rating: 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
|
||||
},
|
||||
{
|
||||
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
|
||||
},
|
||||
{
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "Google",
|
||||
review: "Amazing for Deep Research and reasoning tasks. I hate it for coding.",
|
||||
rating: 4
|
||||
},
|
||||
{
|
||||
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
|
||||
},
|
||||
]
|
||||
|
||||
export const aiReviews: AIReview[] = [
|
||||
{
|
||||
tool: "Claude Code",
|
||||
rating: 5,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,39 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import Header from '@/components/Header'
|
||||
import Footer from '@/components/Footer'
|
||||
import { Brain } from 'lucide-react'
|
||||
import TopPick from './components/TopPick'
|
||||
import AIStack from './components/AIStack'
|
||||
import FavoriteModels from './components/FavoriteModels'
|
||||
import FavoriteTools from './components/FavoriteTools'
|
||||
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="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>
|
||||
</div>
|
||||
|
||||
<TopPick />
|
||||
|
||||
<div className="p-4">
|
||||
<AIStack tools={aiTools} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
|
||||
<FavoriteModels models={favoriteModels} />
|
||||
<FavoriteTools reviews={aiReviews} />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export interface AITool {
|
||||
name: string;
|
||||
icon?: React.ElementType;
|
||||
svg?: React.ReactNode;
|
||||
description: string;
|
||||
status: 'primary' | 'active' | 'occasional' | string;
|
||||
link?: string;
|
||||
usage?: string;
|
||||
price?: number;
|
||||
discountedPrice?: number;
|
||||
}
|
||||
|
||||
export interface FavoriteModel {
|
||||
name: string;
|
||||
provider: string;
|
||||
review: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface AIReview {
|
||||
tool: string;
|
||||
rating: number;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
verdict: string;
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import Header from '@/components/Header'
|
||||
import Footer from '@/components/Footer'
|
||||
import Button from '@/components/objects/Button'
|
||||
import ContactButton from '@/components/objects/ContactButton'
|
||||
import { Phone } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SiGithub, SiForgejo, SiTelegram } from 'react-icons/si'
|
||||
import { Mail, Smartphone } from 'lucide-react'
|
||||
import { faPhone, faEnvelope } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faGithub, faTelegram, faBluesky, faXTwitter } from '@fortawesome/free-brands-svg-icons'
|
||||
|
||||
export default function Contact() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -23,65 +23,56 @@ export default function Contact() {
|
|||
];
|
||||
|
||||
const contactButtonLabels = [
|
||||
"ihatenodejs",
|
||||
"aidan",
|
||||
"p0ntu5",
|
||||
"+1 802-416-9516",
|
||||
"aidan@p0ntus.com",
|
||||
t('contact.buttons.github'),
|
||||
t('contact.buttons.telegram'),
|
||||
t('contact.buttons.bluesky'),
|
||||
t('contact.buttons.x'),
|
||||
t('contact.buttons.phone'),
|
||||
t('contact.buttons.email')
|
||||
];
|
||||
|
||||
const contactButtonHrefs = [
|
||||
"https://github.com/ihatenodejs",
|
||||
"https://git.p0ntus.com/aidan",
|
||||
"https://t.me/p0ntu5",
|
||||
"https://bsky.app/profile/aidxn.cc",
|
||||
"https://x.com/ihatenodejs",
|
||||
"tel:+18024169516",
|
||||
"mailto:aidan@p0ntus.com"
|
||||
];
|
||||
|
||||
const contactButtonIcons = [
|
||||
<SiGithub key="github" />,
|
||||
<SiForgejo key="forgejo" />,
|
||||
<SiTelegram key="telegram" />,
|
||||
<Smartphone key="smartphone" />,
|
||||
<Mail key="mail" />
|
||||
];
|
||||
const contactButtonIcons = [faGithub, faTelegram, faBluesky, faXTwitter, faPhone, faEnvelope];
|
||||
|
||||
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 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 className='mb-6 flex justify-center'>
|
||||
<Phone size={60} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{contactButtonLabels.map((label, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
href={contactButtonHrefs[index]}
|
||||
target="_blank"
|
||||
variant="rounded"
|
||||
icon={contactButtonIcons[index]}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} 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>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
{t('contact.title')}
|
||||
</h1>
|
||||
<div className="p-6 space-y-4">
|
||||
{contactButtonLabels.map((label, index) => (
|
||||
<ContactButton
|
||||
key={index}
|
||||
label={label}
|
||||
href={contactButtonHrefs[index]}
|
||||
icon={contactButtonIcons[index]}
|
||||
className='mr-3'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200 mt-10">{section.title}</h2>
|
||||
{section.texts.map((text, index) => (
|
||||
<p key={index} className="text-gray-300 mb-4">{text}</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import Header from '@/components/Header'
|
||||
import Footer from '@/components/Footer'
|
||||
import { Link } from "lucide-react"
|
||||
import { TbCurrencyDollarOff } from "react-icons/tb";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { faBan } from "@fortawesome/free-solid-svg-icons"
|
||||
import domains from "@/public/data/domains.json"
|
||||
|
||||
export default function Domains() {
|
||||
|
|
@ -10,16 +11,17 @@ export default function Domains() {
|
|||
<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 className="mb-6 flex justify-center">
|
||||
<Link size={60} />
|
||||
</div>
|
||||
<h1
|
||||
className="text-4xl font-bold my-2 text-gray-200"
|
||||
style={{ textShadow: "0 0 10px rgba(255, 255, 255, 0.5)" }}
|
||||
>
|
||||
My Domains
|
||||
</h1>
|
||||
<div className="mb-4 p-4 pt-8 flex flex-col items-center space-y-2">
|
||||
<TbCurrencyDollarOff size={26} className="text-red-500" />
|
||||
<FontAwesomeIcon icon={faBan} className="text-red-500 text-xl" />
|
||||
<span className="text-red-500 font-medium text-center mt-1 mb-0">
|
||||
These domains are not for sale.
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -66,13 +66,8 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
.hover\:glow {
|
||||
transition: text-shadow 0.3s ease;
|
||||
text-shadow: 0 0 0px rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
.hover\:glow:hover {
|
||||
text-shadow: 0 0 15px rgba(255, 255, 255, 0.9);
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
.sub {
|
||||
|
|
|
|||
121
app/layout.tsx
121
app/layout.tsx
|
|
@ -1,71 +1,74 @@
|
|||
import React from 'react'
|
||||
import { Metadata, Viewport } from 'next'
|
||||
"use client"
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import './globals.css'
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||
import { config } from '@fortawesome/fontawesome-svg-core'
|
||||
import { GeistSans } from 'geist/font/sans'
|
||||
import AnimatedTitle from '../components/AnimatedTitle'
|
||||
import I18nProvider from '../components/I18nProvider'
|
||||
import '../i18n'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'aidxn.cc',
|
||||
description: "The Internet home of Aidan. Come on in!",
|
||||
authors: [{ name: 'aidxn.cc' }],
|
||||
robots: 'index, follow',
|
||||
metadataBase: new URL('https://aidxn.cc'),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "https://aidxn.cc",
|
||||
title: "aidxn.cc",
|
||||
description: "The Internet home of Aidan. Come on in!",
|
||||
siteName: "aidxn.cc",
|
||||
images: [
|
||||
{
|
||||
url: "https://aidxn.cc/android-icon-192x192.png",
|
||||
width: 192,
|
||||
height: 192,
|
||||
},
|
||||
],
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
|
||||
{ url: '/android-icon-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-icon-57x57.png', sizes: '57x57' },
|
||||
{ url: '/apple-icon-60x60.png', sizes: '60x60' },
|
||||
{ url: '/apple-icon-72x72.png', sizes: '72x72' },
|
||||
{ url: '/apple-icon-76x76.png', sizes: '76x76' },
|
||||
{ url: '/apple-icon-114x114.png', sizes: '114x114' },
|
||||
{ url: '/apple-icon-120x120.png', sizes: '120x120' },
|
||||
{ url: '/apple-icon-144x144.png', sizes: '144x144' },
|
||||
{ url: '/apple-icon-152x152.png', sizes: '152x152' },
|
||||
{ url: '/apple-icon-180x180.png', sizes: '180x180' },
|
||||
],
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#111827',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
}
|
||||
config.autoAddCss = false
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const title = 'aidxn.cc';
|
||||
let index = 1;
|
||||
let forward = true;
|
||||
const interval = setInterval(() => {
|
||||
document.title = title.substring(0, index);
|
||||
if (forward) {
|
||||
index++;
|
||||
if (index > title.length) {
|
||||
forward = false;
|
||||
index = title.length - 1;
|
||||
}
|
||||
} else {
|
||||
index--;
|
||||
if (index < 1) {
|
||||
forward = true;
|
||||
index = 1;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
|
||||
<AnimatedTitle />
|
||||
<I18nProvider>
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png" />
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png" />
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="The Internet home of Aidan. Come on in!" />
|
||||
<meta name="keywords" content="blog, android, developer" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="language" content="English" />
|
||||
<meta name="author" content="aidxn.cc" />
|
||||
</head>
|
||||
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,14 @@ export default function Manifesto() {
|
|||
<Header />
|
||||
<main 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 className='mb-6 flex justify-center'>
|
||||
<BookOpen size={60} />
|
||||
</div>
|
||||
<div className="px-6 pt-12">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
Internet Manifesto
|
||||
</h1>
|
||||
<div className="px-6 pt-6">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-200">
|
||||
1. Empathy and Understanding
|
||||
</h2>
|
||||
<p className="text-gray-300 mb-4">
|
||||
|
|
@ -28,7 +26,6 @@ export default function Manifesto() {
|
|||
<li>Suspend judgment and seek to understand</li>
|
||||
<li>Recognize the humanity in every digital interaction</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-12 text-gray-200">
|
||||
2. Unconditional Sharing!
|
||||
</h2>
|
||||
|
|
@ -41,14 +38,12 @@ export default function Manifesto() {
|
|||
<li>Support open-source principles</li>
|
||||
<li>Create extensive documentation on all of my projects</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-12 text-gray-200">
|
||||
3. Genuine Human Connection
|
||||
</h2>
|
||||
<p className="text-gray-300 mb-4">
|
||||
I aim to create a genuine human connection with all people I meet, regardless of who or where they are from.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-12 text-gray-200">
|
||||
4. Privacy & Self-Hosted Services
|
||||
</h2>
|
||||
|
|
@ -65,7 +60,6 @@ export default function Manifesto() {
|
|||
<li>Focus my services on being free and open</li>
|
||||
<li>Suggest and support privacy-focused software</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-12 text-gray-200">
|
||||
I Commit
|
||||
</h2>
|
||||
|
|
|
|||
26
app/music/page.tsx
Normal file
26
app/music/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import Header from '@/components/Header'
|
||||
import MusicWidget from '@/components/widgets/Music'
|
||||
import MusicInfo from '@/components/objects/MusicInfo'
|
||||
import Footer from '@/components/Footer'
|
||||
import { Music as MusicNote } from "lucide-react";
|
||||
|
||||
export default function Music() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-12">
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<MusicNote size={60} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
Music and Me
|
||||
</h1>
|
||||
<div className="flex justify-center max-w-2xl gap-16 mx-auto pt-8">
|
||||
<MusicWidget />
|
||||
<MusicInfo />
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
app/page.tsx
145
app/page.tsx
|
|
@ -3,28 +3,13 @@
|
|||
import Header from '@/components/Header'
|
||||
import Footer from '@/components/Footer'
|
||||
import Button from '@/components/objects/Button'
|
||||
import Link from '@/components/objects/Link'
|
||||
import LastPlayed from '@/components/widgets/NowPlaying'
|
||||
import LiveIndicator from '@/components/widgets/LiveIndicator'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
import {CreditCard, Mail, PillBottle, Scale, UserCircle} from 'lucide-react'
|
||||
import { BsArrowClockwise } from "react-icons/bs";
|
||||
import { CreditCard, Mail, PillBottle, Scale } from 'lucide-react'
|
||||
import { FaHandcuffs } from "react-icons/fa6"
|
||||
import {
|
||||
SiGithubsponsors,
|
||||
SiNextdotjs,
|
||||
SiTailwindcss,
|
||||
SiDocker,
|
||||
SiLinux,
|
||||
SiTypescript,
|
||||
SiClaude,
|
||||
SiPostgresql
|
||||
} from 'react-icons/si'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {TbHeartHandshake, TbUserHeart, TbMessage} from "react-icons/tb";
|
||||
import {BiDonateHeart} from "react-icons/bi";
|
||||
import { SiGithubsponsors } from 'react-icons/si'
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -58,120 +43,80 @@ export default function Home() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||
<div className="relative border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 p-4">
|
||||
<div className="absolute top-2 right-2">
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<LastPlayed />
|
||||
</div>
|
||||
<div className="p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300">
|
||||
<LastPlayed />
|
||||
</div>
|
||||
|
||||
{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') ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<TbHeartHandshake />
|
||||
<span className="align-middle">{section}</span>
|
||||
</div>
|
||||
) : section === t('home.sections.whoIAm') ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<UserCircle />
|
||||
<span className="align-middle">{section}</span>
|
||||
</div>
|
||||
) : section === t('home.sections.whatIDo') ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<TbUserHeart />
|
||||
<span className="align-middle">{section}</span>
|
||||
</div>
|
||||
) : (section)}</h2>
|
||||
{section === t('home.sections.whatIDo') && (
|
||||
<div className="flex flex-row items-center justify-center gap-4 my-8">
|
||||
<SiNextdotjs size={38} />
|
||||
<SiTypescript size={38} />
|
||||
<SiTailwindcss size={38} />
|
||||
<SiPostgresql size={38} />
|
||||
<SiDocker size={38} />
|
||||
<SiLinux size={38} />
|
||||
<SiClaude size={38} />
|
||||
</div>
|
||||
)}
|
||||
<section key={secIndex} 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">{section}</h2>
|
||||
{mainStrings[secIndex].map((text: string, index: number) => (
|
||||
<p key={index} className="text-gray-300 leading-relaxed mt-2">
|
||||
{text}
|
||||
{secIndex === 2 && index === 2 && (
|
||||
<>
|
||||
{' '}
|
||||
<Link href="https://nvd.nist.gov/vuln/detail/CVE-2025-29927">
|
||||
CVE-2025-29927
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<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')}
|
||||
</h2>
|
||||
<section id="contact" 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">{t('home.contact.title')}</h2>
|
||||
<p className="text-gray-300 mb-6">{t('home.contact.description')}</p>
|
||||
<Button
|
||||
href={'/contact'}
|
||||
icon={<Mail size={16} />}
|
||||
>
|
||||
{t('home.contact.button')}
|
||||
</Button>
|
||||
label={t('home.contact.button')}
|
||||
icon={Mail}
|
||||
/>
|
||||
</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')}
|
||||
</h2>
|
||||
<section id="donation" 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">{t('home.donation.title')}</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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 md:text-sm gap-3">
|
||||
<Button
|
||||
href="https://unsilenced.org"
|
||||
icon={<FaHandcuffs />}
|
||||
href={'https://unsilenced.org'}
|
||||
label={t('home.donation.charities.unsilenced')}
|
||||
icon={FaHandcuffs}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.charities.unsilenced')}
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
href="https://drugpolicy.org"
|
||||
icon={<PillBottle size={16} />}
|
||||
href={'https://drugpolicy.org'}
|
||||
label={t('home.donation.charities.drugpolicy')}
|
||||
icon={PillBottle}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.charities.drugpolicy')}
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
href="https://www.aclu.org"
|
||||
icon={<Scale size={16} />}
|
||||
href={'https://www.aclu.org'}
|
||||
label={t('home.donation.charities.aclu')}
|
||||
icon={Scale}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.charities.aclu')}
|
||||
</Button>
|
||||
<Button
|
||||
href="https://www.epicrestartfoundation.org"
|
||||
icon={<BsArrowClockwise size={16} />}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.charities.epic-restart')}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4 className="text-lg font-semibold mt-5 mb-2 text-gray-200">{t('home.donation.donate.title')}</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} />}
|
||||
href={'https://donate.stripe.com/6oEeWVcXs9L9ctW4gj'}
|
||||
label={t('home.donation.donate.stripe')}
|
||||
icon={CreditCard}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.donate.stripe')}
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
href="https://github.com/sponsors/ihatenodejs"
|
||||
icon={<SiGithubsponsors size={16} />}
|
||||
href={'https://github.com/sponsors/ihatenodejs'}
|
||||
label={t('home.donation.donate.github')}
|
||||
icon={SiGithubsponsors}
|
||||
target="_blank"
|
||||
>
|
||||
{t('home.donation.donate.github')}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -179,4 +124,4 @@ export default function Home() {
|
|||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
257
app/phone/page.tsx
Normal file
257
app/phone/page.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import Header from "@/components/Header"
|
||||
import Footer from "@/components/Footer"
|
||||
import { Smartphone, 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 About() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-8 md:py-12">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Smartphone size={60} />
|
||||
</div>
|
||||
<h1
|
||||
className="text-4xl font-bold my-2 text-center text-gray-200"
|
||||
style={{ textShadow: "0 0 10px rgba(255, 255, 255, 0.5)" }}
|
||||
>
|
||||
My Phone
|
||||
</h1>
|
||||
</div>
|
||||
<div className="px-6 pt-6">
|
||||
<div className="flex flex-col lg:flex-row items-start gap-6 md:gap-8">
|
||||
<div className="w-full max-w-sm mx-auto justify-start lg:justify-center lg:mx-0">
|
||||
<Image
|
||||
src="/img/cheetah.png"
|
||||
alt="Google Pixel 7 Pro (cheetah)"
|
||||
width={450}
|
||||
height={450}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
<div className="w-full text-center lg:text-left">
|
||||
<h1 className="text-4xl font-semibold mt-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-4 text-slate-500">cheetah</h3>
|
||||
<hr className="mb-6 lg:mb-0"/>
|
||||
<h1 className="text-3xl font-semibold my-3 text-gray-200 flex items-center justify-center lg:justify-start">
|
||||
<Cpu className="mr-2" />
|
||||
Specifications
|
||||
</h1>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<Cpu className="mr-2" size={20} />
|
||||
<b className="mr-1">CPU:</b> Google Tensor G2
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<HardDrive className="mr-2" size={20} />
|
||||
<b className="mr-1">Storage:</b> 128GB
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<MemoryStick className="mr-2" size={20} />
|
||||
<b className="mr-1">RAM:</b> 12GB
|
||||
</p>
|
||||
<hr className="my-6 lg:mt-4 lg:mb-0" />
|
||||
<h1 className="text-3xl font-semibold my-3 text-gray-200 flex items-center justify-center lg:justify-start">
|
||||
<Hash className="mr-2" />
|
||||
Modifications
|
||||
</h1>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<VscTerminalLinux className="mr-2" size={20} />
|
||||
<b className="mr-1">Kernel:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/WildPlusKernel/GKI_KernelSU_SUSFS/"
|
||||
>
|
||||
android13-5.10.WILD
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<MdOutlineAndroid className="mr-2" size={20} />
|
||||
<b className="mr-1">ROM:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://axionaosp.github.io"
|
||||
>
|
||||
AxionAOSP v1.1
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<Hammer className="mr-2" size={20} />
|
||||
<b className="mr-1">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>
|
||||
<hr className="lg:hidden" />
|
||||
<div className="w-full text-center lg:text-left lg:ml-8">
|
||||
<h1 className="text-3xl font-semibold mb-3 text-gray-200 flex items-center justify-center lg:justify-start">
|
||||
<LuPackageOpen className="mr-2" />
|
||||
Apps
|
||||
</h1>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<Music className="mr-2" size={20} />
|
||||
<b className="mr-1">Music:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://qobuz.com"
|
||||
>
|
||||
Qobuz
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<Folder className="mr-2" size={20} />
|
||||
<b className="mr-1">Files:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://mixplorer.com/"
|
||||
>
|
||||
MiXplorer Beta
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<RiTelegram2Fill className="mr-2" size={20} />
|
||||
<b className="mr-1">Telegram:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/arslan4k1390/Cherrygram"
|
||||
>
|
||||
Cherrygram
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<FaYoutube className="mr-2" size={20} />
|
||||
<b className="mr-1">YouTube:</b>
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/NoName-exe/revanced-extended"
|
||||
>
|
||||
ReVanced Extended
|
||||
</Link>
|
||||
</p>
|
||||
<hr className="mt-8 mb-6 lg:my-4" />
|
||||
<h1 className="text-3xl font-semibold mb-3 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-1">
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/chiteroman/PlayIntegrityFix"
|
||||
>
|
||||
Play Integrity Fix
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/5ec1cff/TrickyStore"
|
||||
>
|
||||
Tricky Store
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/LSPosed/LSPosed.github.io/releases"
|
||||
>
|
||||
Shamiko
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/mywalkb/LSPosed_mod/releases"
|
||||
>
|
||||
LSPosed_mod
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/Dr-TSNG/ZygiskNext"
|
||||
>
|
||||
Zygisk Next
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-0.5">
|
||||
<Link
|
||||
className="underline hover:glow transition-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/sidex15/susfs4ksu-module"
|
||||
>
|
||||
SUSFS for KernelSU
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<hr className="mt-2 lg:mt-0 lg:hidden" />
|
||||
<div className="w-full lg:mt-0 text-center lg:text-left lg:ml-8">
|
||||
<h1 className="text-3xl font-semibold mb-3 text-gray-200 flex items-center justify-center lg:justify-start">
|
||||
<SquarePen className="mr-2" />
|
||||
Review
|
||||
</h1>
|
||||
<p className="mb-1 flex items-center justify-center lg:justify-start">
|
||||
<b className="mr-1">Rating:</b>
|
||||
<FaStar size={15} /> <FaStar size={15} /> <FaStar size={15} /> <FaStar size={15} />{" "}
|
||||
<FaStarHalfStroke size={15} />
|
||||
</p>
|
||||
<p className="max-w-sm mt-4 lg:text-sm">
|
||||
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 className="max-w-sm mt-4 lg:text-sm">
|
||||
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 className="max-w-sm mt-4 lg:text-sm">
|
||||
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>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export const robots: MetadataRoute.Robots = {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
},
|
||||
sitemap: 'https://aidxn.cc/sitemap.xml',
|
||||
}
|
||||
|
||||
export default function handler(): MetadataRoute.Robots {
|
||||
return robots
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: 'https://aidxn.cc',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/about',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/ai',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/ai/claude',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/contact',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/domains',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/device/cheetah',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly' /* yes, i really re-flash roms this often */,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/device/bonito',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/device/komodo',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: 'https://aidxn.cc/manifesto',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
}
|
||||
15
app/time-periods/early-summer-2024/page.tsx
Normal file
15
app/time-periods/early-summer-2024/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import Header from '@/components/Header'
|
||||
import WhatWasGoingOn from '@/components/pages/time-periods/early-summer-2024/WhatWasGoingOn'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export default function EarlySummer2024() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-12">
|
||||
<WhatWasGoingOn />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import Header from '@/components/Header'
|
||||
import WhatWasGoingOn from '@/components/pages/time-periods/early-summer-2024/WhatWasGoingOn'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export default function Music() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-12">
|
||||
<WhatWasGoingOn />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
app/time-periods/late-summer-2024/page.tsx
Normal file
15
app/time-periods/late-summer-2024/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import Header from '@/components/Header'
|
||||
import WhatWasGoingOn from '@/components/pages/time-periods/late-summer-2024/WhatWasGoingOn'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export default function LateSummer2024() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-12">
|
||||
<WhatWasGoingOn />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
app/time-periods/late-summer-2024/what-was-going-on/page.tsx
Normal file
15
app/time-periods/late-summer-2024/what-was-going-on/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import Header from '@/components/Header'
|
||||
import WhatWasGoingOn from '@/components/pages/time-periods/late-summer-2024/WhatWasGoingOn'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export default function Music() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="grow container mx-auto px-4 py-12">
|
||||
<WhatWasGoingOn />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AnimatedTitle() {
|
||||
useEffect(() => {
|
||||
const title = 'aidxn.cc';
|
||||
let index = 1;
|
||||
let forward = true;
|
||||
const interval = setInterval(() => {
|
||||
document.title = title.substring(0, index);
|
||||
if (forward) {
|
||||
index++;
|
||||
if (index > title.length) {
|
||||
forward = false;
|
||||
index = title.length - 1;
|
||||
}
|
||||
} else {
|
||||
index--;
|
||||
if (index < 1) {
|
||||
forward = true;
|
||||
index = 1;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ export default function Footer() {
|
|||
return (
|
||||
<footer className="bg-gray-800 text-gray-400 py-4">
|
||||
<div className="flex flex-col sm:flex-row container mx-auto px-4 text-center items-center justify-center">
|
||||
<Link href="https://git.p0ntus.com/aidan/aidxnCC" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<Link href="https://git.pontusmail.org/aidan/aidxnCC" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<div className="flex items-center justify-center">
|
||||
<TbCopyrightOff className="text-md mr-2" />
|
||||
Open Source and Copyright-Free
|
||||
|
|
|
|||
|
|
@ -2,22 +2,7 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
House,
|
||||
Link as LinkIcon,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
X,
|
||||
Menu,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
Smartphone
|
||||
} from 'lucide-react'
|
||||
import { TbUserHeart } from 'react-icons/tb'
|
||||
import { SiClaude, SiGoogle } from 'react-icons/si'
|
||||
import { House, Link as LinkIcon, User, Phone, BookOpen, Music, Rss, X, Menu, Globe, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface NavItemProps {
|
||||
|
|
@ -35,193 +20,12 @@ const NavItem = ({ href, icon, children }: NavItemProps) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
interface DropdownNavItemProps {
|
||||
id: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
dropdownContent: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile && isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onOpenChange]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onOpenChange?.(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange?.(isOpen ? null : id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-item relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={isMobile ? handleClick : undefined}
|
||||
className="flex items-center justify-between text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 w-full"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
</Link>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
{!isMobile && (
|
||||
<div className="absolute left-0 top-full w-full h-1 z-50" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'relative w-full mt-2 ml-5 pr-4'
|
||||
: 'absolute left-0 mt-1 z-50 flex'
|
||||
}`}
|
||||
>
|
||||
{dropdownContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NestedDropdownItemProps {
|
||||
children: React.ReactNode;
|
||||
nestedContent: React.ReactNode;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const NestedDropdownItem = ({ children, nestedContent, isMobile = false }: NestedDropdownItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && itemRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => setIsOpen(false), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="relative mt-2 ml-5 pr-4 space-y-1">
|
||||
{nestedContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-between w-full text-left px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Smartphone className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
<div className="absolute left-full top-0 w-2 h-full z-50" />
|
||||
<div className="absolute left-full top-0 ml-2 w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
|
||||
{nestedContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const languages = [
|
||||
{ code: 'en-US', name: 'English' },
|
||||
];
|
||||
|
|
@ -230,14 +34,14 @@ const LanguageSelector = () => {
|
|||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const changeLanguage = async (lng: string) => {
|
||||
await i18n.changeLanguage(lng);
|
||||
const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -248,33 +52,9 @@ const LanguageSelector = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
|
|
@ -285,58 +65,53 @@ const LanguageSelector = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
<Globe className="text-md mr-2" strokeWidth={2.5} size={20} />
|
||||
{languages.find(lang => lang.code === i18n.language)?.name || 'English'}
|
||||
{!isMobile && (
|
||||
<ChevronDown className={`w-4 h-4 ml-1 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={20} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`flex items-center ${isMobile ? 'justify-between' : ''} text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 ${isMobile ? 'w-full' : ''}`}
|
||||
className={`flex items-center text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300 ${isMobile ? 'w-full' : ''}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Globe className="text-md mr-2" strokeWidth={2.5} size={20} />
|
||||
<span>{languages.find(lang => lang.code === i18n.language)?.name || 'English'}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
{buttonContent}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Invisible bridge to handle gap */}
|
||||
{!isMobile && (
|
||||
<div className="absolute right-0 top-full w-56 h-2 z-50" />
|
||||
)}
|
||||
<div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'relative w-full mt-2 ml-4 pr-4 space-y-1'
|
||||
: 'absolute right-0 mt-2 w-56 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50'
|
||||
}`}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="language-menu"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`block w-full text-left ${isMobile ? 'px-4 py-2.5' : 'px-5 py-3'} ${isMobile ? 'text-sm' : 'text-base'} rounded-md ${
|
||||
i18n.language === lang.code
|
||||
? 'text-white bg-gray-700/50'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700/50'
|
||||
} transition-all duration-300`}
|
||||
role="menuitem"
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div
|
||||
className={`${
|
||||
isMobile
|
||||
? 'relative mt-1 w-full bg-gray-800 rounded-md shadow-lg'
|
||||
: 'absolute right-0 mt-2 w-48 bg-gray-800 rounded-md shadow-lg z-50'
|
||||
}`}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="language-menu"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`block w-full text-left px-4 py-2 text-sm ${
|
||||
i18n.language === lang.code
|
||||
? 'text-white bg-gray-700'
|
||||
: 'text-gray-300 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
role="menuitem"
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -344,130 +119,35 @@ const LanguageSelector = () => {
|
|||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (isOpen) {
|
||||
setActiveDropdown(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (id: string | null) => {
|
||||
setActiveDropdown(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
const aboutDropdownContent = (
|
||||
<>
|
||||
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
|
||||
<Link href="/about" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300 cursor-pointer`}>
|
||||
<TbUserHeart className="mr-3" size={18} />
|
||||
Get to Know Me
|
||||
</Link>
|
||||
<NestedDropdownItem
|
||||
isMobile={isMobile}
|
||||
nestedContent={
|
||||
<>
|
||||
<Link href="/device/bonito" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 3a XL (bonito)
|
||||
</Link>
|
||||
<Link href="/device/cheetah" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 7 Pro (cheetah)
|
||||
</Link>
|
||||
<Link href="/device/komodo" className={`flex items-center ${isMobile ? 'px-4 py-3' : 'px-5 py-3'} text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiGoogle className="mr-3" size={18} />
|
||||
Pixel 9 Pro (komodo)
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Devices
|
||||
</NestedDropdownItem>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const aiDropdownContent = (
|
||||
<div className={`${isMobile ? 'w-full' : 'w-64 bg-gray-800 rounded-lg shadow-xl border border-gray-700'}`}>
|
||||
<Link href="/ai/claude" className={`flex items-center px-4 py-3 text-sm text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300`}>
|
||||
<SiClaude className="mr-3" size={18} />
|
||||
Claude Usage
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
const toggleMenu = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 z-30 pointer-events-none transition-all duration-300 ${
|
||||
activeDropdown && !isMobile
|
||||
? 'backdrop-blur-sm opacity-100'
|
||||
: 'backdrop-blur-none opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<header className="bg-gray-800 relative">
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
)}
|
||||
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
|
||||
<Link href="/" className="text-gray-300 hover:text-white text-2xl font-bold transition-all duration-300 hover:glow">
|
||||
aidxn.cc
|
||||
</Link>
|
||||
<button onClick={toggleMenu} className="lg:hidden text-gray-300 focus:outline-hidden">
|
||||
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
|
||||
</button>
|
||||
<ul className={`flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4 absolute lg:static bg-gray-800 lg:bg-transparent w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50 ${isOpen ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<NavItem href="/" icon={House}>Home</NavItem>
|
||||
<DropdownNavItem
|
||||
id="about"
|
||||
href="/about"
|
||||
icon={User}
|
||||
dropdownContent={aboutDropdownContent}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === 'about'}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
About Me
|
||||
</DropdownNavItem>
|
||||
<DropdownNavItem
|
||||
id="ai"
|
||||
href="/ai"
|
||||
icon={Brain}
|
||||
dropdownContent={aiDropdownContent}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === 'ai'}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
AI
|
||||
</DropdownNavItem>
|
||||
<NavItem href="/contact" icon={Phone}>Contact</NavItem>
|
||||
<NavItem href="/domains" icon={LinkIcon}>Domains</NavItem>
|
||||
<NavItem href="/manifesto" icon={BookOpen}>Manifesto</NavItem>
|
||||
<div className="lg:hidden mt-2 pt-3 -mb-1.5 border-t border-gray-600/30">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</ul>
|
||||
<div className="hidden lg:block">
|
||||
<header className="bg-gray-800 shadow-lg">
|
||||
<nav className="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<Link href="/" className="text-gray-300 hover:text-white text-2xl font-bold transition-all duration-300 hover:glow">
|
||||
aidxn.cc
|
||||
</Link>
|
||||
<button onClick={toggleMenu} className="lg:hidden text-gray-300 focus:outline-hidden">
|
||||
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
|
||||
</button>
|
||||
<ul className={`flex flex-col lg:flex-row space-y-2 lg:space-y-0 lg:space-x-4 absolute lg:static bg-gray-800 lg:bg-transparent w-full lg:w-auto left-0 lg:left-auto top-16 lg:top-auto p-4 lg:p-0 transition-all duration-300 ease-in-out ${isOpen ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<NavItem href="/" icon={House}>Home</NavItem>
|
||||
<NavItem href="/about" icon={User}>About</NavItem>
|
||||
<NavItem href="/contact" icon={Phone}>Contact</NavItem>
|
||||
<NavItem href="/domains" icon={LinkIcon}>Domains</NavItem>
|
||||
<NavItem href="/manifesto" icon={BookOpen}>Manifesto</NavItem>
|
||||
<NavItem href="/music" icon={Music}>Music</NavItem>
|
||||
<NavItem href="https://disfunction.blog" icon={Rss}>Blog</NavItem>
|
||||
<div className="lg:hidden">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
</ul>
|
||||
<div className="hidden lg:block">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import "../i18n";
|
||||
|
||||
export default function I18nProvider({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
24
components/objects/BackButton.tsx
Normal file
24
components/objects/BackButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
interface BackButtonProps {
|
||||
href: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const BackButton: React.FC<BackButtonProps> = ({ href, label = 'Back' }) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center px-4 py-2 mt-4 text-white bg-gray-800 rounded-sm shadow-md transition-all duration-300 ease-in-out hover:bg-gray-700 hover:shadow-lg hover:-translate-y-0.5 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
aria-label={`Go back to ${label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="mr-2" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
|
|
@ -1,40 +1,30 @@
|
|||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ButtonProps {
|
||||
href: string
|
||||
label: string
|
||||
icon?: React.ElementType
|
||||
target?: string
|
||||
variant?: "primary" | "rounded"
|
||||
className?: string
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ href, target, variant, className, icon, children }) => {
|
||||
if (!variant || variant === "primary") {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`inline-flex items-center bg-gray-800 text-white font-bold py-2 px-4 rounded-sm shadow-md transition-all duration-300 ease-in-out hover:bg-gray-700 hover:shadow-lg hover:-translate-y-0.5 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 gap-2 ${className}`}
|
||||
target={target}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
} else if (variant === "rounded") {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className={`bg-gray-700 text-white px-4 py-2 rounded-full hover:bg-gray-600 transition-colors inline-flex items-center justify-center gap-2 whitespace-nowrap ${className}`}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
const Button: React.FC<ButtonProps> = ({ href, label, icon, target, className }) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className ? (
|
||||
cn("inline-flex items-center bg-gray-800 text-white font-bold py-2 px-4 rounded-sm shadow-md transition-all duration-300 ease-in-out hover:bg-gray-700 hover:shadow-lg hover:-translate-y-0.5 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-gray-500", className)
|
||||
) : (
|
||||
"inline-flex items-center bg-gray-800 text-white font-bold py-2 px-4 rounded-sm shadow-md transition-all duration-300 ease-in-out hover:bg-gray-700 hover:shadow-lg hover:-translate-y-0.5 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
)}
|
||||
target={target}
|
||||
>
|
||||
{icon && React.createElement(icon, { size: 20, className: "mr-2" })}
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
26
components/objects/ContactButton.tsx
Normal file
26
components/objects/ContactButton.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ContactButtonProps {
|
||||
href: string;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ContactButton({ href, icon, label, className }: ContactButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`bg-gray-700 text-white px-4 py-2 rounded-full hover:bg-gray-600 transition-colors inline-flex items-center ${className}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="text-xl mr-2" />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactButton;
|
||||
31
components/objects/MusicInfo.tsx
Normal file
31
components/objects/MusicInfo.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import Button from './Button';
|
||||
|
||||
interface TimePeriod {
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const timePeriods: TimePeriod[] = [
|
||||
{ title: 'Late Summer 2024', slug: 'late-summer-2024' },
|
||||
{ title: 'Early Summer 2024', slug: 'early-summer-2024' },
|
||||
];
|
||||
|
||||
const MusicInfo: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
{timePeriods.map((period) => (
|
||||
<section key={period.slug} className="mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">{period.title}</h2>
|
||||
<Button
|
||||
href={`/time-periods/${period.slug}/what-was-going-on`}
|
||||
label="WHAT WAS GOING ON"
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MusicInfo;
|
||||
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
interface ScrollTxtProps {
|
||||
text: string
|
||||
className?: string
|
||||
type?: 'artist' | 'track' | 'release'
|
||||
}
|
||||
|
||||
const ScrollTxt: React.FC<ScrollTxtProps> = ({ text, className = "", type }) => {
|
||||
const getTypeClass = (type?: string) => {
|
||||
switch(type) {
|
||||
case 'artist':
|
||||
return 'text-white text-xs opacity-90 font-medium text-[8px]'
|
||||
case 'track':
|
||||
return 'text-white text-xs font-bold'
|
||||
case 'release':
|
||||
return 'text-white text-xs opacity-90 font-medium text-[8px]'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const textClass = getTypeClass(type)
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden ${className}`}>
|
||||
<div className="whitespace-nowrap inline-block">
|
||||
<span className={textClass}>{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTxt
|
||||
|
||||
|
|
@ -1,16 +1,14 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
SiNextdotjs,
|
||||
SiLucide,
|
||||
SiVercel,
|
||||
SiCloudflarepages,
|
||||
SiSimpleicons,
|
||||
SiFontawesome,
|
||||
SiShadcnui,
|
||||
SiTailwindcss
|
||||
} from "react-icons/si"
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export const footerMessages = [
|
||||
[
|
||||
|
|
@ -33,6 +31,11 @@ export const footerMessages = [
|
|||
"https://vercel.com/font",
|
||||
<SiVercel key="vercel" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Hosted by Cloudflare",
|
||||
"https://workers.cloudflare.com/",
|
||||
<SiCloudflarepages key="cloudflare" className="text-md mr-2" />
|
||||
],
|
||||
[
|
||||
"Icons by Font Awesome",
|
||||
"https://fontawesome.com/",
|
||||
|
|
@ -51,30 +54,11 @@ export const footerMessages = [
|
|||
]
|
||||
|
||||
export default function RandomFooterMsg() {
|
||||
const [randomIndex, setRandomIndex] = useState(0)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
setRandomIndex(Math.floor(Math.random() * footerMessages.length))
|
||||
}, [])
|
||||
|
||||
if (!isMounted) {
|
||||
const [message, url, icon] = footerMessages[0]
|
||||
return (
|
||||
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<div className="flex items-center justify-center">
|
||||
{icon}
|
||||
{message}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * footerMessages.length)
|
||||
const [message, url, icon] = footerMessages[randomIndex]
|
||||
|
||||
return (
|
||||
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0">
|
||||
<Link href={String(url)} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors mb-2 sm:mb-0" suppressHydrationWarning>
|
||||
<div className="flex items-center justify-center">
|
||||
{icon}
|
||||
{message}
|
||||
|
|
|
|||
47
components/objects/ScrollTxt.tsx
Normal file
47
components/objects/ScrollTxt.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
interface ScrollTxtProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ScrollTxt: React.FC<ScrollTxtProps> = ({ text, className = "" }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && textRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const textWidth = textRef.current.offsetWidth
|
||||
setShouldScroll(textWidth > containerWidth)
|
||||
}
|
||||
}, []) // Updated dependency array
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`overflow-hidden ${className}`}>
|
||||
<div
|
||||
ref={textRef}
|
||||
className={`whitespace-nowrap inline-block ${shouldScroll ? "animate-marquee hover:pause" : ""}`}
|
||||
>
|
||||
{shouldScroll ? (
|
||||
<>
|
||||
<span>{text}</span>
|
||||
<span className="mx-4">•</span>
|
||||
<span>{text}</span>
|
||||
<span className="mx-4">•</span>
|
||||
<span>{text}</span>
|
||||
</>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTxt
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react'
|
||||
import BackButton from '@/components/objects/BackButton'
|
||||
|
||||
const WhatWasGoingOnLateSummer2024: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
What was going on during the start of summer 2024?
|
||||
</h1>
|
||||
<div className="px-6 pt-6">
|
||||
<p className="text-gray-300 mb-4">
|
||||
During Early Summer 2024, I was walking a ton in towns all across Massachusetts. During this time, I would listen to a <i>lot</i> of music. I regret not finding out about LastFM for so long... During this time, I was always happy, especially when I had music or a YouTube video playing. I would also call my friends often during this time.
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-12 text-gray-200">Context</h2>
|
||||
<p className="text-gray-300 mb-4">
|
||||
This summer was the one where I came back from my abusive treatment center. I was finally free from the place that had been holding me back for so long. So as you can imagine, I felt free as a bird.
|
||||
</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
With this chance to explore, being in so many different towns, I really had a good time and made good memories, which I will not be writing about.
|
||||
</p>
|
||||
<BackButton href="/music" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatWasGoingOnLateSummer2024;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import BackButton from '@/components/objects/BackButton'
|
||||
|
||||
const WhatWasGoingOnLateSummer2024: React.FC = () => {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h1 className="text-4xl font-bold my-2 text-center text-gray-200" style={{ textShadow: '0 0 10px rgba(255, 255, 255, 0.5)' }}>
|
||||
What was going on during the end of summer 2024?
|
||||
</h1>
|
||||
<div className="px-6 pt-6">
|
||||
<p className="text-gray-300 mb-4">
|
||||
During late summer 2024, my depression and the "after effects" of treatment really kicked in. I had quit going to my therapist as I didn't feel like they were doing much of anything for me. I am very happy to say that since I quit my therapist, I have been doing much better.
|
||||
</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
At this time, the baseball season was over, so I was walking around much less. I was still listening to a lot of music and I started getting into less depressed songs. I was also starting to get into more "normal" music, which was an interesting phase (which I believe I'm still in).
|
||||
</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
A highlight of late summer 2024 was a vacation I took. This vacation has entire albums which remind me of it and I will always cherish those memories deeply.
|
||||
</p>
|
||||
<BackButton href="/music" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatWasGoingOnLateSummer2024;
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { SiGithub, SiForgejo } from "react-icons/si"
|
||||
import { TbStar, TbGitBranch } from "react-icons/tb"
|
||||
import featuredProjects from "@/public/data/featured.json"
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faGitAlt, faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||
import { faStar, faCodeBranch } from '@fortawesome/free-solid-svg-icons'
|
||||
import featuredProjects from '@/public/data/featured.json'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function GitHubFeatured({ className }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -10,16 +11,16 @@ export default function GitHubFeatured({ className }: { className?: string }) {
|
|||
{featuredProjects.map((project) => (
|
||||
<div key={project.id} className="bg-gray-800 p-6 rounded-lg shadow-md min-h-[200px] flex flex-col">
|
||||
<div className="flex-1">
|
||||
<h3 className="flex items-center justify-center text-xl font-bold text-gray-100 mb-3">
|
||||
{project.github ? <SiGithub className="mr-2" /> : <SiForgejo className="mr-2" />} {project.name}
|
||||
<h3 className="text-xl font-bold text-gray-100 mb-3">
|
||||
<FontAwesomeIcon icon={project.github ? faGithub : faGitAlt} className="mr-2" /> {project.name}
|
||||
</h3>
|
||||
<p className="text-gray-300 grow">{project.description}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-700 flex justify-between items-center mt-auto">
|
||||
<Link href={project.url} className="text-blue-400 hover:underline">View Repo</Link>
|
||||
<div className="flex items-center text-gray-400">
|
||||
<TbStar className="mr-1 size-5" /> {project.stars}
|
||||
<TbGitBranch className="ml-4 mr-1 size-5" /> {project.forks}
|
||||
<FontAwesomeIcon icon={faStar} className="mr-1" /> {project.stars}
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="ml-4 mr-1" /> {project.forks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { connectSocket } from "@/lib/socket"
|
||||
|
||||
const LiveIndicator = () => {
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const socket = connectSocket()
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setConnected(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.off('connect')
|
||||
socket.off('disconnect')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
|
||||
<div className={`w-1 h-1 rounded-full ${connected ? "bg-red-400 animate-pulse" : "bg-gray-400"}`}></div>
|
||||
<div className="text-white text-xs">
|
||||
{connected ? "LIVE" : "Connecting..."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LiveIndicator
|
||||
127
components/widgets/Music.tsx
Normal file
127
components/widgets/Music.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { Play, SkipBack, SkipForward } from "lucide-react"
|
||||
import LoadingSpinner from "../objects/LoadingSpinner"
|
||||
import { SeekBar } from "@/components/objects/SeekBar"
|
||||
|
||||
interface Song {
|
||||
albumArt: string
|
||||
name: string
|
||||
artist: string
|
||||
duration: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
interface Period {
|
||||
timePeriod: string
|
||||
songs: Song[]
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [timePeriod, setTimePeriod] = useState("Early Summer 2024")
|
||||
const [songs, setSongs] = useState<Song[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [currentPosition, setCurrentPosition] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
fetch("/data/music.json")
|
||||
.then((response) => response.json())
|
||||
.then((data: Period[]) => {
|
||||
const selectedPeriod = data.find((period) => period.timePeriod === timePeriod)
|
||||
const songsList = selectedPeriod ? selectedPeriod.songs : []
|
||||
setSongs(songsList)
|
||||
const newIndex = Math.floor(Math.random() * songsList.length)
|
||||
setCurrentIndex(newIndex)
|
||||
// Set initial random position for the selected song
|
||||
if (songsList.length > 0) {
|
||||
const durationInSeconds = parseDuration(songsList[newIndex]?.duration || "0:00")
|
||||
setCurrentPosition(Math.floor(Math.random() * durationInSeconds))
|
||||
}
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching music data:", error)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [timePeriod])
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prevIndex) => {
|
||||
const nextIndex = (prevIndex + 1) % songs.length
|
||||
const durationInSeconds = parseDuration(songs[nextIndex].duration)
|
||||
setCurrentPosition(Math.floor(Math.random() * durationInSeconds))
|
||||
return nextIndex
|
||||
})
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
setCurrentIndex((prevIndex) => {
|
||||
const nextIndex = (prevIndex - 1 + songs.length) % songs.length
|
||||
const durationInSeconds = parseDuration(songs[nextIndex].duration)
|
||||
setCurrentPosition(Math.floor(Math.random() * durationInSeconds))
|
||||
return nextIndex
|
||||
})
|
||||
}
|
||||
|
||||
const parseDuration = (duration: string): number => {
|
||||
const [minutes, seconds] = duration.split(":").map(Number)
|
||||
return minutes * 60 + seconds
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section id="music-carousel" className="mb-12">
|
||||
{isLoading && <LoadingSpinner />}
|
||||
|
||||
{!isLoading && songs.length > 0 && (
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={songs[currentIndex].albumArt || "/placeholder.svg"}
|
||||
alt={songs[currentIndex].name}
|
||||
width={300}
|
||||
height={300}
|
||||
className="mb-4 rounded-lg"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold text-gray-100">{songs[currentIndex].name}</h3>
|
||||
<p>{songs[currentIndex].artist}</p>
|
||||
<SeekBar
|
||||
key={`${currentIndex}-${currentPosition}`}
|
||||
startPos={currentPosition}
|
||||
duration={songs[currentIndex].duration}
|
||||
/>
|
||||
<div className="flex justify-center pb-2">
|
||||
<button onClick={handlePrevious} className="mr-4 cursor-pointer">
|
||||
<SkipBack className="w-8 h-8" />
|
||||
</button>
|
||||
<button className="mr-4 cursor-pointer" onClick={() => window.open(songs[currentIndex]?.link, "_blank")}>
|
||||
<Play className="w-8 h-8" />
|
||||
</button>
|
||||
<button onClick={handleNext} className="cursor-pointer">
|
||||
<SkipForward className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center mt-4">
|
||||
<label htmlFor="timePeriod" className="font-bold uppercase text-sm pb-1">
|
||||
Time Period
|
||||
</label>
|
||||
<select
|
||||
id="timePeriod"
|
||||
value={timePeriod}
|
||||
onChange={(e) => setTimePeriod(e.target.value)}
|
||||
className="px-3 py-2 bg-gray-700 rounded-sm mb-2"
|
||||
>
|
||||
<option value="Early Summer 2024">Early Summer 2024</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,290 +1,253 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Loader2, AlertCircle } from "lucide-react"
|
||||
import { PiMusicNotesFill } from "react-icons/pi";
|
||||
import { FaBluetoothB } from "react-icons/fa6";
|
||||
import { IoBatteryFullSharp } from "react-icons/io5"
|
||||
import { IoIosPlay } from "react-icons/io"
|
||||
import { TbDiscOff } from "react-icons/tb"
|
||||
import { useEffect, useState, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { Music, ExternalLink, Disc, User, Loader2, AlertCircle } from "lucide-react"
|
||||
import { TbDiscOff, TbDisc } from "react-icons/tb"
|
||||
import Marquee from "react-fast-marquee"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import Link from "@/components/objects/Link"
|
||||
import ScrollTxt from "@/components/objects/MusicText"
|
||||
import { connectSocket, disconnectSocket } from "@/lib/socket"
|
||||
|
||||
interface LastFmResponse {
|
||||
album?: {
|
||||
image?: Array<{ size: string; '#text': string }>
|
||||
}
|
||||
track?: {
|
||||
album?: {
|
||||
image?: Array<{ size: string; '#text': string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NowPlayingData {
|
||||
track_name?: string
|
||||
artist_name?: string
|
||||
interface Track {
|
||||
track_name: string
|
||||
artist_name: string
|
||||
release_name?: string
|
||||
mbid?: string
|
||||
coverArt?: string | null
|
||||
lastFmData?: LastFmResponse
|
||||
status: 'loading' | 'partial' | 'complete' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC = () => {
|
||||
const [nowPlaying, setNowPlaying] = useState<NowPlayingData>({ status: 'loading' })
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
const [volume, setVolume] = useState(25)
|
||||
const [screenOn, setScreenOn] = useState(true)
|
||||
const [progressSteps, setProgressSteps] = useState({ current: 0, total: 3 })
|
||||
const ScrollableText: React.FC<{ text: string; className?: string }> = ({ text, className = "" }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldScroll, setShouldScroll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const socket = connectSocket()
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server')
|
||||
socket.emit('requestNowPlaying')
|
||||
socket.emit('startAutoRefresh')
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[i] Disconnected from server')
|
||||
})
|
||||
|
||||
socket.on('nowPlaying', (data: NowPlayingData) => {
|
||||
console.log('Received now playing data:', data)
|
||||
setNowPlaying(prevState => ({
|
||||
...prevState,
|
||||
...data
|
||||
}))
|
||||
|
||||
if (data.status === 'loading') {
|
||||
setProgressSteps({ current: 1, total: 3 })
|
||||
} else if (data.status === 'partial') {
|
||||
setProgressSteps({ current: 2, total: 3 })
|
||||
} else if (data.status === 'complete') {
|
||||
setProgressSteps({ current: 3, total: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[!] Connection error:', error)
|
||||
setNowPlaying({ status: 'error', message: 'Connection failed' })
|
||||
})
|
||||
|
||||
return () => {
|
||||
socket.off('connect')
|
||||
socket.off('disconnect')
|
||||
socket.off('nowPlaying')
|
||||
socket.off('connect_error')
|
||||
disconnectSocket()
|
||||
if (containerRef.current) {
|
||||
setShouldScroll(containerRef.current.scrollWidth > containerRef.current.clientWidth)
|
||||
console.log("[i] text width checked: ", containerRef.current.scrollWidth, containerRef.current.clientWidth)
|
||||
}
|
||||
}, [])
|
||||
}, [containerRef])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
|
||||
const renderScreenContent = () => {
|
||||
if (nowPlaying.status === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Loader2 className="animate-spin text-white mb-4" size={32} />
|
||||
<div className="text-white text-xs text-center px-4">
|
||||
<div className="mb-2">{nowPlaying.message || 'Connecting...'}</div>
|
||||
<Progress
|
||||
value={progressSteps.total > 0 ? (progressSteps.current * 100) / progressSteps.total : 0}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (nowPlaying.status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<AlertCircle className="text-red-500 mb-4" size={32} />
|
||||
<div className="text-red-500 text-xs text-center px-4">
|
||||
{nowPlaying.message || 'Error loading data'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!nowPlaying.track_name) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<TbDiscOff className="text-gray-400 mb-4" size={32} />
|
||||
<div className="text-gray-400 text-xs text-center px-4">
|
||||
Nothing playing
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs text-center px-4 mt-2">
|
||||
Check my <Link href="https://listenbrainz.org/user/p0ntus" target="_blank" rel="noopener noreferrer" className="text-blue-400">ListenBrainz</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// normal state
|
||||
if (shouldScroll) {
|
||||
console.log("✅ scrolling is active")
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={nowPlaying.mbid ? `https://musicbrainz.org/release/${nowPlaying.mbid}` : `https://listenbrainz.org/user/p0ntus`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
|
||||
>
|
||||
<div className="text-center leading-none pb-1">
|
||||
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" className="-mt-0.5" />
|
||||
<ScrollTxt text={nowPlaying.track_name || ''} type="track" className="-mt-0.5" />
|
||||
{nowPlaying.release_name && <ScrollTxt text={nowPlaying.release_name} type="release" className="-mt-1.5" />}
|
||||
</div>
|
||||
</a>
|
||||
{/* Album art */}
|
||||
<div className="relative w-full aspect-square">
|
||||
{nowPlaying.status === 'partial' && !nowPlaying.coverArt ? (
|
||||
<div className="w-full h-full bg-gray-700 flex flex-col items-center justify-center">
|
||||
<Loader2 className="animate-spin text-gray-400 mb-2" size={32} />
|
||||
<div className="text-gray-400 text-xs">Fetching Album Art</div>
|
||||
</div>
|
||||
) : nowPlaying.coverArt ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={nowPlaying.coverArt}
|
||||
alt={nowPlaying.track_name || 'Album cover'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
<PiMusicNotesFill size={74} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Marquee gradientWidth={20} speed={20} pauseOnHover={true}>
|
||||
<div className={className}>{text}</div>
|
||||
<span className="mx-4 text-gray-400">•</span>
|
||||
</Marquee>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${nowPlaying.release_name ? "h-[24.1rem]" : "h-[23.6rem]"}`}>
|
||||
{/* Volume buttons */}
|
||||
<div className="absolute -left-[2.55px] top-8 rounded-l w-[1.75px] flex flex-col z-0">
|
||||
<div className="h-8 bg-[#BFAF8A] border-b border-[#A09070] rounded-l cursor-pointer" onClick={() => setVolume(v => Math.min(100, v + 5))}></div> {/* up */}
|
||||
<div className="h-12 bg-[#A09070] translate-x-[0.65px] -my-[0.85px]"></div> {/* play/pause */}
|
||||
<div className="h-8 bg-[#BFAF8A] border-t border-[#A09070] rounded-l cursor-pointer" onClick={() => setVolume(v => Math.max(0, v - 5))}></div> {/* down */}
|
||||
<div ref={containerRef} className={`overflow-hidden ${className}`}>
|
||||
<div className="whitespace-nowrap">{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC = () => {
|
||||
const [track, setTrack] = useState<Track | null>(null)
|
||||
const [coverArt, setCoverArt] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingStatus, setLoadingStatus] = useState("Initializing")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [steps, setSteps] = useState(0)
|
||||
|
||||
const updateProgress = useCallback((current: number, total: number, status: string) => {
|
||||
setProgress(current)
|
||||
setSteps(total)
|
||||
setLoadingStatus(status)
|
||||
console.log(`[${current}/${total}] ${status}`)
|
||||
}, [])
|
||||
|
||||
const fetchAlbumArt = useCallback(async (artist: string, album?: string) => {
|
||||
if (!album) {
|
||||
updateProgress(0, 0, "No album found")
|
||||
setCoverArt(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
updateProgress(2, 3, `Searching for album: ${artist} - ${album}`)
|
||||
const response = await fetch(
|
||||
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
|
||||
artist
|
||||
)}%20AND%20release:${encodeURIComponent(album)}&fmt=json`
|
||||
)
|
||||
if (!response.ok) {
|
||||
updateProgress(0, 0, `Album art fetch error: ${response.status}`)
|
||||
setError("Error fetching album art (see console for details)")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data.releases && data.releases.length > 0) {
|
||||
const mbid = data.releases[0].id
|
||||
updateProgress(3, 3, "Fetching cover art...")
|
||||
setTrack(prev => prev ? { ...prev, mbid: `${mbid || null}` } : { track_name: "", artist_name: "", release_name: undefined, mbid: `${mbid || null}` })
|
||||
const coverArtResponse = await fetch(`https://coverartarchive.org/release/${mbid}/front`)
|
||||
if (coverArtResponse.ok) {
|
||||
setCoverArt(coverArtResponse.url)
|
||||
setLoading(false)
|
||||
} else {
|
||||
updateProgress(0, 0, "Cover art not found")
|
||||
setCoverArt(null)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
updateProgress(0, 0, "No releases found")
|
||||
setCoverArt(null)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
updateProgress(0, 0, `Error: ${error}`)
|
||||
setCoverArt(null)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [updateProgress])
|
||||
|
||||
const fetchNowPlaying = useCallback(async () => {
|
||||
updateProgress(1, 3, "Fetching current listen...")
|
||||
try {
|
||||
const response = await fetch("https://api.listenbrainz.org/1/user/p0ntus/playing-now")
|
||||
const data = await response.json()
|
||||
|
||||
if (data.payload.count > 0 && data.payload.listens[0].track_metadata) {
|
||||
const trackMetadata = data.payload.listens[0].track_metadata
|
||||
console.log("= TRACK METADATA =")
|
||||
if (trackMetadata.track_name) { console.log("🎵", trackMetadata.track_name) }
|
||||
if (trackMetadata.artist_name) { console.log("🎤", trackMetadata.artist_name) }
|
||||
if (trackMetadata.release_name) { console.log("💿", trackMetadata.release_name) }
|
||||
setTrack({
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.mbid,
|
||||
})
|
||||
updateProgress(2, 3, "Finding album art...")
|
||||
await fetchAlbumArt(trackMetadata.artist_name, trackMetadata.release_name)
|
||||
} else {
|
||||
updateProgress(0, 0, "No track playing")
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
updateProgress(0, 0, `Error: ${error}`)
|
||||
setError("Error fetching now playing data")
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fetchAlbumArt, updateProgress])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNowPlaying()
|
||||
}, [fetchNowPlaying])
|
||||
|
||||
if (loading) {
|
||||
console.log("[LastPlayed] Loading state rendered")
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Loader2 className="animate-spin text-gray-200" size={24} />
|
||||
<h2 className="text-2xl font-bold text-gray-200">Fetching music data...</h2>
|
||||
</div>
|
||||
{/* Top power button */}
|
||||
<div className="absolute right-1 -top-[3px] w-9 h-[3px] bg-[#BFAF8A] rounded-t-2xl cursor-pointer" onClick={() => setScreenOn(prev => !prev)}></div>
|
||||
{/* White bezel (fuses screen+home btn) */}
|
||||
<div className="absolute inset-2 bg-white rounded-sm overflow-hidden flex flex-col">
|
||||
{/* Virtual screen */}
|
||||
<div className="mx-2 mt-2 flex-1 bg-black overflow-hidden flex flex-col">
|
||||
{screenOn && (
|
||||
<div className="bg-gradient-to-b from-gray-700 via-gray-800 to-gray-900 border-b border-gray-700" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
<div className="relative flex items-center pr-1 py-0.5">
|
||||
<FaBluetoothB size={14} className="text-gray-400" />
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 text-white text-xs font-medium">{formatTime(currentTime)}</div>
|
||||
<div className="flex items-center gap-0.5 ml-auto ">
|
||||
<IoIosPlay size={14} className="text-white" />
|
||||
<IoBatteryFullSharp size={18} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{screenOn ? renderScreenContent() : (
|
||||
<div className="w-full h-full bg-black"></div>
|
||||
)}
|
||||
{/* Player controls and seekbar */}
|
||||
{screenOn && nowPlaying.track_name && (
|
||||
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
|
||||
<div className="flex justify-center items-center gap-0 px-2">
|
||||
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
|
||||
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">
|
||||
<defs>
|
||||
<linearGradient id="skipBackGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f9fafb" />
|
||||
<stop offset="49%" stopColor="#e5e7eb" />
|
||||
<stop offset="51%" stopColor="#6b7280" />
|
||||
<stop offset="100%" stopColor="#d1d5db" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="4" width="2" height="12" fill="url(#skipBackGradient)" />
|
||||
<polygon points="12,4 6,10 12,16" fill="url(#skipBackGradient)" />
|
||||
<polygon points="20,4 12,10 20,16" fill="url(#skipBackGradient)" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-[1px] h-6 bg-gray-800 mx-0.5"></div>
|
||||
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" className="drop-shadow-sm">
|
||||
<defs>
|
||||
<linearGradient id="pauseGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f9fafb" />
|
||||
<stop offset="49%" stopColor="#e5e7eb" />
|
||||
<stop offset="51%" stopColor="#6b7280" />
|
||||
<stop offset="100%" stopColor="#d1d5db" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="6" y="4" width="4" height="16" fill="url(#pauseGradient)" />
|
||||
<rect x="14" y="4" width="4" height="16" fill="url(#pauseGradient)" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-[1px] h-6 bg-gray-800 mx-1"></div>
|
||||
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
|
||||
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">
|
||||
<defs>
|
||||
<linearGradient id="skipForwardGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f9fafb" />
|
||||
<stop offset="49%" stopColor="#e5e7eb" />
|
||||
<stop offset="51%" stopColor="#6b7280" />
|
||||
<stop offset="100%" stopColor="#d1d5db" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="2,4 9,10 2,16" fill="url(#skipForwardGradient)" />
|
||||
<polygon points="9,4 17,10 9,16" fill="url(#skipForwardGradient)" />
|
||||
<rect x="18" y="4" width="2" height="12" fill="url(#skipForwardGradient)" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative w-full flex justify-center mt-1">
|
||||
<div className="w-38 h-2 bg-gray-800 rounded-full relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white to-gray-600 rounded-full" style={{width: `${volume}%`}} />
|
||||
<div
|
||||
className="absolute top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 bg-gradient-to-b from-gray-200 via-gray-300 to-gray-500 rounded-full border border-gray-400 shadow-inner" style={{
|
||||
left: `calc(${volume}% - 8px)`,
|
||||
backgroundImage: 'radial-gradient(circle at 30% 30%, #f0f0f0 0%, #c0c0c0 60%, #808080 100%), repeating-conic-gradient(#f9fafb 0deg 45deg, #9ca3af 45deg 90deg)',
|
||||
backgroundBlendMode: 'overlay',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.3), 0 1px 2px rgba(255,255,255,0.5)'
|
||||
}}></div>
|
||||
<input type="range" min="0" max="100" value={volume} onChange={(e) => setVolume(Number(e.target.value))} className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Home button */}
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="w-8 h-8 bg-white rounded-full border border-gray-300 shadow flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-1 border-[#D4C29A] rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={steps > 0 ? (progress * 100) / steps : 0} className="mb-4" />
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<p className="text-gray-200">
|
||||
{loadingStatus} {steps > 0 && `(${progress}/${steps})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("[LastPlayed] Error state rendered")
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-4 text-gray-200">Now Playing</h2>
|
||||
<div className="flex items-center justify-center text-red-500">
|
||||
<AlertCircle className="text-red-500 mr-2" size={24} />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
console.log("[LastPlayed] Hidden due to no track data")
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TbDiscOff className="text-gray-200" size={24} />
|
||||
<h2 className="text-2xl font-bold text-gray-200">Nothing's playing right now</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<p>Can you believe it? I'm not listening to anything on ListenBrainz right now! If you're in the mood, feel free to check out my <Link href="https://listenbrainz.org/user/p0ntus" target="_blank" rel="noopener noreferrer">ListenBrainz</Link>.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[LastPlayed] Rendered track:", track.track_name)
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TbDisc className="animate-spin text-gray-200" size={24} />
|
||||
<h2 className="text-2xl font-bold text-gray-200">Now Playing</h2>
|
||||
</div>
|
||||
<div className="now-playing flex items-center">
|
||||
{coverArt ? (
|
||||
<div className="relative w-26 h-26 md:w-40 md:h-40 rounded-lg mr-4 flex-shrink-0">
|
||||
<Image
|
||||
src={coverArt || ""}
|
||||
alt={track.track_name}
|
||||
fill
|
||||
sizes="96px"
|
||||
style={{ objectFit: "cover" }}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-26 h-26 md:w-40 md:h-40 bg-gray-200 rounded-lg mr-4 flex items-center justify-center flex-shrink-0">
|
||||
<Music size={48} className="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow min-w-0 overflow-hidden">
|
||||
<div className="flex items-center space-x-2 font-bold text-lg mb-1">
|
||||
<Music size={16} className="text-gray-200 flex-shrink-0" />
|
||||
<ScrollableText text={track.track_name} className="text-gray-200" />
|
||||
</div>
|
||||
{track.release_name && (
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<Disc size={16} className="text-gray-300 flex-shrink-0" />
|
||||
<ScrollableText text={track.release_name} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<User size={16} className="text-gray-300 flex-shrink-0" />
|
||||
<ScrollableText text={track.artist_name} className="text-gray-300" />
|
||||
</div>
|
||||
<a
|
||||
href={track.mbid ? `https://musicbrainz.org/release/${track.mbid}` : `https://listenbrainz.org/user/p0ntus`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 flex items-center mt-1 hover:text-blue-300 transition-colors duration-200"
|
||||
>
|
||||
<ExternalLink size={16} className="mr-1 flex-shrink-0" />
|
||||
<span>View on MusicBrainz</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 mt-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
|
||||
<p className="text-gray-200 text-sm">
|
||||
<span className="font-bold text-red-400">LIVE</span> data provided by <Link href="https://listenbrainz.org" target="_blank" rel="noopener noreferrer">ListenBrainz</Link>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-200 text-sm">
|
||||
Last updated: {new Date().toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
import { Server as SocketServer } from 'socket.io'
|
||||
|
||||
interface TrackMetadata {
|
||||
track_name: string
|
||||
artist_name: string
|
||||
release_name?: string
|
||||
mbid?: string
|
||||
additional_info?: {
|
||||
recording_mbid?: string
|
||||
artist_mbids?: string[]
|
||||
release_mbid?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LastFmImage {
|
||||
size: string
|
||||
'#text': string
|
||||
}
|
||||
|
||||
interface LastFmAlbum {
|
||||
image?: LastFmImage[]
|
||||
}
|
||||
|
||||
interface LastFmTrack {
|
||||
album?: LastFmAlbum
|
||||
}
|
||||
|
||||
interface LastFmResponse {
|
||||
album?: LastFmAlbum
|
||||
track?: LastFmTrack
|
||||
}
|
||||
|
||||
interface NowPlayingData {
|
||||
track_name?: string
|
||||
artist_name?: string
|
||||
release_name?: string
|
||||
mbid?: string
|
||||
coverArt?: string | null
|
||||
lastFmData?: LastFmResponse
|
||||
status: 'loading' | 'partial' | 'complete' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export class NowPlayingService {
|
||||
private readonly io: SocketServer
|
||||
private readonly lastFmApiKey: string | undefined
|
||||
|
||||
constructor(io: SocketServer) {
|
||||
this.io = io
|
||||
this.lastFmApiKey = process.env.LASTFM_API_KEY
|
||||
}
|
||||
|
||||
async fetchNowPlaying(socketId: string) {
|
||||
const emit = (data: Partial<NowPlayingData>) => {
|
||||
this.io.to(socketId).emit('nowPlaying', data)
|
||||
}
|
||||
|
||||
try {
|
||||
emit({ status: 'loading', message: 'Fetching from ListenBrainz...' })
|
||||
|
||||
const listenBrainzResponse = await fetch(
|
||||
'https://api.listenbrainz.org/1/user/p0ntus/playing-now',
|
||||
{
|
||||
headers: process.env.LISTENBRAINZ_TOKEN
|
||||
? { Authorization: `Token ${process.env.LISTENBRAINZ_TOKEN}` }
|
||||
: {}
|
||||
}
|
||||
)
|
||||
|
||||
if (!listenBrainzResponse.ok) {
|
||||
emit({
|
||||
status: 'error',
|
||||
message: `ListenBrainz error: ${listenBrainzResponse.status}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const listenBrainzData = await listenBrainzResponse.json()
|
||||
|
||||
if (listenBrainzData.payload.count === 0) {
|
||||
emit({
|
||||
status: 'complete',
|
||||
message: 'No track currently playing'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const trackMetadata: TrackMetadata = listenBrainzData.payload.listens[0].track_metadata
|
||||
|
||||
emit({
|
||||
status: 'partial',
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.additional_info?.release_mbid,
|
||||
message: 'Fetching additional info...'
|
||||
})
|
||||
|
||||
// Try to get data from Last.fm if MBID
|
||||
let lastFmData: LastFmResponse | null = null
|
||||
let lastFmCoverArt: string | null = null
|
||||
|
||||
if (this.lastFmApiKey) {
|
||||
emit({ status: 'partial', message: 'Fetching from Last.fm...' })
|
||||
|
||||
const lastFmQueries = []
|
||||
|
||||
// Try with MBID if available
|
||||
if (trackMetadata.additional_info?.recording_mbid) {
|
||||
lastFmQueries.push(this.fetchLastFmByMbid(trackMetadata.additional_info.recording_mbid))
|
||||
}
|
||||
|
||||
// Also try with track and artist name
|
||||
lastFmQueries.push(this.fetchLastFmByTrack(
|
||||
trackMetadata.artist_name,
|
||||
trackMetadata.track_name
|
||||
))
|
||||
|
||||
const lastFmResults = await Promise.allSettled(lastFmQueries)
|
||||
|
||||
for (const result of lastFmResults) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
lastFmData = result.value
|
||||
// Extract cover
|
||||
if (lastFmData.album?.image) {
|
||||
const images = lastFmData.album.image
|
||||
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
|
||||
images.find((img: LastFmImage) => img.size === 'large') ||
|
||||
images[images.length - 1]
|
||||
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
|
||||
lastFmCoverArt = largeImage['#text']
|
||||
}
|
||||
} else if (lastFmData.track?.album?.image) {
|
||||
const images = lastFmData.track.album.image
|
||||
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
|
||||
images.find((img: LastFmImage) => img.size === 'large') ||
|
||||
images[images.length - 1]
|
||||
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
|
||||
lastFmCoverArt = largeImage['#text']
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get album art
|
||||
let finalCoverArt = lastFmCoverArt
|
||||
|
||||
if (!finalCoverArt) {
|
||||
if (trackMetadata.additional_info?.release_mbid) {
|
||||
emit({ status: 'partial', message: 'Fetching from Cover Art Archive...' })
|
||||
|
||||
try {
|
||||
const coverArtResponse = await fetch(
|
||||
`https://coverartarchive.org/release/${trackMetadata.additional_info.release_mbid}/front`
|
||||
)
|
||||
|
||||
if (coverArtResponse.ok) {
|
||||
finalCoverArt = coverArtResponse.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Cover Art Archive direct fetch failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalCoverArt && trackMetadata.release_name && trackMetadata.artist_name) {
|
||||
emit({ status: 'partial', message: 'Searching MusicBrainz for album art...' })
|
||||
|
||||
try {
|
||||
const mbSearchResponse = await fetch(
|
||||
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
|
||||
trackMetadata.artist_name
|
||||
)}%20AND%20release:${encodeURIComponent(trackMetadata.release_name)}&fmt=json&limit=1`
|
||||
)
|
||||
|
||||
if (mbSearchResponse.ok) {
|
||||
const mbData = await mbSearchResponse.json()
|
||||
|
||||
if (mbData.releases && mbData.releases.length > 0) {
|
||||
const releaseMbid = mbData.releases[0].id
|
||||
|
||||
try {
|
||||
const coverArtResponse = await fetch(
|
||||
`https://coverartarchive.org/release/${releaseMbid}/front`
|
||||
)
|
||||
|
||||
if (coverArtResponse.ok) {
|
||||
finalCoverArt = coverArtResponse.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Cover Art Archive fallback fetch failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] MusicBrainz search failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit({
|
||||
status: 'complete',
|
||||
track_name: trackMetadata.track_name,
|
||||
artist_name: trackMetadata.artist_name,
|
||||
release_name: trackMetadata.release_name,
|
||||
mbid: trackMetadata.additional_info?.release_mbid || trackMetadata.mbid,
|
||||
coverArt: finalCoverArt || null,
|
||||
lastFmData: lastFmData || undefined,
|
||||
message: 'Complete'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[!] Error in fetchNowPlaying:', error)
|
||||
emit({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLastFmByMbid(mbid: string): Promise<LastFmResponse | null> {
|
||||
if (!this.lastFmApiKey) return null
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://ws.audioscrobbler.com/2.0/?method=track.getInfoByMbid&mbid=${mbid}&api_key=${this.lastFmApiKey}&format=json`
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json() as LastFmResponse
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Last.fm MBID fetch failed:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async fetchLastFmByTrack(artist: string, track: string): Promise<LastFmResponse | null> {
|
||||
if (!this.lastFmApiKey) return null
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
method: 'track.getInfo',
|
||||
api_key: this.lastFmApiKey,
|
||||
artist: artist,
|
||||
track: track,
|
||||
format: 'json',
|
||||
autocorrect: '1'
|
||||
})
|
||||
|
||||
const response = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`)
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json() as LastFmResponse
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[!] Last.fm track fetch failed:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { io, Socket } from "socket.io-client"
|
||||
|
||||
let socket: Socket | null = null
|
||||
|
||||
export const getSocket = (): Socket => {
|
||||
if (!socket) {
|
||||
socket = io(undefined, {
|
||||
autoConnect: false,
|
||||
})
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
export const connectSocket = (): Socket => {
|
||||
const s = getSocket()
|
||||
if (!s.connected) {
|
||||
s.connect()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export const disconnectSocket = (): void => {
|
||||
if (socket?.connected) {
|
||||
socket.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@ const nextConfig: NextConfig = {
|
|||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.archive.org',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
dangerouslyAllowSVG: true,
|
||||
},
|
||||
|
|
|
|||
2048
package-lock.json
generated
2048
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -3,43 +3,44 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run server.ts",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "NODE_ENV=production bun run server.ts",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@radix-ui/react-progress": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.5.1",
|
||||
"geist": "^1.4.2",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"lucide-react": "^0.485.0",
|
||||
"next": "^15.5.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"next": "^15.3.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-fast-marquee": "^1.6.5",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "^3.2.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.8"
|
||||
"tw-animate-css": "^1.2.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
"@tailwindcss/postcss": "^4.1.6",
|
||||
"@types/node": "^20.17.46",
|
||||
"@types/react": "^19.1.3",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"sharp",
|
||||
|
|
|
|||
|
|
@ -1,857 +0,0 @@
|
|||
{
|
||||
"daily": [
|
||||
{
|
||||
"date": "2025-08-08",
|
||||
"inputTokens": 14919,
|
||||
"outputTokens": 23378,
|
||||
"cacheCreationTokens": 480031,
|
||||
"cacheReadTokens": 11034031,
|
||||
"totalTokens": 11552359,
|
||||
"totalCost": 6.777273749999996,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 4837,
|
||||
"outputTokens": 20788,
|
||||
"cacheCreationTokens": 443453,
|
||||
"cacheReadTokens": 10661975,
|
||||
"cost": 5.18787225
|
||||
},
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 10082,
|
||||
"outputTokens": 2590,
|
||||
"cacheCreationTokens": 36578,
|
||||
"cacheReadTokens": 372056,
|
||||
"cost": 1.5894014999999997
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-09",
|
||||
"inputTokens": 3142,
|
||||
"outputTokens": 20594,
|
||||
"cacheCreationTokens": 513312,
|
||||
"cacheReadTokens": 13270007,
|
||||
"totalTokens": 13807055,
|
||||
"totalCost": 20.561232300000007,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 373,
|
||||
"outputTokens": 10485,
|
||||
"cacheCreationTokens": 294339,
|
||||
"cacheReadTokens": 7740261,
|
||||
"cost": 17.92121775
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 2769,
|
||||
"outputTokens": 10109,
|
||||
"cacheCreationTokens": 218973,
|
||||
"cacheReadTokens": 5529746,
|
||||
"cost": 2.640014549999999
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-10",
|
||||
"inputTokens": 2384,
|
||||
"outputTokens": 33087,
|
||||
"cacheCreationTokens": 752268,
|
||||
"cacheReadTokens": 12833548,
|
||||
"totalTokens": 13621287,
|
||||
"totalCost": 24.83825640000001,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 983,
|
||||
"outputTokens": 24065,
|
||||
"cacheCreationTokens": 320876,
|
||||
"cacheReadTokens": 9495745,
|
||||
"cost": 22.079662499999998
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 1401,
|
||||
"outputTokens": 9022,
|
||||
"cacheCreationTokens": 431392,
|
||||
"cacheReadTokens": 3337803,
|
||||
"cost": 2.7585938999999993
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-11",
|
||||
"inputTokens": 1127,
|
||||
"outputTokens": 23663,
|
||||
"cacheCreationTokens": 746606,
|
||||
"cacheReadTokens": 10310633,
|
||||
"totalTokens": 11082029,
|
||||
"totalCost": 31.256441999999993,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1127,
|
||||
"outputTokens": 23663,
|
||||
"cacheCreationTokens": 746606,
|
||||
"cacheReadTokens": 10310633,
|
||||
"cost": 31.256441999999993
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-12",
|
||||
"inputTokens": 17245,
|
||||
"outputTokens": 164864,
|
||||
"cacheCreationTokens": 2646250,
|
||||
"cacheReadTokens": 49767559,
|
||||
"totalTokens": 52595918,
|
||||
"totalCost": 85.49760780000005,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 13710,
|
||||
"outputTokens": 77330,
|
||||
"cacheCreationTokens": 1413354,
|
||||
"cacheReadTokens": 26762148,
|
||||
"cost": 72.64900950000008
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 3535,
|
||||
"outputTokens": 87534,
|
||||
"cacheCreationTokens": 1232896,
|
||||
"cacheReadTokens": 23005411,
|
||||
"cost": 12.848598300000004
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-13",
|
||||
"inputTokens": 29365,
|
||||
"outputTokens": 23237,
|
||||
"cacheCreationTokens": 1034891,
|
||||
"cacheReadTokens": 7332169,
|
||||
"totalTokens": 8419662,
|
||||
"totalCost": 9.039594749999997,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 24909,
|
||||
"outputTokens": 18462,
|
||||
"cacheCreationTokens": 935307,
|
||||
"cacheReadTokens": 6758235,
|
||||
"cost": 5.886528749999999
|
||||
},
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 4456,
|
||||
"outputTokens": 4775,
|
||||
"cacheCreationTokens": 99584,
|
||||
"cacheReadTokens": 573934,
|
||||
"cost": 3.1530659999999995
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-14",
|
||||
"inputTokens": 4984,
|
||||
"outputTokens": 20654,
|
||||
"cacheCreationTokens": 676409,
|
||||
"cacheReadTokens": 8769252,
|
||||
"totalTokens": 9471299,
|
||||
"totalCost": 13.503454350000002,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 282,
|
||||
"outputTokens": 6364,
|
||||
"cacheCreationTokens": 260989,
|
||||
"cacheReadTokens": 3092770,
|
||||
"cost": 10.014228749999996
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 4702,
|
||||
"outputTokens": 14290,
|
||||
"cacheCreationTokens": 415420,
|
||||
"cacheReadTokens": 5676482,
|
||||
"cost": 3.489225600000001
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-15",
|
||||
"inputTokens": 6744,
|
||||
"outputTokens": 53509,
|
||||
"cacheCreationTokens": 1315474,
|
||||
"cacheReadTokens": 18699807,
|
||||
"totalTokens": 20075534,
|
||||
"totalCost": 37.771287000000015,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 3739,
|
||||
"outputTokens": 36231,
|
||||
"cacheCreationTokens": 818888,
|
||||
"cacheReadTokens": 9919502,
|
||||
"cost": 33.00681300000001
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 3005,
|
||||
"outputTokens": 17278,
|
||||
"cacheCreationTokens": 496586,
|
||||
"cacheReadTokens": 8780305,
|
||||
"cost": 4.764474
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-16",
|
||||
"inputTokens": 67226,
|
||||
"outputTokens": 230912,
|
||||
"cacheCreationTokens": 4693459,
|
||||
"cacheReadTokens": 126251857,
|
||||
"totalTokens": 131243454,
|
||||
"totalCost": 67.51195695000014,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 66858,
|
||||
"outputTokens": 225159,
|
||||
"cacheCreationTokens": 4442992,
|
||||
"cacheReadTokens": 122698549,
|
||||
"cost": 57.048743700000024
|
||||
},
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 368,
|
||||
"outputTokens": 5753,
|
||||
"cacheCreationTokens": 250467,
|
||||
"cacheReadTokens": 3553308,
|
||||
"cost": 10.463213249999994
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-17",
|
||||
"inputTokens": 5258,
|
||||
"outputTokens": 107279,
|
||||
"cacheCreationTokens": 2065168,
|
||||
"cacheReadTokens": 40221095,
|
||||
"totalTokens": 42398800,
|
||||
"totalCost": 46.292036099999905,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1306,
|
||||
"outputTokens": 37998,
|
||||
"cacheCreationTokens": 568961,
|
||||
"cacheReadTokens": 11688668,
|
||||
"cost": 31.07046074999999
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 3952,
|
||||
"outputTokens": 69281,
|
||||
"cacheCreationTokens": 1496207,
|
||||
"cacheReadTokens": 28532427,
|
||||
"cost": 15.221575350000005
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-18",
|
||||
"inputTokens": 26822,
|
||||
"outputTokens": 158126,
|
||||
"cacheCreationTokens": 4162794,
|
||||
"cacheReadTokens": 85133032,
|
||||
"totalTokens": 89480774,
|
||||
"totalCost": 98.36223149999994,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1334,
|
||||
"outputTokens": 59577,
|
||||
"cacheCreationTokens": 1226868,
|
||||
"cacheReadTokens": 27304867,
|
||||
"cost": 68.4493605
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 25488,
|
||||
"outputTokens": 98549,
|
||||
"cacheCreationTokens": 2935926,
|
||||
"cacheReadTokens": 57828165,
|
||||
"cost": 29.912871000000024
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-19",
|
||||
"inputTokens": 25035,
|
||||
"outputTokens": 192422,
|
||||
"cacheCreationTokens": 2749046,
|
||||
"cacheReadTokens": 86412205,
|
||||
"totalTokens": 89378708,
|
||||
"totalCost": 120.90988019999996,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 6500,
|
||||
"outputTokens": 120014,
|
||||
"cacheCreationTokens": 1447294,
|
||||
"cacheReadTokens": 43939676,
|
||||
"cost": 102.14482650000001
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 18535,
|
||||
"outputTokens": 72408,
|
||||
"cacheCreationTokens": 1301752,
|
||||
"cacheReadTokens": 42472529,
|
||||
"cost": 18.76505370000004
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-20",
|
||||
"inputTokens": 1777,
|
||||
"outputTokens": 45019,
|
||||
"cacheCreationTokens": 1288952,
|
||||
"cacheReadTokens": 18847679,
|
||||
"totalTokens": 20183427,
|
||||
"totalCost": 46.130642700000024,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1472,
|
||||
"outputTokens": 29172,
|
||||
"cacheCreationTokens": 1017913,
|
||||
"cacheReadTokens": 14937895,
|
||||
"cost": 43.702691249999994
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 305,
|
||||
"outputTokens": 15847,
|
||||
"cacheCreationTokens": 271039,
|
||||
"cacheReadTokens": 3909784,
|
||||
"cost": 2.4279514499999997
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-21",
|
||||
"inputTokens": 81,
|
||||
"outputTokens": 3400,
|
||||
"cacheCreationTokens": 57191,
|
||||
"cacheReadTokens": 406935,
|
||||
"totalTokens": 467607,
|
||||
"totalCost": 1.0935505500000002,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 37,
|
||||
"outputTokens": 826,
|
||||
"cacheCreationTokens": 29950,
|
||||
"cacheReadTokens": 172089,
|
||||
"cost": 0.882201
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 44,
|
||||
"outputTokens": 2574,
|
||||
"cacheCreationTokens": 27241,
|
||||
"cacheReadTokens": 234846,
|
||||
"cost": 0.21134955
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-22",
|
||||
"inputTokens": 201,
|
||||
"outputTokens": 26357,
|
||||
"cacheCreationTokens": 182770,
|
||||
"cacheReadTokens": 1764101,
|
||||
"totalTokens": 1973429,
|
||||
"totalCost": 8.052878999999999,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 201,
|
||||
"outputTokens": 26357,
|
||||
"cacheCreationTokens": 182770,
|
||||
"cacheReadTokens": 1764101,
|
||||
"cost": 8.052878999999999
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-23",
|
||||
"inputTokens": 114,
|
||||
"outputTokens": 6030,
|
||||
"cacheCreationTokens": 408902,
|
||||
"cacheReadTokens": 2606990,
|
||||
"totalTokens": 3022036,
|
||||
"totalCost": 11.605633500000005,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 88,
|
||||
"outputTokens": 5424,
|
||||
"cacheCreationTokens": 387862,
|
||||
"cacheReadTokens": 2545780,
|
||||
"cost": 11.499202500000006
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 26,
|
||||
"outputTokens": 606,
|
||||
"cacheCreationTokens": 21040,
|
||||
"cacheReadTokens": 61210,
|
||||
"cost": 0.106431
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-26",
|
||||
"inputTokens": 2836,
|
||||
"outputTokens": 22779,
|
||||
"cacheCreationTokens": 465292,
|
||||
"cacheReadTokens": 11182259,
|
||||
"totalTokens": 11673166,
|
||||
"totalCost": 25.288227900000006,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 2745,
|
||||
"outputTokens": 19221,
|
||||
"cacheCreationTokens": 405641,
|
||||
"cacheReadTokens": 10473081,
|
||||
"cost": 24.798140250000003
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 91,
|
||||
"outputTokens": 3558,
|
||||
"cacheCreationTokens": 59651,
|
||||
"cacheReadTokens": 709178,
|
||||
"cost": 0.49008765
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-30",
|
||||
"inputTokens": 151,
|
||||
"outputTokens": 63263,
|
||||
"cacheCreationTokens": 430727,
|
||||
"cacheReadTokens": 4992045,
|
||||
"totalTokens": 5486186,
|
||||
"totalCost": 20.311188749999992,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 151,
|
||||
"outputTokens": 63263,
|
||||
"cacheCreationTokens": 430727,
|
||||
"cacheReadTokens": 4992045,
|
||||
"cost": 20.311188749999992
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-08-31",
|
||||
"inputTokens": 108,
|
||||
"outputTokens": 777,
|
||||
"cacheCreationTokens": 40539,
|
||||
"cacheReadTokens": 305195,
|
||||
"totalTokens": 346619,
|
||||
"totalCost": 1.2777937499999998,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 108,
|
||||
"outputTokens": 777,
|
||||
"cacheCreationTokens": 40539,
|
||||
"cacheReadTokens": 305195,
|
||||
"cost": 1.2777937499999998
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"inputTokens": 592,
|
||||
"outputTokens": 28240,
|
||||
"cacheCreationTokens": 712734,
|
||||
"cacheReadTokens": 12698327,
|
||||
"totalTokens": 13439893,
|
||||
"totalCost": 34.53813299999999,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 592,
|
||||
"outputTokens": 28240,
|
||||
"cacheCreationTokens": 712734,
|
||||
"cacheReadTokens": 12698327,
|
||||
"cost": 34.53813299999999
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-06",
|
||||
"inputTokens": 1668,
|
||||
"outputTokens": 33964,
|
||||
"cacheCreationTokens": 833152,
|
||||
"cacheReadTokens": 7717866,
|
||||
"totalTokens": 8586650,
|
||||
"totalCost": 27.341439,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 590,
|
||||
"outputTokens": 32624,
|
||||
"cacheCreationTokens": 715702,
|
||||
"cacheReadTokens": 7239371,
|
||||
"cost": 26.734119
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 1078,
|
||||
"outputTokens": 1340,
|
||||
"cacheCreationTokens": 117450,
|
||||
"cacheReadTokens": 478495,
|
||||
"cost": 0.6073200000000001
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-07",
|
||||
"inputTokens": 114,
|
||||
"outputTokens": 1815,
|
||||
"cacheCreationTokens": 34842,
|
||||
"cacheReadTokens": 441584,
|
||||
"totalTokens": 478355,
|
||||
"totalCost": 0.2906997,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 114,
|
||||
"outputTokens": 1815,
|
||||
"cacheCreationTokens": 34842,
|
||||
"cacheReadTokens": 441584,
|
||||
"cost": 0.2906997
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-08",
|
||||
"inputTokens": 986,
|
||||
"outputTokens": 43404,
|
||||
"cacheCreationTokens": 1059761,
|
||||
"cacheReadTokens": 10126143,
|
||||
"totalTokens": 11230294,
|
||||
"totalCost": 13.057204650000008,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 59,
|
||||
"outputTokens": 6291,
|
||||
"cacheCreationTokens": 138364,
|
||||
"cacheReadTokens": 2448010,
|
||||
"cost": 6.739049999999998
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 927,
|
||||
"outputTokens": 37113,
|
||||
"cacheCreationTokens": 921397,
|
||||
"cacheReadTokens": 7678133,
|
||||
"cost": 6.318154650000004
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-09",
|
||||
"inputTokens": 8308,
|
||||
"outputTokens": 25340,
|
||||
"cacheCreationTokens": 822848,
|
||||
"cacheReadTokens": 15392924,
|
||||
"totalTokens": 16249420,
|
||||
"totalCost": 34.003420200000015,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 818,
|
||||
"outputTokens": 20524,
|
||||
"cacheCreationTokens": 598685,
|
||||
"cacheReadTokens": 13061090,
|
||||
"cost": 32.36854874999999
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 7490,
|
||||
"outputTokens": 4816,
|
||||
"cacheCreationTokens": 224163,
|
||||
"cacheReadTokens": 2331834,
|
||||
"cost": 1.6348714500000001
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-10",
|
||||
"inputTokens": 6970,
|
||||
"outputTokens": 56622,
|
||||
"cacheCreationTokens": 2561420,
|
||||
"cacheReadTokens": 41730332,
|
||||
"totalTokens": 44355344,
|
||||
"totalCost": 99.1074762,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1372,
|
||||
"outputTokens": 33019,
|
||||
"cacheCreationTokens": 1955184,
|
||||
"cacheReadTokens": 37322873,
|
||||
"cost": 95.14101450000001
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 5598,
|
||||
"outputTokens": 23603,
|
||||
"cacheCreationTokens": 606236,
|
||||
"cacheReadTokens": 4407459,
|
||||
"cost": 3.9664617000000004
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-11",
|
||||
"inputTokens": 6374,
|
||||
"outputTokens": 49460,
|
||||
"cacheCreationTokens": 2458036,
|
||||
"cacheReadTokens": 26091177,
|
||||
"totalTokens": 28605047,
|
||||
"totalCost": 51.98917170000001,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 820,
|
||||
"outputTokens": 16938,
|
||||
"cacheCreationTokens": 1169066,
|
||||
"cacheReadTokens": 13017543,
|
||||
"cost": 42.728951999999985
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 5554,
|
||||
"outputTokens": 32522,
|
||||
"cacheCreationTokens": 1288970,
|
||||
"cacheReadTokens": 13073634,
|
||||
"cost": 9.2602197
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-12",
|
||||
"inputTokens": 11056,
|
||||
"outputTokens": 48249,
|
||||
"cacheCreationTokens": 1310531,
|
||||
"cacheReadTokens": 29530721,
|
||||
"totalTokens": 30900557,
|
||||
"totalCost": 16.632927149999993,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 10888,
|
||||
"outputTokens": 42034,
|
||||
"cacheCreationTokens": 1244336,
|
||||
"cacheReadTokens": 28918658,
|
||||
"cost": 14.005031399999988
|
||||
},
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 168,
|
||||
"outputTokens": 6215,
|
||||
"cacheCreationTokens": 66195,
|
||||
"cacheReadTokens": 612063,
|
||||
"cost": 2.6278957499999995
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-13",
|
||||
"inputTokens": 1473,
|
||||
"outputTokens": 38594,
|
||||
"cacheCreationTokens": 754889,
|
||||
"cacheReadTokens": 7447975,
|
||||
"totalTokens": 8242931,
|
||||
"totalCost": 27.64876425,
|
||||
"modelsUsed": [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-1-20250805"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 1437,
|
||||
"outputTokens": 38340,
|
||||
"cacheCreationTokens": 724797,
|
||||
"cacheReadTokens": 7342175,
|
||||
"cost": 27.50026125
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 36,
|
||||
"outputTokens": 254,
|
||||
"cacheCreationTokens": 30092,
|
||||
"cacheReadTokens": 105800,
|
||||
"cost": 0.148503
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": "2025-09-15",
|
||||
"inputTokens": 498,
|
||||
"outputTokens": 54148,
|
||||
"cacheCreationTokens": 988285,
|
||||
"cacheReadTokens": 16800377,
|
||||
"totalTokens": 17843308,
|
||||
"totalCost": 46.537434450000006,
|
||||
"modelsUsed": [
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-sonnet-4-20250514"
|
||||
],
|
||||
"modelBreakdowns": [
|
||||
{
|
||||
"modelName": "claude-opus-4-1-20250805",
|
||||
"inputTokens": 448,
|
||||
"outputTokens": 52014,
|
||||
"cacheCreationTokens": 924435,
|
||||
"cacheReadTokens": 16653998,
|
||||
"cost": 46.221923249999996
|
||||
},
|
||||
{
|
||||
"modelName": "claude-sonnet-4-20250514",
|
||||
"inputTokens": 50,
|
||||
"outputTokens": 2134,
|
||||
"cacheCreationTokens": 63850,
|
||||
"cacheReadTokens": 146379,
|
||||
"cost": 0.3155112
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"inputTokens": 247558,
|
||||
"outputTokens": 1599186,
|
||||
"cacheCreationTokens": 36246579,
|
||||
"cacheReadTokens": 678117825,
|
||||
"totalTokens": 716211148,
|
||||
"totalCost": 1027.2278395500002
|
||||
}
|
||||
}
|
||||
|
|
@ -6,23 +6,23 @@
|
|||
},
|
||||
{
|
||||
"id": 2,
|
||||
"domain": "pontushost.com",
|
||||
"usage": "My hosting provider project"
|
||||
"domain": "aidxn.fun",
|
||||
"usage": "My alternative homepage (Version 2)"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"domain": "librecloud.cc",
|
||||
"usage": "LibreCloud's root domain"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"domain": "disfunction.blog",
|
||||
"usage": "My blog's official home"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"domain": "androidintegrity.org",
|
||||
"usage": "A project to fix Play Integrity"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"domain": "librecloud.cc",
|
||||
"usage": "My old cloud services provider project"
|
||||
"domain": "androidintegrity.org",
|
||||
"usage": "A team project to improve Play Integrity"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
|
|
@ -47,12 +47,12 @@
|
|||
{
|
||||
"id": 10,
|
||||
"domain": "dontbeevil.lol",
|
||||
"usage": "Another fun domain for p0ntus mail"
|
||||
"usage": "A Google meme domain used for p0ntus mail"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"domain": "wikitools.cloud",
|
||||
"usage": "Unused (for now!)"
|
||||
"usage": "Tools I've made for Wikipedia"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
|
|
|
|||
|
|
@ -4,35 +4,35 @@
|
|||
"name": "aidxnCC",
|
||||
"description": "aidxnCC is the third version of my personal website",
|
||||
"github": false,
|
||||
"url": "https://git.p0ntus.com/aidan/aidxnCC",
|
||||
"stars": 1,
|
||||
"forks": 0
|
||||
"url": "https://git.pontusmail.org/aidan/aidxnCC",
|
||||
"stars": 2,
|
||||
"forks": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "abocn/TelegramBot",
|
||||
"name": "librecloud/web",
|
||||
"description": "Landing page for p0ntus mail",
|
||||
"github": true,
|
||||
"url": "https://github.com/abocn/TelegramBot",
|
||||
"stars": 13,
|
||||
"forks": 6
|
||||
"github": false,
|
||||
"url": "https://git.pontusmail.org/librecloud/web",
|
||||
"stars": 0,
|
||||
"forks": 0
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "modules",
|
||||
"description": "A Magisk/KernelSU module repository",
|
||||
"github": true,
|
||||
"url": "https://github.com/abocn/modules",
|
||||
"stars": 5,
|
||||
"description": "An open-source Magisk module and FOSS app store",
|
||||
"github": false,
|
||||
"url": "https://git.pontusmail.org/aidan/modules",
|
||||
"stars": 3,
|
||||
"forks": 0
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "pontus/pontus-front",
|
||||
"description": "The frontend and API for p0ntus, my free privacy-focused service provider",
|
||||
"github": false,
|
||||
"url": "https://git.p0ntus.com/pontus/pontus-front",
|
||||
"stars": 1,
|
||||
"forks": 0
|
||||
"name": "AndroidIntegrity/website",
|
||||
"description": "AIA website source code",
|
||||
"github": true,
|
||||
"url": "https://github.com/AndroidIntegrity/website",
|
||||
"stars": 6,
|
||||
"forks": 1
|
||||
}
|
||||
]
|
||||
|
|
|
|||
56
public/data/music.json
Normal file
56
public/data/music.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[
|
||||
{
|
||||
"timePeriod": "Early Summer 2024",
|
||||
"songs": [
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/noticeme.png",
|
||||
"name": "Notice Me",
|
||||
"artist": "tobi lou feat. MIA GLADSTONE",
|
||||
"duration": "2:35",
|
||||
"link": "https://www.last.fm/music/tobi+lou/Notice+Me"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/comforttexas.webp",
|
||||
"name": "comfort, texas",
|
||||
"artist": "Buppy.",
|
||||
"duration": "2:11",
|
||||
"link": "https://www.last.fm/music/Buppy./comfort,+texas"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/nonperishable.webp",
|
||||
"name": "Jelly",
|
||||
"artist": "tobi lou",
|
||||
"duration": "1:50",
|
||||
"link": "https://www.last.fm/music/tobi+lou/_/Jelly"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/exes.webp",
|
||||
"name": "exes",
|
||||
"artist": "Tate McRae",
|
||||
"duration": "2:39",
|
||||
"link": "https://www.last.fm/music/Tate+McRae/exes/exes"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/ick.webp",
|
||||
"name": "Ick",
|
||||
"artist": "Lay Bankz",
|
||||
"duration": "1:55",
|
||||
"link": "https://www.last.fm/music/Lay+Bankz/_/Ick"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/nani.webp",
|
||||
"name": "NANi",
|
||||
"artist": "Saweetie",
|
||||
"duration": "2:34",
|
||||
"link": "https://www.last.fm/music/Saweetie/Nani"
|
||||
},
|
||||
{
|
||||
"albumArt": "https://p0ntus.com/archives/img/killerloverboy.webp",
|
||||
"name": "killer lover boy",
|
||||
"artist": "SEB",
|
||||
"duration": "2:14",
|
||||
"link": "https://www.last.fm/music/Seb/_/killer+lover+boy"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
|
|
@ -1,20 +1,20 @@
|
|||
{
|
||||
"home": {
|
||||
"whoAmI": [
|
||||
"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."
|
||||
"Hey there! My name is Aidan, and I'm a systems administrator, full-stack developer, and student from the United States. I primarily work with Linux, Docker, Next.js, and Node.js.",
|
||||
"I primarily focus on Linux system administration with a few servers I run for myself and others. I enjoy working on web development projects on the side, most of which are Unlicensed/CC0.",
|
||||
"When I'm not programming, I can be found re-flashing my phone with a new custom ROM and jumping between projects."
|
||||
],
|
||||
"whatIDo": [
|
||||
"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'm at my best when I'm doing system administration, which is what I'd say I have the most experience and familiarity with.",
|
||||
"I host a variety of public-access services and websites on my VPS, most of which can be found on my \"Domains\" page with a short description.",
|
||||
"My biggest project is LibreCloud, 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 frequently write and work on a website hosted on a public Linux server, known as a \"tilde.\""
|
||||
],
|
||||
"whereYouAre": [
|
||||
"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."
|
||||
"I am not here to brag about my accomplishments or plug my cool SaaS product. 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, and inspire a new project or two.",
|
||||
"This page is currently hosted on Cloudflare Workers, after what happened with "
|
||||
],
|
||||
"sections": {
|
||||
"whoIAm": "Who I am",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
},
|
||||
"contact": {
|
||||
"title": "Send me a message",
|
||||
"description": "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.",
|
||||
"description": "Feel free to reach out for feedback, collaborations, or just a hello :)",
|
||||
"button": "Contact Me"
|
||||
},
|
||||
"donation": {
|
||||
|
|
@ -31,10 +31,10 @@
|
|||
"description": "Feeling generous? Support me or one of the causes I support!",
|
||||
"charities": {
|
||||
"title": "Charities",
|
||||
"description": "I support the following charities:",
|
||||
"unsilenced": "Unsilenced",
|
||||
"drugpolicy": "Drug Policy Alliance",
|
||||
"aclu": "ACLU",
|
||||
"epic-restart": "EPIC Restart Foundation"
|
||||
"aclu": "ACLU"
|
||||
},
|
||||
"donate": {
|
||||
"title": "Donate to Me",
|
||||
|
|
@ -67,10 +67,18 @@
|
|||
"If you need to get in touch with me, please send me a message on Telegram or an email. I will provide my actual phone number if you have a valid reason."
|
||||
]
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"github": "ihatenodejs",
|
||||
"telegram": "@p0ntu5",
|
||||
"x": "@ihatenodejs",
|
||||
"bluesky": "@aidxn.cc",
|
||||
"phone": "(802) 416-9516",
|
||||
"email": "aidan@p0ntus.com"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Get to Know Me",
|
||||
"title": "About Me",
|
||||
"description": "Hey there! My name is Aidan, and I'm a systems administrator, full-stack developer, and student from the United States. I primarily work with Linux, Docker, Next.js, and Node.js.",
|
||||
"sections": {
|
||||
"projects": "Projects",
|
||||
|
|
@ -80,37 +88,31 @@
|
|||
"featuredProjects": "Featured Projects"
|
||||
},
|
||||
"projects": [
|
||||
"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.",
|
||||
"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 p0ntus, a free service provider for privacy-focused individuals.",
|
||||
"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.",
|
||||
"Me and my developer friends operate an organization called ABOCN, where we primarily maintain a Telegram bot called Kowalski. You can find it on Telegram as @KowalskiNodeBot.",
|
||||
"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.",
|
||||
"I own a channel called PontusHub on Telegram, where I post updates about my projects, along with commentary and info about my projects related to the Android rooting community."
|
||||
"I have worked on countless projects over the past five years, for the most part. I have been learning to program in Python since I was seven and have evolved from there. I got into web development due to my uncle, who taught my how to write my first lines of HTML.",
|
||||
"Recently, I have been involved in developing several projects, especially with Node.js, my new favorite language as of a year ago. My biggest project is LibreCloud, a free service provider for individuals.",
|
||||
"In terms of system administration, I have developed my skills over the past three years of learning Linux for fun. I currently operate three servers running in the cloud, which run out of Germany and the United States."
|
||||
],
|
||||
"hobbies": [
|
||||
"When I'm not programming, I can typically be found distro hopping or flashing a new ROM to my phone. I also spend a lot of time spreading Next.js and TypeScript propaganda to JavaScript developers.",
|
||||
"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.",
|
||||
"I am frequently active on my Forgejo server 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!",
|
||||
"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.",
|
||||
"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 AfC reviewer, new page reviewer, and rollbacker. You can find me on Wikipedia as OnlyNano."
|
||||
"When I'm not programming, I can typically be found installing another Linux distro on my laptop or flashing a new ROM to my phone. I am also a passionate writer and I like to write creatively in my free time.",
|
||||
"I consider maintaining my technology as a hobby as well, as I devote a lot of time to it. I currently run Gentoo Linux on my Thinkpad T470s, which does not use a single bin package. I am very proud of this laptop, despite it's constant need for compiling updates.",
|
||||
"I am almost always active on my Gitea instance and GitHub and make daily contributions to several of my repositories. 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!",
|
||||
"My Google Pixel 7 Pro (cheetah) runs LineageOS 22.1, and has been one of my favorite additions to my life. It is proudly rooted with KernelSU-Next. It has suffered one drop to it's back on a tile floor."
|
||||
],
|
||||
"devices": {
|
||||
"Mobile Devices": [
|
||||
"I use a Google Pixel 9 Pro XL (komodo) as my daily driver. It runs Android 16 and is proudly rooted with KernelSU-Next.",
|
||||
"My previous phone, the Google Pixel 7 Pro (cheetah), is still in use as my secondary WiFi-only device. It runs Android 16 and is proudly rooted with KernelSU-Next.",
|
||||
"I also have a Google Pixel 3a XL (bonito) which I use as a tertiary device. It runs LineageOS 22.2 and is rooted with Magisk."
|
||||
"Phone": [
|
||||
"I use a Google Pixel 7 Pro (cheetah) as my daily driver. It runs LineageOS microG and is proudly rooted with KernelSU-Next.",
|
||||
"It's back is shattered and missing volume buttons, but it continues to thrive and survive as my daily driver."
|
||||
],
|
||||
"Laptops": [
|
||||
"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.",
|
||||
"I use a Lenovo Thinkpad T470s with macOS Sequoia (using OpenCore) as my \"side piece,\" if you will. I've had it for about a year now, and it's been a great experience.",
|
||||
"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 Xubuntu, and the Chromebook runs Arch Linux."
|
||||
"Laptop": [
|
||||
"I use a Lenovo Thinkpad T470s running Arch Linux. I've had it for about half a year now, and it's been a great experience. I proudly use X11 and LXDE, with some Xfce backend components to make management easier."
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
"Most of my repositories have migrated to p0ntus git. My username is aidan. You can find me on GitHub as ihatenodejs."
|
||||
"Most of my repositories have migrated to LibreCloud Git. My username is aidan.",
|
||||
"You can find me on GitHub as ihatenodejs."
|
||||
],
|
||||
"featuredProjects": [
|
||||
"Here's just four of my top projects. Star and fork counts are manually updated and count both Gitea and GitHub."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
45
server.ts
45
server.ts
|
|
@ -1,45 +0,0 @@
|
|||
import { createServer } from "node:http";
|
||||
import next from "next";
|
||||
import { Server } from "socket.io";
|
||||
import { NowPlayingService } from "./lib/now-playing-server";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const hostname = "localhost";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const handler = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const httpServer = createServer(handler);
|
||||
const io = new Server(httpServer);
|
||||
|
||||
const nowPlayingService = new NowPlayingService(io);
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("[WS] Client connected:", socket.id);
|
||||
|
||||
socket.on("requestNowPlaying", async () => {
|
||||
await nowPlayingService.fetchNowPlaying(socket.id);
|
||||
});
|
||||
|
||||
socket.on("startAutoRefresh", () => {
|
||||
const intervalId = setInterval(async () => {
|
||||
await nowPlayingService.fetchNowPlaying(socket.id);
|
||||
}, 30000);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
clearInterval(intervalId);
|
||||
console.log("[WS] Client disconnected:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("[WS] Client disconnected:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`✓ Ready on http://${hostname}:${port}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ const config: Config = {
|
|||
content: [
|
||||
"app/**/*.{ts,tsx}",
|
||||
"components/**/*.{ts,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"*.{js,ts,jsx,tsx,mdx}",
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type NumberLike = number | undefined | null;
|
||||
|
||||
interface ModelBreakdown {
|
||||
modelName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface DailyEntry {
|
||||
date: string; // YYYY-MM-DD
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
modelsUsed?: string[];
|
||||
modelBreakdowns?: ModelBreakdown[];
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
interface CcFile {
|
||||
daily: DailyEntry[];
|
||||
totals?: Totals;
|
||||
}
|
||||
|
||||
function toNumber(n: NumberLike): number {
|
||||
return typeof n === "number" && Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function computeTotals(daily: DailyEntry[]): Totals {
|
||||
const totals: Totals = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
};
|
||||
for (const d of daily) {
|
||||
totals.inputTokens += toNumber(d.inputTokens);
|
||||
totals.outputTokens += toNumber(d.outputTokens);
|
||||
totals.cacheCreationTokens += toNumber(d.cacheCreationTokens);
|
||||
totals.cacheReadTokens += toNumber(d.cacheReadTokens);
|
||||
totals.totalTokens += toNumber(d.totalTokens);
|
||||
totals.totalCost += toNumber(d.totalCost);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
function isReplacementBetter(a: DailyEntry, b: DailyEntry): boolean {
|
||||
const aTokens = toNumber(a.totalTokens);
|
||||
const bTokens = toNumber(b.totalTokens);
|
||||
if (bTokens !== aTokens) return bTokens > aTokens;
|
||||
const aCost = toNumber(a.totalCost);
|
||||
const bCost = toNumber(b.totalCost);
|
||||
if (bCost !== aCost) return bCost > aCost;
|
||||
|
||||
const aBreakdowns = a.modelBreakdowns?.length ?? 0;
|
||||
const bBreakdowns = b.modelBreakdowns?.length ?? 0;
|
||||
if (bBreakdowns !== aBreakdowns) return bBreakdowns > aBreakdowns;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readJson<T = unknown>(filePath: string): Promise<T> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function coerceTotals(t: unknown): Totals {
|
||||
const r = isObject(t) ? t : {};
|
||||
return {
|
||||
inputTokens: toNumber(r["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(r["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike),
|
||||
totalTokens: toNumber(r["totalTokens"] as NumberLike),
|
||||
totalCost: toNumber(r["totalCost"] as NumberLike),
|
||||
};
|
||||
}
|
||||
|
||||
function coerceDailyEntry(item: unknown): DailyEntry {
|
||||
const r = isObject(item) ? item : {};
|
||||
|
||||
const modelBreakdownsRaw = Array.isArray(r["modelBreakdowns"]) ? (r["modelBreakdowns"] as unknown[]) : [];
|
||||
const modelBreakdowns: ModelBreakdown[] = modelBreakdownsRaw.map((mb) => {
|
||||
const m = isObject(mb) ? mb : {};
|
||||
return {
|
||||
modelName: typeof m["modelName"] === "string" ? (m["modelName"] as string) : "",
|
||||
inputTokens: toNumber(m["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(m["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(m["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(m["cacheReadTokens"] as NumberLike),
|
||||
cost: toNumber(m["cost"] as NumberLike),
|
||||
};
|
||||
});
|
||||
|
||||
const modelsUsed = Array.isArray(r["modelsUsed"]) ? (r["modelsUsed"] as unknown[]).filter((x): x is string => typeof x === "string") : undefined;
|
||||
|
||||
return {
|
||||
date: String((r["date"] as unknown) ?? ""),
|
||||
inputTokens: toNumber(r["inputTokens"] as NumberLike),
|
||||
outputTokens: toNumber(r["outputTokens"] as NumberLike),
|
||||
cacheCreationTokens: toNumber(r["cacheCreationTokens"] as NumberLike),
|
||||
cacheReadTokens: toNumber(r["cacheReadTokens"] as NumberLike),
|
||||
totalTokens: toNumber(r["totalTokens"] as NumberLike),
|
||||
totalCost: toNumber(r["totalCost"] as NumberLike),
|
||||
modelsUsed,
|
||||
modelBreakdowns: modelBreakdowns.length ? modelBreakdowns : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCcShape(obj: unknown): CcFile {
|
||||
const o = isObject(obj) ? obj : {};
|
||||
const rawDaily = Array.isArray(o["daily"]) ? (o["daily"] as unknown[]) : [];
|
||||
const daily = rawDaily.map(coerceDailyEntry);
|
||||
const totals = isObject(o["totals"]) ? coerceTotals(o["totals"]) : undefined;
|
||||
return { daily, totals };
|
||||
}
|
||||
|
||||
function sortByDateAsc(entries: DailyEntry[]): DailyEntry[] {
|
||||
return entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
||||
console.log(`Usage: tsx tools/ccombine.ts <new-cc.json> [--base public/data/cc.json] [--out <out.json>] [--dry]`);
|
||||
process.exit(args.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
let inputPath = "";
|
||||
let basePath = path.join(process.cwd(), "public", "data", "cc.json");
|
||||
let outPath: string | undefined;
|
||||
let dryRun = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--base") {
|
||||
basePath = path.resolve(args[++i]);
|
||||
} else if (a === "--out") {
|
||||
outPath = path.resolve(args[++i]);
|
||||
} else if (a === "--dry" || a === "--dry-run") {
|
||||
dryRun = true;
|
||||
} else if (!a.startsWith("-")) {
|
||||
inputPath = path.resolve(a);
|
||||
} else {
|
||||
console.error(`Unknown option: ${a}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inputPath) {
|
||||
console.error("Error: missing <new-cc.json> input path");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!outPath) outPath = basePath;
|
||||
|
||||
if (!(await fileExists(inputPath))) {
|
||||
console.error(`Error: input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseExists = await fileExists(basePath);
|
||||
const baseCc = baseExists ? normalizeCcShape(await readJson(basePath)) : { daily: [] };
|
||||
const newCc = normalizeCcShape(await readJson(inputPath));
|
||||
|
||||
const baseByDate = new Map<string, DailyEntry>();
|
||||
for (const d of baseCc.daily) baseByDate.set(d.date, d);
|
||||
|
||||
const added: string[] = [];
|
||||
const replaced: string[] = [];
|
||||
const unchanged: string[] = [];
|
||||
|
||||
for (const incoming of newCc.daily) {
|
||||
const existing = baseByDate.get(incoming.date);
|
||||
if (!existing) {
|
||||
baseByDate.set(incoming.date, incoming);
|
||||
added.push(incoming.date);
|
||||
continue;
|
||||
}
|
||||
if (isReplacementBetter(existing, incoming)) {
|
||||
baseByDate.set(incoming.date, incoming);
|
||||
replaced.push(incoming.date);
|
||||
} else {
|
||||
unchanged.push(incoming.date);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedDaily = sortByDateAsc(Array.from(baseByDate.values()));
|
||||
const totals = computeTotals(mergedDaily);
|
||||
const merged: CcFile = { daily: mergedDaily, totals };
|
||||
|
||||
if (dryRun) {
|
||||
console.log("[ccombine] Dry run. No files written.");
|
||||
} else {
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
const outDisplay = dryRun ? "(dry run)" : outPath;
|
||||
console.log("[ccombine] Output:", outDisplay);
|
||||
console.log(`[ccombine] Added: ${added.length} | Replaced: ${replaced.length} | Unchanged (overlap): ${unchanged.length}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[ccombine] Error:", err?.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue