diff --git a/app/api/wakatime-stats/route.ts b/app/api/wakatime-stats/route.ts new file mode 100644 index 0000000..a19505f --- /dev/null +++ b/app/api/wakatime-stats/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server' + +type Language = { + name: string + hours: string +} + +export async function GET(request: NextRequest) { + const url = request.nextUrl + const username = url.searchParams.get('username') + + if (!username) { + return new Response(JSON.stringify({ error: 'Username is required' }), { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + try { + const response = await fetch(`https://wakatime.com/api/v1/users/${username}/stats/all_time`) + + if (!response.ok) { + console.log(response.status) + throw new Error('Failed to fetch data from WakaTime API') + } + + const data = await response.json() + const topLanguages: Language[] = data.data.languages.map((lang: any) => ({ + name: lang.name, + hours: (lang.total_seconds / 3600).toFixed(2), + })) + + return new Response(JSON.stringify(topLanguages), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }) + } catch (error) { + console.error('WakaTime API error:', error) + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + }) + } +} diff --git a/app/globals.css b/app/globals.css index e18918f..ac03b59 100644 --- a/app/globals.css +++ b/app/globals.css @@ -36,6 +36,14 @@ --ring: 34 54% 45%; --radius: 0.5rem; + + --chart-1: 34 54% 81%; + --chart-2: 34 34% 73%; + --chart-3: 35 22% 65%; + --chart-4: 35 16% 57%; + --chart-5: 35 12% 41%; + --chart-6: 33 12% 33%; + --chart-7: 32 12% 25%; } .dark { @@ -71,6 +79,14 @@ --ring: 34 54% 81%; --radius: 0.5rem; + + --chart-1: 34 54% 81%; + --chart-2: 34 34% 73%; + --chart-3: 35 22% 65%; + --chart-4: 35 16% 57%; + --chart-5: 35 12% 41%; + --chart-6: 33 12% 33%; + --chart-7: 32 12% 25%; } * { @@ -241,10 +257,6 @@ background-image: url('/static/images/bento/github.webp'); } - .grid-item-j { - background-image: url('/static/images/bento/technologies.webp'); - } - .grid-item-k-overlay { background-image: url('/static/images/bento/twitter.webp'); } diff --git a/components/bento/BentoBox.tsx b/components/bento/BentoBox.tsx index c7dffd1..e7c6f24 100644 --- a/components/bento/BentoBox.tsx +++ b/components/bento/BentoBox.tsx @@ -10,6 +10,7 @@ import ExternalLink from './ExternalLink' import GithubCalendar from './GithubCalendar' import SpotifyPresence from './SpotifyPresence' import { Skeleton } from '../shadcn/skeleton' +import WakatimeGraph from './WakatimeGraph' const BentoBox = ({ posts }) => { const lanyard = useLanyard({ @@ -105,11 +106,13 @@ const BentoBox = ({ posts }) => { /> -
-

- Technologies used (in order of comfort): NextJS, Tailwind CSS, React, Hexo, - TypeScript, Unity, C#, Python, Svelte, Astro, JavaScript, Vercel -

+
+ +
diff --git a/components/bento/ExternalLink.tsx b/components/bento/ExternalLink.tsx index c0c217f..151b8c4 100644 --- a/components/bento/ExternalLink.tsx +++ b/components/bento/ExternalLink.tsx @@ -13,13 +13,13 @@ const ExternalLink = ({ }: ExternalLinkProps & AnchorHTMLAttributes) => { return newTab ? ( -
+
) : ( -
+
diff --git a/components/bento/WakatimeGraph.tsx b/components/bento/WakatimeGraph.tsx new file mode 100644 index 0000000..5764f3d --- /dev/null +++ b/components/bento/WakatimeGraph.tsx @@ -0,0 +1,149 @@ +'use client' + +import React, { useState, useEffect, useMemo, useCallback } from 'react' +import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from 'recharts' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/shadcn/chart' +import { Skeleton } from '@/components/shadcn/skeleton' +import { getLanguageIcon } from '@/scripts/utils/language-icons' + +interface Language { + name: string + hours: number + fill: string +} + +interface Props { + username: string + omitLanguages?: string[] +} + +const colors = [ + 'hsl(var(--chart-1))', + 'hsl(var(--chart-2))', + 'hsl(var(--chart-3))', + 'hsl(var(--chart-4))', + 'hsl(var(--chart-5))', + 'hsl(var(--chart-6))', + 'hsl(var(--chart-7))', +] + +const chartConfig: ChartConfig = { + hours: { + label: 'Hours', + color: 'hsl(var(--primary))', + }, + label: { + color: 'hsl(var(--muted-foreground))', + }, +} + +colors.forEach((color, index) => { + chartConfig[`language${index}`] = { + label: `Language ${index + 1}`, + color: color, + } +}) + +const WakatimeGraph = ({ username, omitLanguages = [] }: Props) => { + const [languages, setLanguages] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchData = useCallback(async () => { + try { + const response = await fetch(`/api/wakatime-stats?username=${username}`) + if (!response.ok) { + throw new Error('Failed to fetch data') + } + const data = await response.json() + + const filteredLanguages = data + .filter((lang) => !omitLanguages.includes(lang.name)) + .slice(0, 7) + .map((lang, index) => ({ + name: lang.name, + hours: parseFloat(lang.hours), + fill: colors[index % colors.length], + })) + + setLanguages(filteredLanguages) + setIsLoading(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred') + setIsLoading(false) + } + }, [username, omitLanguages]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const CustomYAxisTick = useCallback((props: any) => { + const { x, y, payload } = props + const icon = getLanguageIcon(payload.value.toLowerCase()) + return ( + + {payload.value} + + + {icon ? ( + React.cloneElement(icon, { + size: 16, + color: '#E9D3B6', + }) + ) : ( + + {payload.value.charAt(0).toUpperCase()} + + )} + + + ) + }, []) + + const memoizedLanguages = useMemo(() => languages, [languages]) + + if (isLoading) return + if (error) return
Error: {error}
+ + return ( + + + + } + /> + + } /> + + `${value.toFixed(1)}h`} + className="fill-foreground" + fontSize={12} + /> + + + + ) +} + +export default React.memo(WakatimeGraph) diff --git a/components/shadcn/chart.tsx b/components/shadcn/chart.tsx new file mode 100644 index 0000000..1d22a42 --- /dev/null +++ b/components/shadcn/chart.tsx @@ -0,0 +1,334 @@ +'use client' + +import * as React from 'react' +import * as RechartsPrimitive from 'recharts' + +import { cn } from '@/scripts/utils/tailwind-helpers' + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error('useChart must be used within a ') + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + config: ChartConfig + children: React.ComponentProps['children'] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = 'Chart' + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( +