diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..cf3ac00 --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/.gitea/workflows/push.yml b/.gitea/workflows/push.yml new file mode 100644 index 0000000..0ff437f --- /dev/null +++ b/.gitea/workflows/push.yml @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 922363e..61d6aa7 100644 --- a/Dockerfile +++ b/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"] \ No newline at end of file +CMD ["node", "build/server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 29ae9d3..db7bed3 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,51 @@ # aidxnCC [![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) +[![Build Status](https://git.pontusmail.org/aidan/aidxnCC/actions/workflows/push.yml/badge.svg)](https://git.pontusmail.org/aidan/aidxnCC/actions/?workflow=push.yml) +[![ESLint Status](https://git.pontusmail.org/aidan/aidxnCC/actions/workflows/lint.yml/badge.svg)](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 +[![Deploy with Vercel](https://vercel.com/button)](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. diff --git a/app/about/page.tsx b/app/about/page.tsx index 476d8e7..f0b4fed 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -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 (
-
-
-
- -
-

{t('about.title')}

+
+
+
+

+ {t('about.title')} +

{mainStrings.map((section, index) => { if (mainSections[index] === t('about.sections.featuredProjects')) { return ( -
+

{mainSections[index]}

{section.map((text, index) => (

@@ -57,19 +56,19 @@ export default function About() { ) } else if (mainSections[index] === t('about.sections.contributions')) { return ( -

+

{mainSections[index]}

{section.map((text, index) => (

- {text.split(/(ihatenodejs|p0ntus git|aidan)/).map((part, i) => { + {text.split(/(ihatenodejs|LibreCloud Git|aidan)/).map((part, i) => { if (part === 'ihatenodejs') { - return ihatenodejs + return GitHub } - if (part === 'p0ntus git') { - return p0ntus git + if (part === 'LibreCloud Git') { + return LibreCloud Git } if (part === 'aidan') { - return aidan + return aidan } return part })} @@ -77,19 +76,18 @@ export default function About() { ))} {!imageError && (

- ihatenodejs's Stats setImageError(true)} loading="eager" priority unoptimized - className="max-w-full h-auto" /> - ihatenodejs's Top Languages
)} @@ -105,54 +102,31 @@ export default function About() { ) } else if (mainSections[index] === t('about.sections.devices')) { return ( -
+

{mainSections[index]}

{Object.entries(section).map(([key, value], index) => (
-

{key}

+

{key}

{(value as unknown as string[]).map((text: string, index: number) => (

- {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 KernelSU-Next } - if (part === 'LineageOS 22.2') { - return LineageOS 22.2 - } - if (part === 'Android 16') { - return Android 16 - } - if (part === 'OpenCore') { - return OpenCore - } - if (part === 'Xubuntu') { - return Xubuntu + if (part === 'LineageOS microG') { + return LineageOS microG } return part })}

))} - {key === "Mobile Devices" && ( -
- - - -
+ {key === "Phone" && ( +
))} @@ -160,22 +134,16 @@ export default function About() { ) } else if (mainSections[index] === t('about.sections.hobbies')) { return ( -
+

{mainSections[index]}

{section.map((text, index) => (

- {text.split(/(my Forgejo server|my phone|AfC|OnlyNano)/).map((part, i) => { - if (part === 'my Forgejo server') { - return my Forgejo server + {text.split(/(my Gitea instance|my phone)/).map((part, i) => { + if (part === 'my Gitea instance') { + return my Gitea instance } if (part === 'my phone') { - return my phone - } - if (part === 'AfC') { - return AfC - } - if (part === 'OnlyNano') { - return OnlyNano + return my phone } return part })} @@ -185,25 +153,13 @@ export default function About() { ) } else if (mainSections[index] === t('about.sections.projects')) { return ( -

+

{mainSections[index]}

{section.map((text, index) => (

- {text.split(/(p0ntus|PontusHub|ABOCN|Kowalski|@KowalskiNodeBot)/).map((part, i) => { - if (part === 'p0ntus') { - return p0ntus - } - if (part === 'PontusHub') { - return PontusHub - } - if (part === 'ABOCN') { - return ABOCN - } - if (part === 'Kowalski') { - return Kowalski - } - if (part === '@KowalskiNodeBot') { - return @KowalskiNodeBot + {text.split(/(LibreCloud)/).map((part, i) => { + if (part === 'LibreCloud') { + return LibreCloud } return part })} @@ -213,7 +169,7 @@ export default function About() { ) } else { return ( -

+

{mainSections[index]}

{section.map((text, index) => (

diff --git a/app/ai/claude/components/Activity.tsx b/app/ai/claude/components/Activity.tsx deleted file mode 100644 index 1838500..0000000 --- a/app/ai/claude/components/Activity.tsx +++ /dev/null @@ -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 ( -

-
-

Activity

-
- {viewMode === 'heatmap' ? 'Heatmap' : 'Chart'} - -
-
- {viewMode === 'heatmap' ? ( -
-
-
-
-
- {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( -
- {day} -
- ))} -
-
-
- {(() => { - 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 ( -
- {monthLabels.map((label, idx) => ( -
- {label.month} -
- ))} -
- ) - })()} -
-
- {heatmapWeeks.map((week, weekIndex) => ( -
- {week.map((day, dayIndex) => ( -
-
- {day && ( -
-
-

{day.formattedDate}

-

Cost: ${day.cost.toFixed(2)}

-

Tokens: {(day.tokens / 1000000).toFixed(2)}M

-
-
- )} -
- ))} -
- ))} -
-
-
-
- Less -
-
-
-
-
-
-
- More -
-
-
- ) : ( - <> -
- - -
- - - - - - selectedMetric === 'cost' ? formatCurrency(value) : formatTokens(value)} - /> - - - - - )} -
- ) -} - diff --git a/app/ai/claude/components/LoadingSkeleton.tsx b/app/ai/claude/components/LoadingSkeleton.tsx deleted file mode 100644 index 5b5bc01..0000000 --- a/app/ai/claude/components/LoadingSkeleton.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client" - -import PageHeader from './PageHeader' - -export default function LoadingSkeleton() { - return ( -
- - -
-
-

Total Cost

-
-
-
-

Total Tokens

-
-
-
-

Days Active

-
-
-
-
-
-
-

Avg Daily Cost

-
-
-
- -
-
-
-

Activity

-
- Heatmap -
-
-
-
-
-
-
-
- {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => ( -
- {day} -
- ))} -
-
-
-
- {['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov'].map((month) => ( -
- ))} -
-
-
- {(() => { - 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) => ( -
- {[...Array(7)].map((_, dayIndex) => ( -
- ))} -
- )) - })()} -
-
-
-
- Less -
- {[...Array(5)].map((_, i) => ( -
- ))} -
- More -
-
-
-
-
- -
-
-

Model Usage Distribution

-
-
-
- {[...Array(3)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
-
- Total Models Used -
-
-
- Most Used -
-
-
-
-
-
-
-

Token Type Breakdown

-
-
-
-

Token Composition

-
-
-
- -
-
-

Recent Sessions

-
- - - - - - - - - - - {[...Array(5)].map((_, index) => ( - - - - - - - ))} - -
DateModels UsedTotal TokensCost
-
-
-
-
-
-
-
-
-
-
-
-
- ) -} - diff --git a/app/ai/claude/components/ModelUsageCard.tsx b/app/ai/claude/components/ModelUsageCard.tsx deleted file mode 100644 index 92518d4..0000000 --- a/app/ai/claude/components/ModelUsageCard.tsx +++ /dev/null @@ -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 ( -
-

Model Usage Distribution

-
- - - - {modelUsageData.map((_entry, index) => ( - - ))} - - formatCurrency(value)} - labelStyle={{ color: '#fff' }} - itemStyle={{ color: '#fff' }} - /> - - -
- {modelUsageData.map((model, index) => { - const percentage = ((model.value / Math.max(totalCost, 1)) * 100).toFixed(1) - return ( -
-
-
- {model.name} -
-
- {percentage}% - ${model.value.toFixed(2)} -
-
- ) - })} -
-
- Total Models Used - {modelUsageData.length} -
-
- Most Used - - {modelUsageData[0]?.name} - -
-
-
-
-
- ) -} - diff --git a/app/ai/claude/components/PageHeader.tsx b/app/ai/claude/components/PageHeader.tsx deleted file mode 100644 index 8b94c94..0000000 --- a/app/ai/claude/components/PageHeader.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client" - -import Link from 'next/link' -import { SiClaude } from 'react-icons/si' - -export default function PageHeader() { - return ( - <> - - ← Back to AI - - -
-
- -
-

Claude Code Usage

-

How much I use Claude Code!

-
- - ) -} - diff --git a/app/ai/claude/components/RecentSessions.tsx b/app/ai/claude/components/RecentSessions.tsx deleted file mode 100644 index cc0d991..0000000 --- a/app/ai/claude/components/RecentSessions.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import { DailyData } from './types' -import { getModelLabel } from './utils' - -export default function RecentSessions({ daily }: { daily: DailyData[] }) { - return ( -
-

Recent Sessions

-
- - - - - - - - - - - {daily.slice(-5).reverse().map((day, index) => ( - - - - - - - ))} - -
DateModels UsedTotal TokensCost
{new Date(day.date + 'T00:00:00').toLocaleDateString()} - {day.modelsUsed.map(getModelLabel).join(', ')} - {(day.totalTokens / 1000000).toFixed(2)}M${day.totalCost.toFixed(2)}
-
-
- ) -} - diff --git a/app/ai/claude/components/StatsGrid.tsx b/app/ai/claude/components/StatsGrid.tsx deleted file mode 100644 index 2ff4298..0000000 --- a/app/ai/claude/components/StatsGrid.tsx +++ /dev/null @@ -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 ( -
-
-

Total Cost

-

${totals.totalCost.toFixed(2)}

-
-
-

Total Tokens

-

{(totals.totalTokens / 1000000).toFixed(1)}M

-
-
-

Days Active

-

- {daily.length} - - 🔥 {formatStreakCompact(streak)} - -

-
-
-

Avg Daily Cost

-

${(totals.totalCost / Math.max(daily.length, 1)).toFixed(2)}

-
-
- ) -} - diff --git a/app/ai/claude/components/TokenComposition.tsx b/app/ai/claude/components/TokenComposition.tsx deleted file mode 100644 index d3569f0..0000000 --- a/app/ai/claude/components/TokenComposition.tsx +++ /dev/null @@ -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 ( -
-

Token Composition

- - - - - `${value}K`} /> - `${value.toFixed(1)}K tokens`} - /> - - - - - - -
- ) -} - diff --git a/app/ai/claude/components/TokenTypeBreakdown.tsx b/app/ai/claude/components/TokenTypeBreakdown.tsx deleted file mode 100644 index c8625d8..0000000 --- a/app/ai/claude/components/TokenTypeBreakdown.tsx +++ /dev/null @@ -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 ( -
-

Token Type Breakdown

- - - - - `${(value / 1000000).toFixed(0)}M`} /> - `${(value / 1000000).toFixed(2)}M tokens`} - /> - - - -
- ) -} - diff --git a/app/ai/claude/components/types.ts b/app/ai/claude/components/types.ts deleted file mode 100644 index 7000dca..0000000 --- a/app/ai/claude/components/types.ts +++ /dev/null @@ -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 -} - diff --git a/app/ai/claude/components/utils.ts b/app/ai/claude/components/utils.ts deleted file mode 100644 index d8d706e..0000000 --- a/app/ai/claude/components/utils.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { CCData, DailyData, HeatmapDay } from './types' - -export const COLORS = ['#c15f3c', '#b1ada1', '#f4f3ee', '#c15f3c', '#b1ada1', '#f4f3ee'] - -export const MODEL_LABELS: Record = { - '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( - 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() - 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 }, -]) - diff --git a/app/ai/claude/page.tsx b/app/ai/claude/page.tsx deleted file mode 100644 index db934a8..0000000 --- a/app/ai/claude/page.tsx +++ /dev/null @@ -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(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(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 ( -
-
- -
-
- ) - } - - if (error || !data) { - return ( -
-
-
-
Error loading data: {error}
-
-
-
- ) - } - - return ( -
-
-
- - - - -
- -
- -
- - - -
- -
- -
-
-
-
- ) -} - diff --git a/app/ai/components/AIStack.tsx b/app/ai/components/AIStack.tsx deleted file mode 100644 index 2abaf6b..0000000 --- a/app/ai/components/AIStack.tsx +++ /dev/null @@ -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 ( -
-
-

- - My AI Stack -

-

The AI tools I use as a part of my routine and workflow.

-
-
- {tools.map((tool, index) => ( -
-
-
- {tool.icon && } - {tool.svg && ( -
- {tool.svg} -
- )} -
-
-

{tool.name}

- {tool.price !== undefined && ( -
- {tool.discountedPrice !== undefined ? ( - <> - - {formatPrice(tool.price)} - - - {formatPrice(tool.discountedPrice)} - - - ) : ( - - {formatPrice(tool.price)} - - )} -
- )} -
-

{tool.description}

-
-
-
-
- - {getStatusLabel(tool.status)} - - - {tool.link && ( - - Visit → - - )} - {tool.usage && ( - - Usage → - - )} - -
-
- ))} -
-
- ) -} diff --git a/app/ai/components/FavoriteModels.tsx b/app/ai/components/FavoriteModels.tsx deleted file mode 100644 index f5b812f..0000000 --- a/app/ai/components/FavoriteModels.tsx +++ /dev/null @@ -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 ( -
-
-

- - Favorite Models -

-

Based on personal preference

-
-
- {models.map((model, index) => ( -
-
-
-

{model.name}

-

{model.provider}

-
-
- {[...Array(5)].map((_, i) => ( - - ))} -
-
-

{model.review}

-
- ))} -
-
- ) -} diff --git a/app/ai/components/FavoriteTools.tsx b/app/ai/components/FavoriteTools.tsx deleted file mode 100644 index d04fe03..0000000 --- a/app/ai/components/FavoriteTools.tsx +++ /dev/null @@ -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 ( -
-
-

- - Favorite Tools -

-

Based on personal preference

-
-
- {reviews.map((review, index) => ( -
-
-

{review.tool}

-
- {[...Array(5)].map((_, i) => ( - - ))} -
-
-
-
-

Pros:

-
    - {review.pros.map((pro, i) => ( -
  • • {pro}
  • - ))} -
-
-
-

Cons:

-
    - {review.cons.map((con, i) => ( -
  • • {con}
  • - ))} -
-
-
-

{review.verdict}

-
- ))} -
-
- ) -} diff --git a/app/ai/components/TopPick.tsx b/app/ai/components/TopPick.tsx deleted file mode 100644 index b58daa7..0000000 --- a/app/ai/components/TopPick.tsx +++ /dev/null @@ -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 ( -
-

- - Top Pick of 2025 -

-
-
-
- -
-

Claude

-

by Anthropic

-
- - Visit - - - My Usage - -
-
-
-
-

- 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. -

-
-
- Top-Tier Tool Calling - High-Value Plans - Good Speed -
-
-
-
-
-
- ) -} diff --git a/app/ai/data.tsx b/app/ai/data.tsx deleted file mode 100644 index 3c457ca..0000000 --- a/app/ai/data.tsx +++ /dev/null @@ -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 */ - - Z.ai - - - ), - 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: ( - - - - - - - - - - - - - - - - ), - 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: ( - - - - - - - - - - - - - ), - 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: v0, - 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" - }, -] diff --git a/app/ai/page.tsx b/app/ai/page.tsx deleted file mode 100644 index ef9a604..0000000 --- a/app/ai/page.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
- -
-

AI

-

My journey with using LLMs

-
- - - -
- -
- -
- - -
-
-
-
- ) -} \ No newline at end of file diff --git a/app/ai/types.ts b/app/ai/types.ts deleted file mode 100644 index 5bf7aae..0000000 --- a/app/ai/types.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/app/contact/page.tsx b/app/contact/page.tsx index ee209ba..0edfd2f 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -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 = [ - , - , - , - , - - ]; + const contactButtonIcons = [faGithub, faTelegram, faBluesky, faXTwitter, faPhone, faEnvelope]; return (
-
-
- -
-

- {t('contact.title')} -

+
+
-
-
- {contactButtonLabels.map((label, index) => ( - - ))} -
- {sections.map((section, sectionIndex) => ( -
-

{section.title}

- {section.texts.map((text, index) => ( -

{text}

- ))} -
+

+ {t('contact.title')} +

+
+ {contactButtonLabels.map((label, index) => ( + ))}
+ + {sections.map((section, sectionIndex) => ( +
+

{section.title}

+ {section.texts.map((text, index) => ( +

{text}

+ ))} +
+ ))}