mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Add wakatime stats; begin work on blog tags
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
# astro
|
||||
.astro/
|
||||
|
||||
|
||||
@@ -8,19 +8,21 @@
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/react": "^4.1.6",
|
||||
"@astrojs/react": "^4.2.0",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"astro": "^5.1.9",
|
||||
"astro": "^5.2.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.0.7",
|
||||
"@astrojs/mdx": "^4.0.8",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@react-hook/intersection-observer": "^3.1.2",
|
||||
"@react-three/drei": "^9.121.4",
|
||||
"@react-three/fiber": "^8.17.14",
|
||||
"lucide-react": "^0.468.0",
|
||||
"marked": "^15.0.6",
|
||||
"react": "^18.3.1",
|
||||
@@ -30,6 +32,7 @@
|
||||
"rehype-pretty-code": "^0.14.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"schema-dts": "^1.1.2",
|
||||
"three": "^0.172.0",
|
||||
"typewriter-effect": "^2.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
1086
src/pnpm-lock.yaml
generated
1086
src/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
150
src/src/components/about/stats-activity.tsx
Normal file
150
src/src/components/about/stats-activity.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export const ActivityGrid = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/wakatime');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch data');
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result.data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Get intensity based on coding hours (0-4 for different shades)
|
||||
const getIntensity = (hours) => {
|
||||
if (hours === 0) return 0;
|
||||
if (hours < 2) return 1;
|
||||
if (hours < 4) return 2;
|
||||
if (hours < 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
// Get color class based on intensity
|
||||
const getColorClass = (intensity) => {
|
||||
if (intensity === 0) return 'bg-foreground/5';
|
||||
if (intensity === 1) return 'bg-green-DEFAULT/30';
|
||||
if (intensity === 2) return 'bg-green-DEFAULT/60';
|
||||
if (intensity === 3) return 'bg-green-DEFAULT/80';
|
||||
return 'bg-green-bright';
|
||||
};
|
||||
|
||||
// Group data by week
|
||||
const weeks = [];
|
||||
let currentWeek = [];
|
||||
|
||||
if (data.length > 0) {
|
||||
data.forEach((day, index) => {
|
||||
currentWeek.push(day);
|
||||
if (currentWeek.length === 7 || index === data.length - 1) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6">
|
||||
<div className="text-lg text-aqua-bright mb-6">Loading activity data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6">
|
||||
<div className="text-lg text-red-bright mb-6">Error loading activity: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
|
||||
<div className="text-lg text-aqua-bright mb-6">Activity</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* Days labels */}
|
||||
<div className="flex flex-col gap-2 pt-6 text-xs">
|
||||
{days.map((day, i) => (
|
||||
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ''}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grid */}
|
||||
<div className="flex-grow overflow-x-auto">
|
||||
<div className="flex gap-2">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-2">
|
||||
{week.map((day, dayIndex) => {
|
||||
const hours = day.grand_total.total_seconds / 3600;
|
||||
const intensity = getIntensity(hours);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
|
||||
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
|
||||
group relative`}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
|
||||
bg-background border border-foreground/10 rounded-md opacity-0
|
||||
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
|
||||
{hours.toFixed(1)} hours on {day.date}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Months labels */}
|
||||
<div className="flex text-xs text-foreground/60 mt-2">
|
||||
{weeks.map((week, i) => {
|
||||
const date = new Date(week[0].date);
|
||||
const isFirstOfMonth = date.getDate() <= 7;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-3 mx-1"
|
||||
style={{ marginLeft: i === 0 ? '0' : undefined }}
|
||||
>
|
||||
{isFirstOfMonth && months[date.getMonth()]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
|
||||
<span>Less</span>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<div
|
||||
key={intensity}
|
||||
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityGrid;
|
||||
213
src/src/components/about/stats-alltime.tsx
Normal file
213
src/src/components/about/stats-alltime.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const Stats = () => {
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [count, setCount] = useState(0);
|
||||
const [isFinished, setIsFinished] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wakatime/alltime");
|
||||
const data = await res.json();
|
||||
setStats(data.data);
|
||||
startCounting(data.data.total_seconds);
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const startCounting = (totalSeconds: number) => {
|
||||
const duration = 2000;
|
||||
const steps = 60;
|
||||
let currentStep = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
currentStep += 1;
|
||||
|
||||
if (currentStep >= steps) {
|
||||
setCount(totalSeconds);
|
||||
setIsFinished(true);
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
|
||||
setCount(Math.floor(totalSeconds * progress));
|
||||
}, duration / steps);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
};
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const hours = Math.floor(count / 3600);
|
||||
const formattedHours = hours.toLocaleString("en-US", {
|
||||
minimumIntegerDigits: 4,
|
||||
useGrouping: true
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
|
||||
<div className={`
|
||||
text-2xl opacity-0
|
||||
${isVisible ? "animate-fade-in-first" : ""}
|
||||
`}>
|
||||
I've spent
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-8xl text-center relative z-10">
|
||||
<span className="font-bold relative">
|
||||
<span className={`
|
||||
bg-gradient-text opacity-0
|
||||
${isVisible ? "animate-fade-in-second" : ""}
|
||||
`}>
|
||||
{formattedHours}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`
|
||||
text-4xl opacity-0
|
||||
${isVisible ? "animate-slide-in-hours" : ""}
|
||||
`}>
|
||||
hours
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className={`
|
||||
text-xl opacity-0
|
||||
${isVisible ? "animate-fade-in-third" : ""}
|
||||
`}>
|
||||
writing code & building apps
|
||||
</div>
|
||||
|
||||
<div className={`
|
||||
flex items-center gap-3 text-lg opacity-0
|
||||
${isVisible ? "animate-fade-in-fourth" : ""}
|
||||
`}>
|
||||
<span>since</span>
|
||||
<span className="text-green-bright font-bold">{stats.range.start_text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.bg-gradient-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#fbbf24,
|
||||
#f59e0b,
|
||||
#d97706,
|
||||
#b45309,
|
||||
#f59e0b,
|
||||
#fbbf24
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
@keyframes fadeInFirst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInSecond {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInHours {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
margin-left: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translateX(0);
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInThird {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInFourth {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-first {
|
||||
animation: fadeInFirst 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-second {
|
||||
animation: fadeInSecond 0.7s ease-out forwards;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.animate-slide-in-hours {
|
||||
animation: slideInHours 0.7s ease-out forwards;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.animate-fade-in-third {
|
||||
animation: fadeInThird 0.7s ease-out forwards;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.animate-fade-in-fourth {
|
||||
animation: fadeInFourth 0.7s ease-out forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
175
src/src/components/about/stats-detailed.tsx
Normal file
175
src/src/components/about/stats-detailed.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
|
||||
|
||||
import { ActivityGrid } from "@/components/about/stats-activity";
|
||||
|
||||
const DetailedStats = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [activity, setActivity] = useState(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/wakatime/detailed")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setStats(data.data);
|
||||
setIsVisible(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching stats:", error);
|
||||
});
|
||||
|
||||
fetch("/api/wakatime/activity")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setActivity(data.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching activity:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const progressColors = [
|
||||
"bg-red-bright",
|
||||
"bg-orange-bright",
|
||||
"bg-yellow-bright",
|
||||
"bg-green-bright",
|
||||
"bg-blue-bright",
|
||||
"bg-purple-bright",
|
||||
"bg-aqua-bright"
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright">
|
||||
Weekly Statistics
|
||||
</h2>
|
||||
|
||||
{/* Top Stats Grid */}
|
||||
<div className={`
|
||||
grid grid-cols-1 md:grid-cols-2 gap-8
|
||||
transition-all duration-700 transform
|
||||
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
|
||||
`}>
|
||||
{/* Total Time */}
|
||||
<StatsCard
|
||||
title="Total Time"
|
||||
value={`${Math.round(stats.total_seconds / 3600 * 10) / 10}`}
|
||||
unit="hours"
|
||||
subtitle="this week"
|
||||
color="text-yellow-bright"
|
||||
icon={Clock}
|
||||
iconColor="stroke-yellow-bright"
|
||||
/>
|
||||
|
||||
{/* Daily Average */}
|
||||
<StatsCard
|
||||
title="Daily Average"
|
||||
value={`${Math.round(stats.daily_average / 3600 * 10) / 10}`}
|
||||
unit="hours"
|
||||
subtitle="per day"
|
||||
color="text-orange-bright"
|
||||
icon={CalendarClock}
|
||||
iconColor="stroke-orange-bright"
|
||||
/>
|
||||
|
||||
{/* Editors */}
|
||||
<StatsCard
|
||||
title="Primary Editor"
|
||||
value={stats.editors?.[0]?.name || "None"}
|
||||
unit={`${Math.round(stats.editors?.[0]?.percent || 0)}%`}
|
||||
subtitle="of the time"
|
||||
color="text-blue-bright"
|
||||
icon={CodeXml}
|
||||
iconColor="stroke-blue-bright"
|
||||
/>
|
||||
|
||||
{/* OS */}
|
||||
<StatsCard
|
||||
title="Operating System"
|
||||
value={stats.operating_systems?.[0]?.name || "None"}
|
||||
unit={`${Math.round(stats.operating_systems?.[0]?.percent || 0)}%`}
|
||||
subtitle="of the time"
|
||||
color="text-green-bright"
|
||||
icon={Computer}
|
||||
iconColor="stroke-green-bright"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
<div className={`
|
||||
transition-all duration-700 delay-200 transform
|
||||
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
|
||||
`}>
|
||||
<DetailCard
|
||||
title="Languages"
|
||||
items={stats.languages?.slice(0, 7).map((lang, index) => ({
|
||||
name: lang.name,
|
||||
value: Math.round(lang.percent) + '%',
|
||||
time: Math.round(lang.total_seconds / 3600 * 10) / 10 + ' hrs',
|
||||
color: progressColors[index % progressColors.length]
|
||||
})) || []}
|
||||
titleColor="text-purple-bright"
|
||||
/>
|
||||
|
||||
{/* Activity Grid */}
|
||||
{activity && (
|
||||
<div className={`
|
||||
transition-all duration-700 delay-300 transform
|
||||
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}
|
||||
`}>
|
||||
<ActivityGrid data={activity} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatsCard = ({ title, value, unit, subtitle, color, icon: Icon, iconColor }) => (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors flex items-center justify-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<Icon className={`w-6 h-6 ${iconColor}`} strokeWidth={1.5} />
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`${color} text-lg mb-1`}>{title}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className="text-lg opacity-80">{unit}</div>
|
||||
</div>
|
||||
<div className="text-sm opacity-60 mt-1">{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetailCard = ({ title, items, titleColor }) => (
|
||||
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-yellow transition-colors">
|
||||
<div className={`${titleColor} mb-6 text-lg`}>{title}</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.name} className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-base font-medium">{item.name}</span>
|
||||
<span className="text-base opacity-80">{item.value}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex-grow h-2 bg-foreground/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} rounded-full transition-all duration-1000`}
|
||||
style={{
|
||||
width: item.value,
|
||||
opacity: '0.8'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-foreground/60 min-w-[70px] text-right">{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DetailedStats;
|
||||
114
src/src/components/blog/tag-list.tsx
Normal file
114
src/src/components/blog/tag-list.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface BlogPost {
|
||||
title: string;
|
||||
data: {
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TagListProps {
|
||||
posts: BlogPost[];
|
||||
}
|
||||
|
||||
const TagList: React.FC<TagListProps> = ({ posts }) => {
|
||||
const spectrumColors = [
|
||||
'text-red-bright',
|
||||
'text-orange-bright',
|
||||
'text-yellow-bright',
|
||||
'text-green-bright',
|
||||
'text-aqua-bright',
|
||||
'text-blue-bright',
|
||||
'text-purple-bright'
|
||||
];
|
||||
|
||||
const tagData = useMemo(() => {
|
||||
if (!Array.isArray(posts)) return [];
|
||||
|
||||
const tagMap = new Map();
|
||||
posts.forEach(post => {
|
||||
if (post?.data?.tags && Array.isArray(post.data.tags)) {
|
||||
post.data.tags.forEach(tag => {
|
||||
if (!tagMap.has(tag)) {
|
||||
tagMap.set(tag, {
|
||||
name: tag,
|
||||
count: 1
|
||||
});
|
||||
} else {
|
||||
const data = tagMap.get(tag);
|
||||
data.count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const tagArray = Array.from(tagMap.values());
|
||||
const maxCount = Math.max(...tagArray.map(t => t.count));
|
||||
|
||||
return tagArray
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((tag, index) => ({
|
||||
...tag,
|
||||
color: spectrumColors[index % spectrumColors.length],
|
||||
frequency: tag.count / maxCount
|
||||
}));
|
||||
}, [posts]);
|
||||
|
||||
if (tagData.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 w-full min-h-[16rem] flex items-center justify-center font-comic-code text-foreground opacity-60">
|
||||
No tags available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-full bg-background p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{tagData.map(({ name, count, color, frequency }) => (
|
||||
<a
|
||||
key={name}
|
||||
href={`/blog/tags/${encodeURIComponent(name)}`}
|
||||
className={`
|
||||
group relative
|
||||
flex flex-col items-center justify-center
|
||||
min-h-[5rem]
|
||||
px-6 py-4 rounded-lg
|
||||
font-comic-code text-xl
|
||||
transition-all duration-300 ease-in-out
|
||||
hover:scale-105
|
||||
hover:bg-foreground/5
|
||||
${color}
|
||||
`}
|
||||
>
|
||||
{/* Main tag display */}
|
||||
<div className="font-medium text-center">
|
||||
#{name}
|
||||
</div>
|
||||
|
||||
{/* Post count */}
|
||||
<div className="mt-2 text-base opacity-60">
|
||||
{count} post{count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
|
||||
{/* Background gradient */}
|
||||
<div
|
||||
className="absolute inset-0 -z-10 rounded-lg opacity-10"
|
||||
style={{
|
||||
background: `
|
||||
linear-gradient(
|
||||
45deg,
|
||||
currentColor ${frequency * 100}%,
|
||||
transparent
|
||||
)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagList;
|
||||
@@ -4,16 +4,13 @@ import { ClientRouter } from "astro:transitions";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import Background from "@/components/background";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
@@ -36,7 +33,6 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
defaultTransition={false}
|
||||
handleFocus={false}
|
||||
/>
|
||||
|
||||
<style>
|
||||
::view-transition-new(:root) {
|
||||
animation: none;
|
||||
@@ -45,24 +41,24 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
|
||||
::view-transition-old(:root) {
|
||||
animation: 90ms ease-out both fade-out;
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-foreground">
|
||||
<body class="bg-background text-foreground min-h-screen flex flex-col">
|
||||
<Header client:load />
|
||||
<main>
|
||||
<div class="max-w-5xl mx-auto pt-12 px-4 py-8">
|
||||
<main class="flex-1 flex flex-col">
|
||||
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1">
|
||||
<Background layout="content" position="right" client:only="react" transition:persist />
|
||||
<slot />
|
||||
<Background layout="content" position="left" client:only="react" transition:persist />
|
||||
</div>
|
||||
</main>
|
||||
<Footer client:load transition:persist />
|
||||
|
||||
<div class="mt-auto">
|
||||
<Footer client:load transition:persist />
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("astro:after-navigation", () => {
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import "@/style/globals.css"
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
import Intro from "@/components/about/intro";
|
||||
import AllTimeStats from "@/components/about/stats-alltime";
|
||||
import DetailedStats from "@/components/about/stats-detailed";
|
||||
import Timeline from "@/components/about/timeline";
|
||||
import CurrentFocus from "@/components/about/current-focus";
|
||||
import OutsideCoding from "@/components/about/outside-coding";
|
||||
@@ -14,6 +16,14 @@ import OutsideCoding from "@/components/about/outside-coding";
|
||||
<section class="h-screen flex items-center justify-center">
|
||||
<Intro client:load />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<AllTimeStats client:only />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<DetailedStats client:only />
|
||||
</section>
|
||||
|
||||
<section class="flex items-center justify-center py-16">
|
||||
<Timeline client:load />
|
||||
|
||||
32
src/src/pages/api/wakatime/activity.ts
Normal file
32
src/src/pages/api/wakatime/activity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', {
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(WAKATIME_API_KEY).toString('base64')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return new Response(
|
||||
JSON.stringify({ data: data.data }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to fetch WakaTime data' }),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/src/pages/api/wakatime/alltime.ts
Normal file
22
src/src/pages/api/wakatime/alltime.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
try {
|
||||
const { stdout } = await execAsync(`curl -H "Authorization: Basic ${import.meta.env.WAKATIME_API_KEY}" https://wakatime.com/api/v1/users/current/all_time_since_today`);
|
||||
|
||||
return new Response(stdout, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: "Failed to fetch stats" }), {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
}
|
||||
33
src/src/pages/api/wakatime/detailed.ts
Normal file
33
src/src/pages/api/wakatime/detailed.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/pages/api/wakatime/detailed.ts
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', {
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(WAKATIME_API_KEY).toString('base64')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return new Response(
|
||||
JSON.stringify({ data: data.data }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to fetch WakaTime data' }),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
src/src/pages/blog/tags/index.astro
Normal file
28
src/src/pages/blog/tags/index.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import ContentLayout from "@/layouts/content.astro";
|
||||
|
||||
import TagList from "@/components/blog/tag-list";
|
||||
|
||||
const posts = (await getCollection("blog", ({ data }) => {
|
||||
return data.isDraft !== true;
|
||||
})).sort((a, b) => {
|
||||
return b.data.date.valueOf() - a.data.date.valueOf()
|
||||
}).map(post => ({
|
||||
...post,
|
||||
data: {
|
||||
...post.data,
|
||||
date: post.data.date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})
|
||||
}
|
||||
}));
|
||||
---
|
||||
<ContentLayout
|
||||
title="Blog | Timothy Pidashev"
|
||||
description="My experiences and technical insights into software development and the ever-evolving world of programming."
|
||||
>
|
||||
<TagList posts={posts} />
|
||||
</ContentLayout>
|
||||
Reference in New Issue
Block a user