Compare commits

..

34 Commits

Author SHA1 Message Date
2c5f64a769 Polishing animations 2026-03-30 11:18:36 -07:00
b2cd74385f Astro upgrade to v6 2026-03-30 09:53:51 -07:00
95081b8b77 Omit drafts from build 2025-11-11 09:28:59 -08:00
40b6359d8f Add public pgp key; update astro 2025-11-11 09:19:03 -08:00
d61080722d push latest 2025-09-15 10:38:26 -07:00
5117218a1a Fix code block formatting on mobile devices 2025-09-15 10:04:35 -07:00
f355373ba1 For now sunset resources work 2025-09-11 08:55:05 -07:00
384cb82efb create 0101-welcome-to-the-terminal 2025-08-27 12:46:17 -07:00
7ff6f6542b create video player 2025-08-27 11:19:18 -07:00
9ad08dc85d background animations: 2025-08-27 08:53:56 -07:00
12631dbd42 Animations 2025-08-27 08:27:22 -07:00
1758dc3153 Fixed 2025-08-22 23:08:39 -07:00
9496030d41 Broken 2025-08-21 22:53:37 -07:00
30f264a6bb Thinking of ways to build out a presentation system 2025-08-21 22:18:04 -07:00
7992fcbd49 Add a resources layout 2025-08-18 13:28:55 -07:00
60a9fb0339 back at it 2025-08-16 13:57:37 -07:00
6711de5eb6 Update mdx command component to conform to mobile container size 2025-04-23 12:26:34 -07:00
f2b4660300 keep working on coreboot guide 2025-04-23 12:00:44 -07:00
97608e983c update readme 2025-04-22 13:29:29 -07:00
ce812e8466 Add commands mdx component; continue work on coreboot post 2025-04-22 12:20:19 -07:00
d44988b39c Set comment feed to load eagerly 2025-04-22 09:26:43 -07:00
d885ea4e6b Fix requestAnimationFrame blurring background after switching views 2025-04-22 09:07:47 -07:00
6cfa4c5b7d Remove cursor; update background 2025-04-22 09:04:21 -07:00
f2e85dc6d8 Add cursor trail; fix giscuss 2025-04-21 14:45:44 -07:00
fce17d397e hide custom cursor om mobile devices 2025-04-21 14:26:17 -07:00
7cc954ae07 Add custom cursor; improve pointer events 2025-04-21 14:15:08 -07:00
c6aa014d29 Fix content typography sizes 2025-04-21 13:25:48 -07:00
a9cbbb7e8e Update dockerfile 2025-04-21 12:31:57 -07:00
788eb84488 Update entrypoint to support ssr 2025-04-21 12:26:19 -07:00
4fc5a07249 Update Caddyfile.release 2025-04-21 12:21:15 -07:00
aca5d53bd1 fix proxy issue 2025-04-21 12:17:57 -07:00
f1af80afaf Update compose 2025-04-21 12:14:40 -07:00
257000e81d Update container name 2025-04-21 12:13:32 -07:00
8b30228c4a update compose 2025-04-21 12:10:48 -07:00
52 changed files with 4311 additions and 3111 deletions

View File

@@ -35,14 +35,13 @@ RUN pnpm run build
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Install serve
RUN npm install -g http-server
# Copy built files # Copy built files
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Expose port 3000 # Expose port 3000
EXPOSE 3000 EXPOSE 3000
# Deployment command # Deployment command
CMD ["http-server", "dist", "-a", "127.0.0.1", "-p", "3000"] CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -1,6 +1,6 @@
PROJECT_NAME := "timmypidashev.dev" PROJECT_NAME := "timmypidashev.dev"
PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>" PROJECT_AUTHORS := "Timothy Pidashev (timmypidashev) <pidashev.tim@gmail.com>"
PROJECT_VERSION := "v1.0.2" PROJECT_VERSION := "v2.1.1"
PROJECT_LICENSE := "MIT" PROJECT_LICENSE := "MIT"
PROJECT_SOURCES := "https://github.com/timmypidashev/web" PROJECT_SOURCES := "https://github.com/timmypidashev/web"
PROJECT_REGISTRY := "ghcr.io/timmypidashev" PROJECT_REGISTRY := "ghcr.io/timmypidashev"

View File

@@ -1 +1,3 @@
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Ftimmypidashev.dev&label=Visits&icon=eye-fill&color=%23198754)
<img src=".github/preview.jpeg" title="Preview"/> <img src=".github/preview.jpeg" title="Preview"/>

View File

@@ -21,7 +21,7 @@ services:
command: --interval 120 --cleanup --label-enable command: --interval 120 --cleanup --label-enable
timmypidashev.dev: timmypidashev.dev:
container_name: timmypidashev container_name: timmypidashev.dev
image: ghcr.io/timmypidashev/timmypidashev.dev:latest image: ghcr.io/timmypidashev/timmypidashev.dev:latest
networks: networks:
- proxy_network - proxy_network

View File

@@ -10,6 +10,10 @@ import sitemap from "@astrojs/sitemap";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
output: "server", output: "server",
server: {
host: true,
port: 3000,
},
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),

View File

@@ -8,32 +8,36 @@
"preview": "astro preview" "preview": "astro preview"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/react": "^4.2.4", "@astrojs/react": "^5.0.2",
"@astrojs/tailwind": "^6.0.2", "@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.20", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.7",
"astro": "^5.7.4", "astro": "^6.1.2",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.19"
}, },
"dependencies": { "dependencies": {
"@astrojs/mdx": "^4.2.4", "@astrojs/mdx": "^5.0.3",
"@astrojs/node": "^9.2.0", "@astrojs/node": "^10.0.4",
"@astrojs/rss": "^4.0.11", "@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.3.0", "@astrojs/sitemap": "^3.7.2",
"@giscus/react": "^3.1.0", "@giscus/react": "^3.1.0",
"@pilcrowjs/object-parser": "^0.0.4", "@pilcrowjs/object-parser": "^0.0.4",
"@react-hook/intersection-observer": "^3.1.2", "@react-hook/intersection-observer": "^3.1.2",
"arctic": "^3.6.0", "@rehype-pretty/transformers": "^0.13.2",
"arctic": "^3.7.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"marked": "^15.0.8", "marked": "^15.0.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.6.0",
"react-responsive": "^10.0.1", "react-responsive": "^10.0.1",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.1", "rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0", "rehype-slug": "^6.0.0",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"typewriter-effect": "^2.21.0" "shiki": "^3.23.0",
"typewriter-effect": "^2.22.0",
"unist-util-visit": "^5.1.0"
} }
} }

3337
src/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

65
src/public/pgp.asc Normal file
View File

@@ -0,0 +1,65 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGTwzLsBEADehIeiC1fV/GiRBclTmM9e6rmG29/YQbrRfJZ+sa1gWlAws/yR
sXbrCDh1S/JU85lirhc0A2N+OZSqCSkGUtvDhCttxLi5VUyVxgqshJWN/mU5eZEN
x4/5mpAApV6K2WGjAggJjoHecr/+sRpV3Vq/5ypdp6RLt7zeJolJSzKWpFrGeYTd
CFaZyZbQVw2NeFe9NBoQHDk0mMS+9bVyXQ2oi4fteKCe205cJkEc4Z8q4NzOlquL
BbowQwxKoAosCwz19Em5yeND34WUHJPlgoYTf9HRgsQcmI17hn9Q00gzwm7gotbF
bqWKEMduOzGBRyEEWeZ+l8CcWHAo2NbxZx01L4UsrZ6+MkXWv+2NQBFhPL14UcDg
uuBVvc5rsgeu7Rm8DE2EXTyA3Bszz1r75TbsQwUianma38kxSuiKArUDE+v5X0vv
F6e2z6hkDkKCe53EM9yF4lvVHNsQwxT8RBXPj+sc6Sqv3nIkLnan3++aMhW3up9s
YXKouadmvgB4uGqsBjWOme4ViW46C7rNVRtUKQTdx89cFG1c+GuaI203RbHjAZVJ
M9fBH5Ycdl0ieSCwmerbHfTRudHqPdNLQMvFYGPEj5pXalwtd3j4CEEC/HqOyklz
IN9ReIYIYF0pSC5OmWD3c70vj0INsbWp96eB8skjONjHYL3Y2CXIZ0AzRQARAQAB
tClUaW1vdGh5IFBpZGFzaGV2IDxwaWRhc2hldi50aW1AZ21haWwuY29tPokCTgQT
AQgAOBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsDBQsJCAcCBhUKCQgL
AgQWAgMBAh4BAheAAAoJEPe9h7j9i0NOsxwQALfdoZJAdkBpM2AmsVdx6JqvA08I
p/Xr1YgjwJvziq8fnWpu/AGz9VevFVgAt1h1Dsr4XAolEtQM6+aiNX7HGqyLKqT7
kum/dpnjw0/tiKvv/P2TRc+YZZLOfb+TYa1bZYGVDzGAHAm17yMJTV3rH8tIKNee
VaqWmxMuwmUQXutvF9P2bhaJLOTjGCIVxuAMfLIhRGKz8q8+I5g5aLm/JrpHC0OY
ACGSSj1vP0b5m5BLqqv67GueDHTX6w/7U1LAEspIcs+/GxoA5G9WzZFn4qNdq98h
RPixuY5y5FYKV6FAVGm5Yu3FSPvKpXAfWZIKM3WzKf7BNhUVaB6HNGbCFAMGDcHz
dZ9xXRohWlHidft55qBUpTXAjy3vb2k5eMXGPNMCwQvMyzDZzLkYbN3apuWbjzlQ
ARdlGKpRRzmeAHEmAybX1Fel8dT2DWjP05t2z2BRQAFK9sGE7WzYhvllMp31C7SA
uJZNzgjjs/aI8oiNc1qpeiQxsEGws3OakHRxd1rnM+d4icwTx13u8lMqHnvORgIe
rAa6qKIUnfZLo0ut0X5s8iPjoLZr/qjDGRbVmHH5K/D5Ci6VlsEkmcQ0REE1FWPA
hrlSNfKbeaHWAJ7MFKvNxViy8n7MeoR6Nn7EGoxCfekgGQLWuRNanChJ8dm7HZpq
o/vu4JH94Ui9xDwBtDlUaW1vdGh5IFBpZGFzaGV2ICh0aW1teXBpZGFzaGV2KSA8
bWFpbEB0aW1teXBpZGFzaGV2LmRldj6JAlQEEwEIAD4CGwMFCwkIBwICIgIGFQoJ
CAsCBBYCAwECHgcCF4AWIQRoxNZefDLF1F/onrn3vYe4/YtDTgUCZ5J4fQIZAQAK
CRD3vYe4/YtDTsGjD/4s/pBI8s+zoV+aBBKi9qmIqFAlZ8+JyY4TzAlIa1qZg/Xk
GVEN1+Lwa9m4eI1SFZUOprLPiqqFJ+DSHjrua5FGo56uhYGBEbPBlzIJx0XtXclS
1FmpoDOjY6FFsvrkv19jPYB2oXnPjok/nkRLdNWp1BVqisqFq8f//iynMu6GTndF
cNHf7iwZ+IHytFTiKFCgMg7jPeXofAkpnFXoOB33wn7ED2I26zhMx9wJE1cKApxv
ZmxGtkPk0rv6kiQqGE9zTg+AKSy1+jkXp8eFrnA4P9bIDXxcnDyVs/63X6X/qEK5
Yg1a3Xq1U4d6aoQUqAShpAQGbTmEvKusYXzdd2fFJEd0OExXkG2mWndhkF/9+8Rh
SroaqS+0G3nG38KTvK7OKnyHhuDVjcvJ5QiWVd1T7M3SBDAZwcOmpkw3SN26b8iS
i8iHAUQGjKftG+PDrXvRhMn7lpIshJBXopCGJvzPwLIoMvVzgq0gxAeCUHqI0wr2
EXEgboPW18zcAagI2r4B7p0xVtpJ9qPamYcCPqPMfxA8YYKhyzb8owkFUBboSF8j
ihBz1NN3ph8ZEa1YbxrdJVMkbOlE/O+DDaegxkXG9gSts/nYZXQx5GZ2LyVlHC4z
yVUpGwsRLfSejubhBnkRrXzOn1dFhAL4kIXFvFp4t4ZkYssdWzLVx71UVTyGsrkC
DQRk8My7ARAA1NRA04/vS9Cww0MMFQwaBztEb4INAT3dVxybyPZEIiNGttqGzEc9
EV+5NlcLwygDraXqw+k5GekIE7Mqf3YukeIqA+4TDVpFv26QbtBnLQ01YM7Z0tU4
R/X6IJmn/Uudc5hKLOLms3BH4x6O/XQJERJIALOfMWRfsmcUXw8a05HF5OuNVClT
w8FHVawN7frCJdBsh9g2bGJArwQFCxaLcDgpydUTMxNgxQMLgcAuIk3GiFwwWC4e
HzrAmp1yHn/iDh+UN8zQBjoi/5Ac4uXJlHKGAiakw/NqYlFccno1vUg5kuW+9QN4
ch2fa4zAosd7ObR5uZjNn6sggnq4ejA98vtg5DssCSQpTiFqNu3pBLroh4LsRh3r
THuWnXj4HWKDPZ3odlPVy2sIswtMXO3uygyWLJbPuT824iFwD9imshqsnoMxazb1
W/GuBFyI7ZM8tzCMVNtZExEBqnOwQdjlSgpla6L3UVWs4KL1UEVWm3doFCGgzQbK
JVVH3Uk0Z+w+jylZqXdmSSrB/wkg+j9QK2VxewEP0onS4FBhoJsaezLL5fTYpOy5
yAx2k3lqa3YF51ulPoGGg3u75R/37zt8VT3rfEXuCjtHd/H4fieluAOW2w4phXrC
u8iMq0eChaedVZsAAsy+DW9Ighf8zy8x/HQ0MKaFGI59B6BOsL6f/2MAEQEAAYkC
NgQYAQgAIBYhBGjE1l58MsXUX+ieufe9h7j9i0NOBQJk8My7AhsMAAoJEPe9h7j9
i0NOWvEQAJl5UqnzxM9+aYQcCw1qxGYTxdui7mE8QHDU9L7sz5vPeLVXrkxZfFsR
+2Y6S92ySk+pTZv8/+TztxYczr3tJ/TUpj6jM7jJROo2BUcAph4wSBD/vxqCN40g
XYcvbzbFvmbXJL+I2h/C3Ja7O0DqlDqRB2icaCYqVK0aDFfe5ldHbfs+w4Ox/zh6
7kkchA+WauRadwyaqWK3XGdusTw3ZcaFRSGfTRCQfM2U1761mRBppeDhOPoZ6Ymy
rWUpiOE7SJNe+gxyX0wVGv/CuUr5RaDEwvl7A5fUA9Sdvtxdr3Eixd6lsCRWoAtq
1woDoqpDZpS0wlb28r7/ZtvAUR1EovAOwy41GNri9AwyMeRSg3NLqb21c9yhEV5a
cWdGzioSjk89dd1c/pzuHPZ1cTRiizH8SRQjn/rLqJTnH1vVhLFmLv/Ywr9LOw2s
RcKxrByu0O+j96zR+6dqsiCo4i0CUS7P1shQlzhRuz6o3eZ1IlepAlif2LDW5waO
KdFJe2hD0oe4JKO9TbzJOeupFW1C/TBzdLif3K7VsKVdfPBL1YWinf7V7gryxJdc
pAE4764aIhfCkLa85tt7Tn/ii4ZLLMQG+Ww7LJ7BRarMlcyrw3nf0dICLxFgAnMS
vclOqTbTH082uTM/wa3dhxByz0rKZEz7xXAjPiZfK+2nncOdQhAm
=T4Wx
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -1,5 +1,57 @@
import React from 'react'; import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from 'lucide-react'; import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
export default function CurrentFocus() { export default function CurrentFocus() {
const recentProjects = [ const recentProjects = [
@@ -7,126 +59,134 @@ export default function CurrentFocus() {
title: "Darkbox", title: "Darkbox",
description: "My gruvbox theme, with a pure black background", description: "My gruvbox theme, with a pure black background",
href: "/projects/darkbox", href: "/projects/darkbox",
tech: ["Neovim", "Lua"] tech: ["Neovim", "Lua"],
}, },
{ {
title: "Revive Auto Parts", title: "Revive Auto Parts",
description: "A car parts listing site built for a client", description: "A car parts listing site built for a client",
href: "/projects/reviveauto", href: "/projects/reviveauto",
tech: ["Tanstack", "React Query", "Fastapi"] tech: ["Tanstack", "React Query", "Fastapi"],
}, },
{ {
title: "Fhccenter", title: "Fhccenter",
description: "Website made for a private school", description: "Website made for a private school",
href: "/projects/fhccenter", href: "/projects/fhccenter",
tech: ["Nextjs", "Typescript", "Prisma"] tech: ["Nextjs", "Typescript", "Prisma"],
} },
]; ];
return ( return (
<div className="flex justify-center items-center w-full"> <div className="flex justify-center items-center w-full">
<div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8"> <div className="w-full max-w-6xl p-4 sm:px-6 py-6 sm:py-8">
<h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12"> <AnimateIn>
Current Focus <h2 className="text-3xl sm:text-4xl font-bold text-center text-yellow-bright mb-8 sm:mb-12">
</h2> Current Focus
</h2>
</AnimateIn>
{/* Recent Projects Section */} {/* Recent Projects Section */}
<div className="mb-8 sm:mb-16"> <div className="mb-8 sm:mb-16">
<div className="flex items-center justify-center gap-2 mb-6"> <AnimateIn delay={100}>
<Code2 className="text-yellow-bright" size={24} /> <div className="flex items-center justify-center gap-2 mb-6">
<h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3> <Code2 className="text-yellow-bright" size={24} />
</div> <h3 className="text-xl font-bold text-foreground/90">Recent Projects</h3>
</div>
</AnimateIn>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-5xl mx-auto">
{recentProjects.map((project) => ( {recentProjects.map((project, i) => (
<a <AnimateIn key={project.title} delay={200 + i * 100}>
href={project.href} <a
key={project.title} href={project.href}
className="p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50 className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50" transition-all duration-300 group bg-background/50 h-full"
> >
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors"> <h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title} {project.title}
</h4> </h4>
<p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p> <p className="text-foreground/70 mt-2 text-sm sm:text-base">{project.description}</p>
<div className="flex flex-wrap gap-2 mt-3"> <div className="flex flex-wrap gap-2 mt-3">
{project.tech.map((tech) => ( {project.tech.map((tech) => (
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60"> <span key={tech} className="text-xs px-2 py-1 rounded-full bg-foreground/5 text-foreground/60">
{tech} {tech}
</span> </span>
))} ))}
</div> </div>
</a> </a>
</AnimateIn>
))} ))}
</div> </div>
</div> </div>
{/* Current Learning & Interests */} {/* Current Learning & Interests */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
{/* What I'm Learning */} <AnimateIn delay={100}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50"> <div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<BookOpen className="text-green-bright" size={24} /> <BookOpen className="text-green-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3> <h3 className="text-lg sm:text-xl font-bold text-foreground/90">Currently Learning</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>Rust Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>WebAssembly with Rust</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>HTTP/3 & WebTransport</span>
</li>
</ul>
</div> </div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70"> </AnimateIn>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>Rust Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>WebAssembly with Rust</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-green-bright flex-shrink-0" />
<span>HTTP/3 & WebTransport</span>
</li>
</ul>
</div>
{/* Project Interests */} <AnimateIn delay={200}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50"> <div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<RocketIcon className="text-blue-bright" size={24} /> <RocketIcon className="text-blue-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3> <h3 className="text-lg sm:text-xl font-bold text-foreground/90">Project Interests</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>AI Model Integration</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Rust Systems Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Cross-platform WASM Apps</span>
</li>
</ul>
</div> </div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70"> </AnimateIn>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>AI Model Integration</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Rust Systems Programming</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue-bright flex-shrink-0" />
<span>Cross-platform WASM Apps</span>
</li>
</ul>
</div>
{/* Areas to Explore */} <AnimateIn delay={300}>
<div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50"> <div className="space-y-4 p-4 sm:p-6 rounded-lg border border-foreground/10 bg-background/50 h-full">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Compass className="text-purple-bright" size={24} /> <Compass className="text-purple-bright" size={24} />
<h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3> <h3 className="text-lg sm:text-xl font-bold text-foreground/90">Want to Explore</h3>
</div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>LLM Fine-tuning</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Rust 2024 Edition</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Real-time Web Transport</span>
</li>
</ul>
</div> </div>
<ul className="space-y-3 text-sm sm:text-base text-foreground/70"> </AnimateIn>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>LLM Fine-tuning</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Rust 2024 Edition</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-purple-bright flex-shrink-0" />
<span>Real-time Web Transport</span>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,47 +1,89 @@
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@/components/icons"; import { ChevronDownIcon } from "@/components/icons";
export default function Intro() { export default function Intro() {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setVisible(true);
return;
}
if (inView) {
// Fresh navigation — animate in
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const scrollToNext = () => { const scrollToNext = () => {
const nextSection = document.querySelector("section")?.nextElementSibling; const nextSection = document.querySelector("section")?.nextElementSibling;
if (nextSection) { if (nextSection) {
const offset = nextSection.offsetTop - (window.innerHeight - nextSection.offsetHeight) / 2; // Center the section const offset = (nextSection as HTMLElement).offsetTop - (window.innerHeight - (nextSection as HTMLElement).offsetHeight) / 2;
window.scrollTo({ window.scrollTo({ top: offset, behavior: "smooth" });
top: offset,
behavior: "smooth"
});
} }
}; };
const anim = (delay: number) =>
({
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
transition: `all 0.7s ease-out ${delay}ms`,
}) as React.CSSProperties;
return ( return (
<div className="w-full max-w-4xl px-4"> <div ref={ref} className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12"> <div className="space-y-8 md:space-y-12">
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16"> <div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16">
<div className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"> <div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
style={anim(0)}
>
<img <img
src="/me.jpeg" src="/me.jpeg"
alt="Timothy Pidashev" alt="Timothy Pidashev"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300" className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300"
/> />
</div> </div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6"> <div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright"> <h2 className="text-xl sm:text-5xl font-bold text-yellow-bright">
Timothy Pidashev Timothy Pidashev
</h2> </h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3"> <div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span> <span className="text-blue">Software Systems Engineer</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span> <span className="text-green">Open Source Enthusiast</span>
</p> </p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2"> <p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span> <span className="text-yellow">Coffee Connoisseur</span>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-8"> <div className="space-y-8" style={anim(750)}>
<p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium"> <p className="text-foreground/80 text-center text-base sm:text-2xl italic max-w-3xl mx-auto font-medium">
"Turning coffee into code" isn't just a clever phrase "Turning coffee into code" isn't just a clever phrase
<span className="text-aqua-bright"> it's how I approach each project:</span> <span className="text-aqua-bright"> it's how I approach each project:</span>
@@ -49,7 +91,7 @@ export default function Intro() {
<span className="text-blue-bright"> with attention to detail,</span> <span className="text-blue-bright"> with attention to detail,</span>
<span className="text-green-bright"> and a refined process.</span> <span className="text-green-bright"> and a refined process.</span>
</p> </p>
<div className="flex justify-center"> <div className="flex justify-center" style={anim(900)}>
<button <button
onClick={scrollToNext} onClick={scrollToNext}
className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300" className="text-foreground/50 hover:text-yellow-bright transition-colors duration-300"

View File

@@ -1,64 +1,115 @@
import React from 'react'; import React, { useEffect, useRef, useState } from "react";
import { Fish, Mountain, Book, Car } from 'lucide-react'; import { Cross, Fish, Mountain, Book } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(20px) scale(0.97)",
}}
>
{children}
</div>
);
}
const interests = [
{
icon: <Cross className="text-red-bright" size={20} />,
title: "Faith",
description: "My walk with Jesus is the foundation of everything I do, guiding my purpose and perspective",
},
{
icon: <Fish className="text-blue-bright" size={20} />,
title: "Fishing",
description: "Finding peace and adventure on the water, always looking for the next great fishing spot",
},
{
icon: <Mountain className="text-green-bright" size={20} />,
title: "Hiking",
description: "Exploring trails with friends and seeking out scenic viewpoints in nature",
},
{
icon: <Book className="text-purple-bright" size={20} />,
title: "Reading",
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind",
},
];
export default function OutsideCoding() { export default function OutsideCoding() {
const interests = [
{
icon: <Fish className="text-blue-bright" size={20} />,
title: "Fishing",
description: "Finding peace and adventure on the water, always looking for the next great fishing spot"
},
{
icon: <Mountain className="text-green-bright" size={20} />,
title: "Hiking",
description: "Exploring trails with friends and seeking out scenic viewpoints in nature"
},
{
icon: <Book className="text-purple-bright" size={20} />,
title: "Reading",
description: "Deep diving into novels & technical books that expand my horizons & captivate my mind"
},
{
icon: <Car className="text-yellow-bright" size={20} />,
title: "Project Cars",
description: "Working on automotive projects, modifying & restoring sporty sedans"
}
];
return ( return (
<div className="flex justify-center items-center w-full"> <div className="flex justify-center items-center w-full">
<div className="w-full max-w-4xl px-4 py-8"> <div className="w-full max-w-4xl px-4 py-8">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8"> <AnimateIn>
Outside of Programming <h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8">
</h2> Outside of Programming
</h2>
</AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest) => ( {interests.map((interest, i) => (
<div <AnimateIn key={interest.title} delay={100 + i * 100}>
key={interest.title} <div
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10 className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50" hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full"
> >
<div className="mb-3"> <div className="mb-3">{interest.icon}</div>
{interest.icon} <h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>
<p className="text-sm text-foreground/70">{interest.description}</p>
</div> </div>
<h3 className="font-bold text-foreground/90 mb-2"> </AnimateIn>
{interest.title}
</h3>
<p className="text-sm text-foreground/70">
{interest.description}
</p>
</div>
))} ))}
</div> </div>
<p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic"> <AnimateIn delay={500}>
When I'm not writing code, you'll find me <p className="text-center text-foreground/80 mt-8 max-w-2xl mx-auto text-sm md:text-base italic">
<span className="text-blue-bright"> out on the water,</span> When I'm not writing code, you'll find me
<span className="text-green-bright"> hiking trails,</span> <span className="text-red-bright"> walking with Christ,</span>
<span className="text-purple-bright"> reading books,</span> <span className="text-blue-bright"> out on the water,</span>
<span className="text-yellow-bright"> or modifying my current ride.</span> <span className="text-green-bright"> hiking trails,</span>
</p> <span className="text-purple-bright"> or reading books.</span>
</p>
</AnimateIn>
</div> </div>
</div> </div>
); );

View File

@@ -1,34 +1,19 @@
import React, { useState, useEffect } from 'react'; import React from "react";
export const ActivityGrid = () => { interface ActivityDay {
const [data, setData] = useState([]); grand_total: { total_seconds: number };
const [loading, setLoading] = useState(true); date: string;
const [error, setError] = useState(null); }
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; interface ActivityGridProps {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; data: ActivityDay[];
}
useEffect(() => { export const ActivityGrid = ({ data }: ActivityGridProps) => {
const fetchData = async () => { const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
try { const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
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(); const getIntensity = (hours: number) => {
}, []);
// Get intensity based on coding hours (0-4 for different shades)
const getIntensity = (hours) => {
if (hours === 0) return 0; if (hours === 0) return 0;
if (hours < 2) return 1; if (hours < 2) return 1;
if (hours < 4) return 2; if (hours < 4) return 2;
@@ -36,20 +21,18 @@ export const ActivityGrid = () => {
return 4; return 4;
}; };
// Get color class based on intensity const getColorClass = (intensity: number) => {
const getColorClass = (intensity) => { if (intensity === 0) return "bg-foreground/5";
if (intensity === 0) return 'bg-foreground/5'; if (intensity === 1) return "bg-green-DEFAULT/30";
if (intensity === 1) return 'bg-green-DEFAULT/30'; if (intensity === 2) return "bg-green-DEFAULT/60";
if (intensity === 2) return 'bg-green-DEFAULT/60'; if (intensity === 3) return "bg-green-DEFAULT/80";
if (intensity === 3) return 'bg-green-DEFAULT/80'; return "bg-green-bright";
return 'bg-green-bright';
}; };
// Group data by week const weeks: ActivityDay[][] = [];
const weeks = []; let currentWeek: ActivityDay[] = [];
let currentWeek = [];
if (data.length > 0) { if (data && data.length > 0) {
data.forEach((day, index) => { data.forEach((day, index) => {
currentWeek.push(day); currentWeek.push(day);
if (currentWeek.length === 7 || index === data.length - 1) { if (currentWeek.length === 7 || index === data.length - 1) {
@@ -59,20 +42,8 @@ export const ActivityGrid = () => {
}); });
} }
if (loading) { if (!data || data.length === 0) {
return ( return null;
<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 ( return (
@@ -83,7 +54,7 @@ export const ActivityGrid = () => {
{/* Days labels */} {/* Days labels */}
<div className="flex flex-col gap-2 pt-6 text-xs"> <div className="flex flex-col gap-2 pt-6 text-xs">
{days.map((day, i) => ( {days.map((day, i) => (
<div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ''}</div> <div key={day} className="h-3 text-foreground/60">{i % 2 === 0 ? day : ""}</div>
))} ))}
</div> </div>
{/* Grid */} {/* Grid */}
@@ -102,7 +73,6 @@ export const ActivityGrid = () => {
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
group relative`} group relative`}
> >
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 <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 bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs"> group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
@@ -123,7 +93,7 @@ export const ActivityGrid = () => {
<div <div
key={i} key={i}
className="w-3 mx-1" className="w-3 mx-1"
style={{ marginLeft: i === 0 ? '0' : undefined }} style={{ marginLeft: i === 0 ? "0" : undefined }}
> >
{isFirstOfMonth && months[date.getMonth()]} {isFirstOfMonth && months[date.getMonth()]}
</div> </div>
@@ -136,10 +106,7 @@ export const ActivityGrid = () => {
<div className="flex items-center gap-2 mt-4 text-xs text-foreground/60"> <div className="flex items-center gap-2 mt-4 text-xs text-foreground/60">
<span>Less</span> <span>Less</span>
{[0, 1, 2, 3, 4].map((intensity) => ( {[0, 1, 2, 3, 4].map((intensity) => (
<div <div key={intensity} className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`} />
key={intensity}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}`}
/>
))} ))}
<span>More</span> <span>More</span>
</div> </div>

View File

@@ -1,98 +1,115 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
const Stats = () => { const Stats = () => {
const [stats, setStats] = useState<any>(null); const [stats, setStats] = useState<any>(null);
const [error, setError] = useState(false);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const hasAnimated = useRef(false);
const sectionRef = useRef<HTMLDivElement>(null);
// Fetch data on mount
useEffect(() => { useEffect(() => {
setIsVisible(true); fetch("/api/wakatime/alltime")
.then((res) => {
const fetchStats = async () => { if (!res.ok) throw new Error("API error");
try { return res.json();
const res = await fetch("/api/wakatime/alltime"); })
const data = await res.json(); .then((data) => setStats(data.data))
setStats(data.data); .catch(() => setError(true));
startCounting(data.data.total_seconds);
} catch (error) {
console.error("Error fetching stats:", error);
}
};
fetchStats();
}, []); }, []);
const startCounting = (totalSeconds: number) => { // Observe visibility — skip animation if already in view on mount
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setIsVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setIsVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Start counter when both visible and data is ready
useEffect(() => {
if (!isVisible || !stats || hasAnimated.current) return;
hasAnimated.current = true;
const totalSeconds = stats.total_seconds;
const duration = 2000; const duration = 2000;
const steps = 60; const steps = 60;
let currentStep = 0; let currentStep = 0;
const timer = setInterval(() => { const timer = setInterval(() => {
currentStep += 1; currentStep += 1;
if (currentStep >= steps) { if (currentStep >= steps) {
setCount(totalSeconds); setCount(totalSeconds);
setIsFinished(true);
clearInterval(timer); clearInterval(timer);
return; return;
} }
const progress = 1 - Math.pow(1 - currentStep / steps, 4); const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress)); setCount(Math.floor(totalSeconds * progress));
}, duration / steps); }, duration / steps);
return () => clearInterval(timer); return () => clearInterval(timer);
}; }, [isVisible, stats]);
if (!stats) return null; if (error) return null;
if (!stats) return <div ref={sectionRef} className="min-h-[50vh]" />;
const hours = Math.floor(count / 3600); const hours = Math.floor(count / 3600);
const formattedHours = hours.toLocaleString("en-US", { const formattedHours = hours.toLocaleString("en-US", {
minimumIntegerDigits: 4, minimumIntegerDigits: 4,
useGrouping: true useGrouping: true,
}); });
return ( return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-6"> <div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={` <div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
text-2xl opacity-0
${isVisible ? "animate-fade-in-first" : ""}
`}>
I've spent I've spent
</div> </div>
<div className="relative"> <div className="relative">
<div className="text-8xl text-center relative z-10"> <div className="text-8xl text-center relative z-10">
<span className="font-bold relative"> <span className="font-bold relative">
<span className={` <span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
bg-gradient-text opacity-0
${isVisible ? "animate-fade-in-second" : ""}
`}>
{formattedHours} {formattedHours}
</span> </span>
</span> </span>
<span className={` <span className={skipAnim ? "text-4xl opacity-60 ml-4" : `text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
text-4xl opacity-0
${isVisible ? "animate-slide-in-hours" : ""}
`}>
hours hours
</span> </span>
</div> </div>
</div> </div>
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<div className={` <div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
text-xl opacity-0
${isVisible ? "animate-fade-in-third" : ""}
`}>
writing code & building apps writing code & building apps
</div> </div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>
<div className={`
flex items-center gap-3 text-lg opacity-0
${isVisible ? "animate-fade-in-fourth" : ""}
`}>
<span>since</span> <span>since</span>
<span className="text-green-bright font-bold">{stats.range.start_text}</span> <span className="text-green-bright font-bold">{stats.range.start_text}</span>
</div> </div>
@@ -100,15 +117,7 @@ const Stats = () => {
<style jsx>{` <style jsx>{`
.bg-gradient-text { .bg-gradient-text {
background: linear-gradient( background: linear-gradient(90deg, #fbbf24, #f59e0b, #d97706, #b45309, #f59e0b, #fbbf24);
90deg,
#fbbf24,
#f59e0b,
#d97706,
#b45309,
#f59e0b,
#fbbf24
);
background-size: 200% auto; background-size: 200% auto;
color: transparent; color: transparent;
background-clip: text; background-clip: text;
@@ -116,95 +125,32 @@ const Stats = () => {
-webkit-text-fill-color: transparent; -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 { @keyframes fadeInFirst {
0% { from { opacity: 0; transform: translateY(20px); }
opacity: 0; to { opacity: 0.8; transform: translateY(0); }
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
} }
@keyframes fadeInSecond { @keyframes fadeInSecond {
0% { from { opacity: 0; transform: translateY(20px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes slideInHours { @keyframes slideInHours {
0% { from { opacity: 0; transform: translateX(20px); margin-left: 0; }
opacity: 0; to { opacity: 0.6; transform: translateX(0); margin-left: 1rem; }
transform: translateX(20px);
margin-left: 0;
}
100% {
opacity: 0.6;
transform: translateX(0);
margin-left: 1rem;
}
} }
@keyframes fadeInThird { @keyframes fadeInThird {
0% { from { opacity: 0; transform: translateY(20px); }
opacity: 0; to { opacity: 0.8; transform: translateY(0); }
transform: translateY(20px);
}
100% {
opacity: 0.8;
transform: translateY(0);
}
} }
@keyframes fadeInFourth { @keyframes fadeInFourth {
0% { from { opacity: 0; transform: translateY(20px); }
opacity: 0; to { opacity: 0.6; transform: translateY(0); }
transform: translateY(20px);
}
100% {
opacity: 0.6;
transform: translateY(0);
}
} }
.animate-fade-in-first { .animate-fade-in-first { animation: fadeInFirst 0.7s ease-out forwards; }
animation: fadeInFirst 0.7s ease-out forwards; .animate-fade-in-second { animation: fadeInSecond 0.7s ease-out 0.4s forwards; }
} .animate-slide-in-hours { animation: slideInHours 0.7s ease-out 0.6s forwards; }
.animate-fade-in-third { animation: fadeInThird 0.7s ease-out 0.8s forwards; }
.animate-fade-in-second { .animate-fade-in-fourth { animation: fadeInFourth 0.7s ease-out 1s forwards; }
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> `}</style>
</div> </div>
); );

View File

@@ -1,35 +1,66 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react"; import { Clock, CalendarClock, CodeXml, Computer } from "lucide-react";
import { ActivityGrid } from "@/components/about/stats-activity"; import { ActivityGrid } from "@/components/about/stats-activity";
const DetailedStats = () => { const DetailedStats = () => {
const [stats, setStats] = useState(null); const [stats, setStats] = useState<any>(null);
const [activity, setActivity] = useState(null); const [activity, setActivity] = useState<any>(null);
const [isVisible, setIsVisible] = useState(false); const [error, setError] = useState(false);
const [visible, setVisible] = useState(false);
const [skipAnim, setSkipAnim] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
fetch("/api/wakatime/detailed") fetch("/api/wakatime/detailed")
.then(res => res.json()) .then((res) => {
.then(data => { if (!res.ok) throw new Error();
setStats(data.data); return res.json();
setIsVisible(true);
}) })
.catch(error => { .then((data) => setStats(data.data))
console.error("Error fetching stats:", error); .catch(() => setError(true));
});
fetch("/api/wakatime/activity") fetch("/api/wakatime/activity")
.then(res => res.json()) .then((res) => {
.then(data => { if (!res.ok) throw new Error();
setActivity(data.data); return res.json();
}) })
.catch(error => { .then((data) => setActivity(data.data))
console.error("Error fetching activity:", error); .catch(() => {});
});
}, []); }, []);
if (!stats) return null; useEffect(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkipAnim(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, rootMargin: "-15% 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [stats]);
if (error) return null;
const progressColors = [ const progressColors = [
"bg-red-bright", "bg-red-bright",
@@ -38,138 +69,163 @@ const DetailedStats = () => {
"bg-green-bright", "bg-green-bright",
"bg-blue-bright", "bg-blue-bright",
"bg-purple-bright", "bg-purple-bright",
"bg-aqua-bright" "bg-aqua-bright",
]; ];
const statCards = stats
? [
{
title: "Total Time",
value: `${Math.round((stats.total_seconds / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "this week",
color: "text-yellow-bright",
borderHover: "hover:border-yellow-bright/50",
icon: Clock,
iconColor: "stroke-yellow-bright",
},
{
title: "Daily Average",
value: `${Math.round((stats.daily_average / 3600) * 10) / 10}`,
unit: "hours",
subtitle: "per day",
color: "text-orange-bright",
borderHover: "hover:border-orange-bright/50",
icon: CalendarClock,
iconColor: "stroke-orange-bright",
},
{
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",
borderHover: "hover:border-blue-bright/50",
icon: CodeXml,
iconColor: "stroke-blue-bright",
},
{
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",
borderHover: "hover:border-green-bright/50",
icon: Computer,
iconColor: "stroke-green-bright",
},
]
: [];
const languages =
stats?.languages?.slice(0, 7).map((lang: any, index: number) => ({
name: lang.name,
percent: Math.round(lang.percent),
time: Math.round((lang.total_seconds / 3600) * 10) / 10 + " hrs",
color: progressColors[index % progressColors.length],
})) || [];
return ( return (
<div className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4"> <div ref={containerRef} className="flex flex-col gap-10 md:py-32 w-full max-w-[1200px] mx-auto px-4 min-h-[50vh]">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright"> {!stats ? null : (
Weekly Statistics <>
</h2> {/* Header */}
<h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
style={skipAnim ? {} : {
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
}}
>
Weekly Statistics
</h2>
{/* Top Stats Grid */} {/* Stat Cards */}
<div className={` <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
grid grid-cols-1 md:grid-cols-2 gap-8 {statCards.map((card, i) => {
transition-all duration-700 transform const Icon = card.icon;
${isVisible ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'} return (
`}> <div
{/* Total Time */} key={card.title}
<StatsCard className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`}
title="Total Time" style={skipAnim ? {} : {
value={`${Math.round(stats.total_seconds / 3600 * 10) / 10}`} transitionDelay: `${150 + i * 100}ms`,
unit="hours" opacity: visible ? 1 : 0,
subtitle="this week" transform: visible ? "translateY(0)" : "translateY(24px)",
color="text-yellow-bright" }}
icon={Clock} >
iconColor="stroke-yellow-bright" <div className="flex gap-4 items-center">
/> <div className="p-3 rounded-lg bg-foreground/5">
<Icon className={`w-6 h-6 ${card.iconColor}`} strokeWidth={1.5} />
</div>
<div className="flex flex-col">
<div className={`${card.color} text-sm mb-1`}>{card.title}</div>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{card.value}</div>
<div className="text-lg opacity-80">{card.unit}</div>
</div>
<div className="text-xs opacity-50 mt-0.5">{card.subtitle}</div>
</div>
</div>
</div>
);
})}
</div>
{/* Daily Average */} {/* Languages */}
<StatsCard <div
title="Daily Average" className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
value={`${Math.round(stats.daily_average / 3600 * 10) / 10}`} style={skipAnim ? {} : {
unit="hours" transitionDelay: "550ms",
subtitle="per day" opacity: visible ? 1 : 0,
color="text-orange-bright" transform: visible ? "translateY(0)" : "translateY(24px)",
icon={CalendarClock} }}
iconColor="stroke-orange-bright" >
/> <div className="text-purple-bright mb-6 text-lg">Languages</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-5">
{languages.map((lang: any, i: number) => (
<div key={lang.name} className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">{lang.name}</span>
<span className="text-sm opacity-70 tabular-nums">{lang.time}</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 ${lang.color} rounded-full`}
style={{
width: visible ? `${lang.percent}%` : "0%",
opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
}}
/>
</div>
<span className="text-xs text-foreground/50 min-w-[36px] text-right tabular-nums">
{lang.percent}%
</span>
</div>
</div>
))}
</div>
</div>
{/* Editors */} {/* Activity Grid */}
<StatsCard {activity && (
title="Primary Editor" <div
value={stats.editors?.[0]?.name || "None"} className={skipAnim ? "" : "transition-all duration-700 ease-out"}
unit={`${Math.round(stats.editors?.[0]?.percent || 0)}%`} style={skipAnim ? {} : {
subtitle="of the time" transitionDelay: "750ms",
color="text-blue-bright" opacity: visible ? 1 : 0,
icon={CodeXml} transform: visible ? "translateY(0)" : "translateY(24px)",
iconColor="stroke-blue-bright" }}
/> >
<ActivityGrid data={activity} />
{/* OS */} </div>
<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> </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; export default DetailedStats;

View File

@@ -1,78 +1,181 @@
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { Check, Code, GitBranch, Star } from "lucide-react"; import { Check, Code, GitBranch, Star, Rocket } from "lucide-react";
const timelineItems = [
{
year: "2026",
title: "Present",
description: "Building domain-specific languages, diving deep into the Salesforce ecosystem, and writing production Java and Python daily. The craft keeps evolving.",
technologies: ["Java", "Python", "Salesforce", "DSLs"],
icon: <Rocket className="text-red-bright" size={20} />,
},
{
year: "2024",
title: "Shipping & Scaling",
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.",
technologies: ["Rust", "Typescript", "Go", "Postgres"],
icon: <Code className="text-yellow-bright" size={20} />,
},
{
year: "2022",
title: "Diving Deeper",
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.",
technologies: ["C++", "Cmake", "Docker", "Docker Compose"],
icon: <GitBranch className="text-green-bright" size={20} />,
},
{
year: "2020",
title: "Exploring the Stack",
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.",
technologies: ["Javascript", "Tailwind", "React", "Express"],
icon: <Star className="text-blue-bright" size={20} />,
},
{
year: "2018",
title: "Starting the Journey",
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
icon: <Check className="text-purple-bright" size={20} />,
},
];
function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const isLeft = index % 2 === 0;
return (
<div ref={ref} className="relative mb-8 md:mb-12 last:mb-0">
<div className={`flex flex-col sm:flex-row items-start ${isLeft ? "sm:flex-row-reverse" : ""}`}>
{/* Node */}
<div
className={`
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`}
>
{item.icon}
</div>
{/* Card */}
<div
className={`
w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"}
${visible
? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
}
`}
>
<div
className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-colors duration-300"
>
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
<div className="flex flex-wrap gap-2 mt-3">
{item.technologies.map((tech) => (
<span
key={tech}
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
text-foreground/60 hover:text-yellow-bright transition-colors duration-300"
>
{tech}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export default function Timeline() { export default function Timeline() {
const timelineItems = [ const lineRef = useRef<HTMLDivElement>(null);
{ const containerRef = useRef<HTMLDivElement>(null);
year: "2024", const [lineHeight, setLineHeight] = useState(0);
title: "Present",
description: "The wisdom of past ventures now flows through my work, whether crafting elegant CRUD applications or embarking on bold projects that expand my limits.", useEffect(() => {
technologies: ["Rust", "Typescript", "Go", "Postgres"], const container = containerRef.current;
icon: <Code className="text-yellow-bright" size={20} /> if (!container) return;
},
{ const observer = new IntersectionObserver(
year: "2022", ([entry]) => {
title: "Diving Deeper", if (entry.isIntersecting) {
description: "The worlds of systems programming and scalable infrastructure collided as I explored low-level C++ graphics programming and containerization with Docker.", // Animate line to full height over time
technologies: ["C++", "Cmake", "Docker", "Docker Compose"], const el = lineRef.current;
icon: <GitBranch className="text-green-bright" size={20} /> if (el) {
}, setLineHeight(100);
{ }
year: "2020", observer.disconnect();
title: "Exploring the Stack", }
description: "Starting with pure HTML and CSS, I explored the foundations of web development, gradually venturing into JavaScript and React to bring my static pages to life.", },
technologies: ["Javascript", "Tailwind", "React", "Express"], { threshold: 0.1 }
icon: <Star className="text-blue-bright" size={20} /> );
},
{ observer.observe(container);
year: "2018", return () => observer.disconnect();
title: "Starting the Journey", }, []);
description: "An elective Python class in 8th grade transformed my keen interest in programming into a relentless obsession, one that drove me to constantly explore new depths.",
technologies: ["Python", "Discord.py", "Asyncio", "Sqlite"],
icon: <Check className="text-purple-bright" size={20} />
}
];
return ( return (
<div className="w-full max-w-6xl px-4 py-8 relative z-0"> <div className="w-full max-w-6xl px-4 py-8 relative z-0">
<h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12"> <h2 className="text-2xl md:text-4xl font-bold text-center text-yellow-bright mb-8 md:mb-12">
Journey Through Code My Journey Through Code
</h2> </h2>
<div className="relative"> <div ref={containerRef} className="relative">
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 bg-foreground/10 -translate-x-1/2" /> {/* Animated vertical line */}
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div
ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }}
/>
</div>
<div className="ml-8 sm:ml-0"> <div className="ml-8 sm:ml-0">
{timelineItems.map((item, index) => ( {timelineItems.map((item, index) => (
<div key={item.year} className="relative mb-8 md:mb-12 last:mb-0"> <TimelineCard key={item.year} item={item} index={index} />
<div className={`flex flex-col sm:flex-row items-start ${
index % 2 === 0 ? 'sm:flex-row-reverse' : ''
}`}>
<div className="absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10">
{item.icon}
</div>
<div className={`w-full sm:w-[calc(50%-32px)] ${
index % 2 === 0 ? 'sm:pr-8 md:pr-12' : 'sm:pl-8 md:pl-12'
}`}>
<div className="p-4 sm:p-6 bg-background/50 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-colors duration-300">
<span className="text-xs sm:text-sm font-mono text-yellow-bright">{item.year}</span>
<h3 className="text-lg sm:text-xl font-bold text-foreground/90 mt-2">{item.title}</h3>
<p className="text-sm sm:text-base text-foreground/70 mt-2">{item.description}</p>
<div className="flex flex-wrap gap-2 mt-3">
{item.technologies.map((tech) => (
<span key={tech}
className="px-2 py-1 text-xs sm:text-sm rounded-full bg-foreground/5
text-foreground/60 hover:text-yellow-bright transition-colors duration-300">
{tech}
</span>
))}
</div>
</div>
</div>
</div>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useRef, useState } from "react";
interface AnimateInProps {
children: React.ReactNode;
delay?: number;
threshold?: number;
}
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}

View File

@@ -18,6 +18,8 @@ interface Cell {
transitioning: boolean; transitioning: boolean;
transitionComplete: boolean; transitionComplete: boolean;
rippleEffect: number; // For ripple animation rippleEffect: number; // For ripple animation
rippleStartTime: number; // When ripple started
rippleDistance: number; // Distance from ripple center
} }
interface Grid { interface Grid {
@@ -42,16 +44,19 @@ interface BackgroundProps {
position?: 'left' | 'right'; position?: 'left' | 'right';
} }
const CELL_SIZE = 25; const CELL_SIZE_MOBILE = 15;
const CELL_SIZE_DESKTOP = 25;
const TARGET_FPS = 60; // Target frame rate
const CYCLE_TIME = 3000; // 3 seconds per full cycle, regardless of FPS
const TRANSITION_SPEED = 0.05; const TRANSITION_SPEED = 0.05;
const SCALE_SPEED = 0.05; const SCALE_SPEED = 0.05;
const CYCLE_FRAMES = 180;
const INITIAL_DENSITY = 0.15; const INITIAL_DENSITY = 0.15;
const SIDEBAR_WIDTH = 240; const SIDEBAR_WIDTH = 240;
const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels const MOUSE_INFLUENCE_RADIUS = 150; // Radius of mouse influence in pixels
const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount const COLOR_SHIFT_AMOUNT = 30; // Maximum color shift amount
const RIPPLE_SPEED = 0.2; // Speed of ripple propagation const RIPPLE_SPEED = 0.02; // Speed of ripple propagation
const ELEVATION_FACTOR = 15; // Max height for 3D effect const RIPPLE_ELEVATION_FACTOR = 4; // Height of ripple wave
const ELEVATION_FACTOR = 8; // Max height for 3D effect - reduced for more subtle effect
const Background: React.FC<BackgroundProps> = ({ const Background: React.FC<BackgroundProps> = ({
layout = 'index', layout = 'index',
@@ -60,7 +65,8 @@ const Background: React.FC<BackgroundProps> = ({
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const gridRef = useRef<Grid>(); const gridRef = useRef<Grid>();
const animationFrameRef = useRef<number>(); const animationFrameRef = useRef<number>();
const frameCount = useRef(0); const lastUpdateTimeRef = useRef<number>(0);
const lastCycleTimeRef = useRef<number>(0);
const resizeTimeoutRef = useRef<NodeJS.Timeout>(); const resizeTimeoutRef = useRef<NodeJS.Timeout>();
const mouseRef = useRef<MousePosition>({ const mouseRef = useRef<MousePosition>({
x: -1000, x: -1000,
@@ -83,11 +89,18 @@ const Background: React.FC<BackgroundProps> = ({
return colors[Math.floor(Math.random() * colors.length)]; return colors[Math.floor(Math.random() * colors.length)];
}; };
const getCellSize = () => {
// Check if we're on mobile based on screen width
const isMobile = window.innerWidth <= 768;
return isMobile ? CELL_SIZE_MOBILE : CELL_SIZE_DESKTOP;
};
const calculateGridDimensions = (width: number, height: number) => { const calculateGridDimensions = (width: number, height: number) => {
const cols = Math.floor(width / CELL_SIZE); const cellSize = getCellSize();
const rows = Math.floor(height / CELL_SIZE); const cols = Math.floor(width / cellSize);
const offsetX = Math.floor((width - (cols * CELL_SIZE)) / 2); const rows = Math.floor(height / cellSize);
const offsetY = Math.floor((height - (rows * CELL_SIZE)) / 2); const offsetX = Math.floor((width - (cols * cellSize)) / 2);
const offsetY = Math.floor((height - (rows * cellSize)) / 2);
return { cols, rows, offsetX, offsetY }; return { cols, rows, offsetX, offsetY };
}; };
@@ -114,7 +127,9 @@ const Background: React.FC<BackgroundProps> = ({
targetElevation: 0, targetElevation: 0,
transitioning: false, transitioning: false,
transitionComplete: false, transitionComplete: false,
rippleEffect: 0 rippleEffect: 0,
rippleStartTime: 0,
rippleDistance: 0
}; };
}) })
); );
@@ -224,7 +239,7 @@ const Background: React.FC<BackgroundProps> = ({
}; };
const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => { const createRippleEffect = (grid: Grid, centerX: number, centerY: number) => {
const maxDistance = Math.max(grid.cols, grid.rows) / 2; const currentTime = Date.now();
for (let i = 0; i < grid.cols; i++) { for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) { for (let j = 0; j < grid.rows; j++) {
@@ -237,15 +252,9 @@ const Background: React.FC<BackgroundProps> = ({
// Only apply ripple to visible cells // Only apply ripple to visible cells
if (cell.opacity > 0.1) { if (cell.opacity > 0.1) {
// Delayed animation based on distance from center cell.rippleStartTime = currentTime + distance * 100; // Delayed start based on distance
setTimeout(() => { cell.rippleDistance = distance;
cell.rippleEffect = 1; // Start ripple cell.rippleEffect = 0;
// After a short time, reset ripple
setTimeout(() => {
cell.rippleEffect = 0;
}, 300 + distance * 50);
}, distance * 100);
} }
} }
} }
@@ -272,51 +281,51 @@ const Background: React.FC<BackgroundProps> = ({
} }
}; };
const updateCellAnimations = (grid: Grid) => { const updateCellAnimations = (grid: Grid, deltaTime: number) => {
const mouseX = mouseRef.current.x; const mouseX = mouseRef.current.x;
const mouseY = mouseRef.current.y; const mouseY = mouseRef.current.y;
const cellSize = getCellSize();
// Adjust transition speeds based on time
const transitionFactor = TRANSITION_SPEED * (deltaTime / (1000 / TARGET_FPS));
const scaleFactor = SCALE_SPEED * (deltaTime / (1000 / TARGET_FPS));
for (let i = 0; i < grid.cols; i++) { for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) { for (let j = 0; j < grid.rows; j++) {
const cell = grid.cells[i][j]; const cell = grid.cells[i][j];
// Smooth transitions // Smooth transitions
cell.opacity += (cell.targetOpacity - cell.opacity) * TRANSITION_SPEED; cell.opacity += (cell.targetOpacity - cell.opacity) * transitionFactor;
cell.scale += (cell.targetScale - cell.scale) * SCALE_SPEED; cell.scale += (cell.targetScale - cell.scale) * scaleFactor;
cell.elevation += (cell.targetElevation - cell.elevation) * SCALE_SPEED; cell.elevation += (cell.targetElevation - cell.elevation) * scaleFactor;
// Apply mouse interaction // Apply mouse interaction
const cellCenterX = grid.offsetX + i * CELL_SIZE + CELL_SIZE / 2; const cellCenterX = grid.offsetX + i * cellSize + cellSize / 2;
const cellCenterY = grid.offsetY + j * CELL_SIZE + CELL_SIZE / 2; const cellCenterY = grid.offsetY + j * cellSize + cellSize / 2;
const dx = cellCenterX - mouseX; const dx = cellCenterX - mouseX;
const dy = cellCenterY - mouseY; const dy = cellCenterY - mouseY;
const distanceToMouse = Math.sqrt(dx * dx + dy * dy); const distanceToMouse = Math.sqrt(dx * dx + dy * dy);
// Color wave effect based on mouse position // 3D hill effect based on mouse position
if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) { if (distanceToMouse < MOUSE_INFLUENCE_RADIUS && cell.opacity > 0.1) {
// Calculate color adjustment based on distance // Calculate height based on distance - peak at center, gradually decreasing
const influenceFactor = 1 - (distanceToMouse / MOUSE_INFLUENCE_RADIUS); const influenceFactor = Math.cos((distanceToMouse / MOUSE_INFLUENCE_RADIUS) * Math.PI / 2);
// Only positive elevation (growing upward)
cell.targetElevation = ELEVATION_FACTOR * influenceFactor * influenceFactor; // squared for more pronounced effect
// Wave effect with sine function // Slight color shift as cells rise
const waveOffset = (frameCount.current * 0.05 + distanceToMouse * 0.05) % (Math.PI * 2); const colorShift = influenceFactor * COLOR_SHIFT_AMOUNT * 0.5;
const waveFactor = (Math.sin(waveOffset) * 0.5 + 0.5) * influenceFactor;
// Adjust color based on wave
cell.color = [ cell.color = [
Math.min(255, Math.max(0, cell.baseColor[0] + COLOR_SHIFT_AMOUNT * waveFactor)), Math.min(255, Math.max(0, cell.baseColor[0] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[1] - COLOR_SHIFT_AMOUNT * waveFactor)), Math.min(255, Math.max(0, cell.baseColor[1] + colorShift)),
Math.min(255, Math.max(0, cell.baseColor[2] + COLOR_SHIFT_AMOUNT * waveFactor)) Math.min(255, Math.max(0, cell.baseColor[2] + colorShift))
] as [number, number, number]; ] as [number, number, number];
// 3D elevation effect when mouse is close
cell.targetElevation = ELEVATION_FACTOR * influenceFactor;
} else { } else {
// Gradually return to base color when mouse is away // Gradually return to base color and zero elevation when mouse is away
cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1; cell.color[0] += (cell.baseColor[0] - cell.color[0]) * 0.1;
cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1; cell.color[1] += (cell.baseColor[1] - cell.color[1]) * 0.1;
cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1; cell.color[2] += (cell.baseColor[2] - cell.color[2]) * 0.1;
// Reset elevation when mouse moves away
cell.targetElevation = 0; cell.targetElevation = 0;
} }
@@ -339,9 +348,34 @@ const Background: React.FC<BackgroundProps> = ({
} }
} }
// Gradually decrease ripple effect // Handle ripple animation
if (cell.rippleEffect > 0) { if (cell.rippleStartTime > 0) {
cell.rippleEffect = Math.max(0, cell.rippleEffect - RIPPLE_SPEED); const elapsedTime = Date.now() - cell.rippleStartTime;
if (elapsedTime > 0) {
// Calculate ripple progress (0 to 1)
const rippleProgress = elapsedTime / 1000; // 1 second for full animation
if (rippleProgress < 1) {
// Create a smooth wave effect
const wavePhase = rippleProgress * Math.PI * 2;
const waveHeight = Math.sin(wavePhase) * Math.exp(-rippleProgress * 4);
// Apply wave height to cell elevation only if it's not being overridden by mouse
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.rippleEffect = waveHeight;
cell.targetElevation = RIPPLE_ELEVATION_FACTOR * waveHeight;
} else {
cell.rippleEffect = waveHeight * 0.3; // Reduced effect when mouse is influencing
}
} else {
// Reset ripple effects
cell.rippleEffect = 0;
cell.rippleStartTime = 0;
if (distanceToMouse >= MOUSE_INFLUENCE_RADIUS) {
cell.targetElevation = 0;
}
}
}
} }
} }
} }
@@ -355,14 +389,22 @@ const Background: React.FC<BackgroundProps> = ({
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mouseY = e.clientY - rect.top;
// Ignore clicks outside the canvas bounds
if (mouseX < 0 || mouseX > rect.width || mouseY < 0 || mouseY > rect.height) return;
// Prevent text selection when interacting with the canvas
e.preventDefault();
const cellSize = getCellSize();
mouseRef.current.isDown = true; mouseRef.current.isDown = true;
mouseRef.current.lastClickTime = Date.now(); mouseRef.current.lastClickTime = Date.now();
const grid = gridRef.current; const grid = gridRef.current;
// Calculate which cell was clicked // Calculate which cell was clicked
const cellX = Math.floor((mouseX - grid.offsetX) / CELL_SIZE); const cellX = Math.floor((mouseX - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseY - grid.offsetY) / CELL_SIZE); const cellY = Math.floor((mouseY - grid.offsetY) / cellSize);
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) { if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
mouseRef.current.cellX = cellX; mouseRef.current.cellX = cellX;
@@ -381,13 +423,37 @@ const Background: React.FC<BackgroundProps> = ({
}; };
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current) return; if (!canvasRef.current || !gridRef.current) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const cellSize = getCellSize();
mouseRef.current.x = e.clientX - rect.left; mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top; mouseRef.current.y = e.clientY - rect.top;
// Drawing functionality - place cells while dragging
if (mouseRef.current.isDown) {
const grid = gridRef.current;
// Calculate which cell the mouse is over
const cellX = Math.floor((mouseRef.current.x - grid.offsetX) / cellSize);
const cellY = Math.floor((mouseRef.current.y - grid.offsetY) / cellSize);
// Only draw if we're on a new cell
if (cellX !== mouseRef.current.cellX || cellY !== mouseRef.current.cellY) {
mouseRef.current.cellX = cellX;
mouseRef.current.cellY = cellY;
// Spawn cell at this position if it's empty
if (cellX >= 0 && cellX < grid.cols && cellY >= 0 && cellY < grid.rows) {
const cell = grid.cells[cellX][cellY];
if (!cell.alive && !cell.transitioning) {
spawnCellAtPosition(grid, cellX, cellY);
}
}
}
}
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
@@ -439,12 +505,15 @@ const Background: React.FC<BackgroundProps> = ({
const ctx = setupCanvas(canvas, displayWidth, displayHeight); const ctx = setupCanvas(canvas, displayWidth, displayHeight);
if (!ctx) return; if (!ctx) return;
frameCount.current = 0; lastUpdateTimeRef.current = 0;
lastCycleTimeRef.current = 0;
const cellSize = getCellSize();
// Only initialize new grid if one doesn't exist or dimensions changed // Only initialize new grid if one doesn't exist or dimensions changed
if (!gridRef.current || if (!gridRef.current ||
gridRef.current.cols !== Math.floor(displayWidth / CELL_SIZE) || gridRef.current.cols !== Math.floor(displayWidth / cellSize) ||
gridRef.current.rows !== Math.floor(displayHeight / CELL_SIZE)) { gridRef.current.rows !== Math.floor(displayHeight / cellSize)) {
gridRef.current = initGrid(displayWidth, displayHeight); gridRef.current = initGrid(displayWidth, displayHeight);
} }
}, 250); }, 250);
@@ -461,24 +530,57 @@ const Background: React.FC<BackgroundProps> = ({
gridRef.current = initGrid(displayWidth, displayHeight); gridRef.current = initGrid(displayWidth, displayHeight);
} }
// Add mouse event listeners // Bind to window so mouse events work even when content overlays the canvas
canvas.addEventListener('mousedown', handleMouseDown, { signal }); window.addEventListener('mousedown', handleMouseDown, { signal });
canvas.addEventListener('mousemove', handleMouseMove, { signal }); window.addEventListener('mousemove', handleMouseMove, { signal });
canvas.addEventListener('mouseup', handleMouseUp, { signal }); window.addEventListener('mouseup', handleMouseUp, { signal });
canvas.addEventListener('mouseleave', handleMouseLeave, { signal });
const animate = () => { const handleVisibilityChange = () => {
if (document.hidden) {
// Tab is hidden
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
} else {
// Tab is visible again
if (!animationFrameRef.current) {
// Reset timing references to prevent catching up
lastUpdateTimeRef.current = performance.now();
lastCycleTimeRef.current = performance.now();
animationFrameRef.current = requestAnimationFrame(animate);
}
}
};
const animate = (currentTime: number) => {
if (signal.aborted) return; if (signal.aborted) return;
frameCount.current++; // Initialize timing if first frame
if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = currentTime;
lastCycleTimeRef.current = currentTime;
}
// Calculate time since last frame
const deltaTime = currentTime - lastUpdateTimeRef.current;
// Limit delta time to prevent large jumps when tab becomes active again
const clampedDeltaTime = Math.min(deltaTime, 100);
lastUpdateTimeRef.current = currentTime;
// Calculate time since last cycle update
const cycleElapsed = currentTime - lastCycleTimeRef.current;
if (gridRef.current) { if (gridRef.current) {
// Every CYCLE_FRAMES, compute the next state // Check if it's time for the next life cycle
if (frameCount.current % CYCLE_FRAMES === 0) { if (cycleElapsed >= CYCLE_TIME) {
computeNextState(gridRef.current); computeNextState(gridRef.current);
lastCycleTimeRef.current = currentTime;
} }
updateCellAnimations(gridRef.current); updateCellAnimations(gridRef.current, clampedDeltaTime);
} }
// Draw frame // Draw frame
@@ -487,8 +589,9 @@ const Background: React.FC<BackgroundProps> = ({
if (gridRef.current) { if (gridRef.current) {
const grid = gridRef.current; const grid = gridRef.current;
const cellSize = CELL_SIZE * 0.8; const cellSize = getCellSize();
const roundness = cellSize * 0.2; const displayCellSize = cellSize * 0.8;
const roundness = displayCellSize * 0.2;
for (let i = 0; i < grid.cols; i++) { for (let i = 0; i < grid.cols; i++) {
for (let j = 0; j < grid.rows; j++) { for (let j = 0; j < grid.rows; j++) {
@@ -496,71 +599,38 @@ const Background: React.FC<BackgroundProps> = ({
// Draw all transitioning cells, even if they're fading out // Draw all transitioning cells, even if they're fading out
if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) { if ((cell.alive || cell.targetOpacity > 0) && cell.opacity > 0.01) {
const [r, g, b] = cell.color; const [r, g, b] = cell.color;
ctx.fillStyle = `rgb(${r},${g},${b})`;
// Apply ripple and elevation effects to opacity // Base opacity
const rippleBoost = cell.rippleEffect * 0.4; // Boost opacity during ripple ctx.globalAlpha = cell.opacity * 0.9;
ctx.globalAlpha = Math.min(1, cell.opacity * 0.8 + rippleBoost);
const scaledSize = cellSize * cell.scale; const scaledSize = displayCellSize * cell.scale;
const xOffset = (cellSize - scaledSize) / 2; const xOffset = (displayCellSize - scaledSize) / 2;
const yOffset = (cellSize - scaledSize) / 2; const yOffset = (displayCellSize - scaledSize) / 2;
// Apply 3D elevation effect // Apply 3D elevation effect
const elevationOffset = cell.elevation; const elevationOffset = cell.elevation;
const x = grid.offsetX + i * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + xOffset; const x = grid.offsetX + i * cellSize + (cellSize - displayCellSize) / 2 + xOffset;
const y = grid.offsetY + j * CELL_SIZE + (CELL_SIZE - cellSize) / 2 + yOffset - elevationOffset; const y = grid.offsetY + j * cellSize + (cellSize - displayCellSize) / 2 + yOffset - elevationOffset;
const scaledRoundness = roundness * cell.scale; const scaledRoundness = roundness * cell.scale;
// Draw shadow for 3D effect if cell has elevation // Draw shadow for 3D effect when cell is elevated
if (elevationOffset > 1) { if (elevationOffset > 0.5) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillStyle = `rgba(0, 0, 0, ${0.2 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + elevationOffset + 5); ctx.moveTo(x + scaledRoundness, y + elevationOffset * 1.1);
ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset + 5); ctx.lineTo(x + scaledSize - scaledRoundness, y + elevationOffset * 1.1);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5, x + scaledSize, y + elevationOffset + 5 + scaledRoundness); ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1, x + scaledSize, y + elevationOffset * 1.1 + scaledRoundness);
ctx.lineTo(x + scaledSize, y + elevationOffset + 5 + scaledSize - scaledRoundness); ctx.lineTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset + 5 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset + 5 + scaledSize); ctx.quadraticCurveTo(x + scaledSize, y + elevationOffset * 1.1 + scaledSize, x + scaledSize - scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.lineTo(x + scaledRoundness, y + elevationOffset + 5 + scaledSize); ctx.lineTo(x + scaledRoundness, y + elevationOffset * 1.1 + scaledSize);
ctx.quadraticCurveTo(x, y + elevationOffset + 5 + scaledSize, x, y + elevationOffset + 5 + scaledSize - scaledRoundness); ctx.quadraticCurveTo(x, y + elevationOffset * 1.1 + scaledSize, x, y + elevationOffset * 1.1 + scaledSize - scaledRoundness);
ctx.lineTo(x, y + elevationOffset + 5 + scaledRoundness); ctx.lineTo(x, y + elevationOffset * 1.1 + scaledRoundness);
ctx.quadraticCurveTo(x, y + elevationOffset + 5, x + scaledRoundness, y + elevationOffset + 5); ctx.quadraticCurveTo(x, y + elevationOffset * 1.1, x + scaledRoundness, y + elevationOffset * 1.1);
ctx.fill();
// Draw side of elevated cell
const sideHeight = elevationOffset;
ctx.fillStyle = `rgba(${r*0.7}, ${g*0.7}, ${b*0.7}, ${ctx.globalAlpha})`;
// Left side
ctx.beginPath();
ctx.moveTo(x, y + scaledRoundness);
ctx.lineTo(x, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x, y + scaledSize - scaledRoundness);
ctx.fill();
// Right side
ctx.beginPath();
ctx.moveTo(x + scaledSize, y + scaledRoundness);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize, y + scaledSize - scaledRoundness);
ctx.fill();
// Bottom side
ctx.fillStyle = `rgba(${r*0.5}, ${g*0.5}, ${b*0.5}, ${ctx.globalAlpha})`;
ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize);
ctx.lineTo(x + scaledSize - scaledRoundness, y + scaledSize + sideHeight);
ctx.lineTo(x + scaledRoundness, y + scaledSize + sideHeight);
ctx.fill(); ctx.fill();
} }
// Draw main cell with original color // Draw main cell
ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y); ctx.moveTo(x + scaledRoundness, y);
@@ -574,9 +644,9 @@ const Background: React.FC<BackgroundProps> = ({
ctx.quadraticCurveTo(x, y, x + scaledRoundness, y); ctx.quadraticCurveTo(x, y, x + scaledRoundness, y);
ctx.fill(); ctx.fill();
// Draw highlight on top for 3D effect // Draw highlight on elevated cells
if (elevationOffset > 1) { if (elevationOffset > 0.5) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.2 * elevationOffset / ELEVATION_FACTOR})`; ctx.fillStyle = `rgba(255, 255, 255, ${0.1 * (elevationOffset / ELEVATION_FACTOR)})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + scaledRoundness, y); ctx.moveTo(x + scaledRoundness, y);
ctx.lineTo(x + scaledSize - scaledRoundness, y); ctx.lineTo(x + scaledSize - scaledRoundness, y);
@@ -588,23 +658,7 @@ const Background: React.FC<BackgroundProps> = ({
ctx.fill(); ctx.fill();
} }
// Draw ripple effect // No need for separate ripple drawing since the elevation handles the 3D ripple effect
if (cell.rippleEffect > 0) {
const rippleRadius = cell.rippleEffect * cellSize * 2;
const rippleAlpha = (1 - cell.rippleEffect) * 0.5;
ctx.strokeStyle = `rgba(255, 255, 255, ${rippleAlpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
x + scaledSize / 2,
y + scaledSize / 2,
rippleRadius,
0,
Math.PI * 2
);
ctx.stroke();
}
} }
} }
} }
@@ -615,11 +669,13 @@ const Background: React.FC<BackgroundProps> = ({
animationFrameRef.current = requestAnimationFrame(animate); animationFrameRef.current = requestAnimationFrame(animate);
}; };
document.addEventListener('visibilitychange', handleVisibilityChange, { signal });
window.addEventListener('resize', handleResize, { signal }); window.addEventListener('resize', handleResize, { signal });
animate(); animate(performance.now());
return () => { return () => {
controller.abort(); controller.abort();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
if (animationFrameRef.current) { if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current); cancelAnimationFrame(animationFrameRef.current);
@@ -645,7 +701,8 @@ const Background: React.FC<BackgroundProps> = ({
<div className={getContainerClasses()}> <div className={getContainerClasses()}>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full bg-black cursor-pointer" className="w-full h-full bg-black"
style={{ cursor: 'default' }} // Changed from cursor-pointer to default
/> />
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" /> <div className="absolute inset-0 bg-black/30 backdrop-blur-sm pointer-events-none" />
</div> </div>

View File

@@ -26,7 +26,7 @@ export const Comments = () => {
emitMetadata="0" emitMetadata="0"
inputPosition="bottom" inputPosition="bottom"
lang="en" lang="en"
loading="lazy" loading="eager"
/> />
) : null} ) : null}
</div> </div>

View File

@@ -1,38 +1,43 @@
import React from "react"; import React from "react";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react"; import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
export const BlogHeader = () => { export const BlogHeader = () => {
return ( return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24"> <div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24">
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed"> <AnimateIn>
Latest Thoughts <br className="sm:hidden" /> <h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
& Writings Latest Thoughts <br className="sm:hidden" />
</h1> & Writings
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base"> </h1>
<a </AnimateIn>
href="/rss" <AnimateIn delay={100}>
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200" <div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
> <a
<RssIcon className="w-4 h-4" /> href="/rss"
<span>RSS Feed</span> className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-orange hover:text-orange-bright hover:border-orange/50 transition-colors duration-200"
</a> >
<RssIcon className="w-4 h-4" />
<span>RSS Feed</span>
</a>
<a <a
href="/blog/tags" href="/blog/tags"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-aqua hover:text-aqua-bright hover:border-aqua/50 transition-colors duration-200"
> >
<TagIcon className="w-4 h-4" /> <TagIcon className="w-4 h-4" />
<span>Browse Tags</span> <span>Browse Tags</span>
</a> </a>
<a <a
href="/blog/popular" href="/blog/popular"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-background border border-foreground/20 text-blue hover:text-blue-bright hover:border-blue/50 transition-colors duration-200"
> >
<TrendingUpIcon className="w-4 h-4" /> <TrendingUpIcon className="w-4 h-4" />
<span>Most Popular</span> <span>Most Popular</span>
</a> </a>
</div> </div>
</AnimateIn>
</div> </div>
); );
}; };

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { AnimateIn } from "@/components/animate-in";
type BlogPost = { type BlogPost = {
slug: string; id: string;
data: { data: {
title: string; title: string;
author: string; author: string;
@@ -29,69 +30,71 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
return ( return (
<div className="w-full max-w-6xl mx-auto"> <div className="w-full max-w-6xl mx-auto">
<ul className="space-y-6 md:space-y-10"> <ul className="space-y-6 md:space-y-10">
{posts.map((post) => ( {posts.map((post, i) => (
<li key={post.slug} className="group px-4 md:px-0"> <AnimateIn key={post.id} delay={i * 80}>
<a <li className="group px-4 md:px-0">
href={`/blog/${post.slug}`} <a
className="block" href={`/blog/${post.id}`}
> className="block"
<article className="flex flex-col md:flex-row gap-4 md:gap-8 pb-6 md:pb-10 border-b border-foreground/20 last:border-b-0 p-2 md:p-4 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200"> >
{/* Image container with fixed aspect ratio */} <article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background"> {/* Image container with fixed aspect ratio */}
<img <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
src={post.data.image || "/blog/placeholder.png"} <img
alt={post.data.title} src={post.data.image || "/blog/placeholder.png"}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" alt={post.data.title}
style={{ objectPosition: post.data.imagePosition || "center center" }} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/> style={{ objectPosition: post.data.imagePosition || "center center" }}
</div> />
</div>
{/* Content container */} {/* Content container */}
<div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-4 py-1 md:py-2"> <div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
{/* Title and meta info */} {/* Title and meta info */}
<div className="space-y-1.5 md:space-y-3"> <div className="space-y-1.5 md:space-y-3">
<h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2"> <h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-purple transition-colors duration-200 line-clamp-2">
{post.data.title} {post.data.title}
</h2> </h2>
<div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80"> <div className="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<span className="text-orange">{post.data.author}</span> <span className="text-orange">{post.data.author}</span>
<span className="text-foreground/50"></span> <span className="text-foreground/50"></span>
<time dateTime={post.data.date} className="text-blue"> <time dateTime={post.data.date} className="text-blue">
{formatDate(post.data.date)} {formatDate(post.data.date)}
</time> </time>
</div>
</div>
{/* Description */}
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0">
{post.data.description}
</p>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tag/${tag}`;
}}
>
#{tag}
</span>
))}
{post.data.tags.length > 3 && (
<span className="text-xs md:text-base text-foreground/60">
+{post.data.tags.length - 3}
</span>
)}
</div> </div>
</div> </div>
</article>
{/* Description */} </a>
<p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2 mt-0.5 md:mt-0"> </li>
{post.data.description} </AnimateIn>
</p>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 md:gap-3 mt-1 md:mt-2">
{post.data.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
onClick={(e) => {
e.preventDefault();
window.location.href = `/blog/tag/${tag}`;
}}
>
#{tag}
</span>
))}
{post.data.tags.length > 3 && (
<span className="text-xs md:text-base text-foreground/60">
+{post.data.tags.length - 3}
</span>
)}
</div>
</div>
</article>
</a>
</li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -12,8 +12,8 @@ export default function Footer({ fixed = false }) {
)); ));
return ( return (
<footer className={`w-full font-bold ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}> <footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20"> <div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
{footerLinks} {footerLinks}
</div> </div>
</footer> </footer>

View File

@@ -87,16 +87,19 @@ export default function Header() {
fixed z-50 top-0 left-0 right-0 fixed z-50 top-0 left-0 right-0
font-bold font-bold
transition-transform duration-300 transition-transform duration-300
pointer-events-none
${visible ? "translate-y-0" : "-translate-y-full"} ${visible ? "translate-y-0" : "-translate-y-full"}
`} `}
> >
<div className={` <div className={`
w-full flex flex-row items-center justify-center w-full flex flex-row items-center justify-center
pointer-events-none
${!isIndexPage ? 'bg-black md:bg-transparent' : ''} ${!isIndexPage ? 'bg-black md:bg-transparent' : ''}
`}> `}>
<div className={` <div className={`
w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl w-full md:w-auto flex flex-row pt-1 px-2 text-lg lg:text-3xl md:text-2xl
items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2 items-center justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 md:py-2
pointer-events-none [&_a]:pointer-events-auto
${!isIndexPage ? 'bg-black md:px-20' : ''} ${!isIndexPage ? 'bg-black md:px-20' : ''}
`}> `}>
{headerLinks} {headerLinks}

View File

@@ -72,8 +72,8 @@ export default function Hero() {
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-4xl font-bold text-center"> <div className="text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
<Typewriter <Typewriter
options={typewriterOptions} options={typewriterOptions}
onInit={handleInit} onInit={handleInit}

View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { Terminal, Copy, Check } from 'lucide-react';
// Import all required icons from react-icons
import { FaDebian, FaFedora } from 'react-icons/fa6';
import { SiGentoo, SiNixos, SiArchlinux } from 'react-icons/si';
// Component for multi-line command sequences
const CommandSequence = ({
commands,
description,
shell = "bash"
}) => {
const [copied, setCopied] = useState(false);
// Join the commands with newlines for copying
const fullCommandText = Array.isArray(commands)
? commands.join('\n')
: commands;
const copyToClipboard = () => {
navigator.clipboard.writeText(fullCommandText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Commands"}
</div>
</div>
<button
onClick={copyToClipboard}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
</>
)}
</button>
</div>
{/* Command Display */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="font-comic-code text-sm">
{Array.isArray(commands)
? commands.map((cmd, index) => (
<div key={index} className="flex items-start mb-2 last:mb-0">
<span className="text-orange-bright mr-2 flex-shrink-0">$</span>
<span className="text-purple-bright overflow-x-auto whitespace-nowrap">
{cmd}
</span>
</div>
))
: (
<div className="flex items-start">
<span className="text-orange-bright mr-2 flex-shrink-0">$</span>
<span className="text-purple-bright overflow-x-auto whitespace-nowrap">
{commands}
</span>
</div>
)
}
</div>
</div>
</div>
);
};
// Original Commands component with tabs for different distros
const Commands = ({
commandId,
description,
archCommand,
debianCommand,
fedoraCommand,
gentooCommand,
nixCommand
}) => {
const [activeTab, setActiveTab] = useState('arch');
const [copied, setCopied] = useState(false);
const distros = [
{
id: 'arch',
name: 'Arch',
icon: SiArchlinux,
command: archCommand || 'echo "No command specified for Arch"'
},
{
id: 'debian',
name: 'Debian/Ubuntu',
icon: FaDebian,
command: debianCommand || 'echo "No command specified for Debian/Ubuntu"'
},
{
id: 'fedora',
name: 'Fedora',
icon: FaFedora,
command: fedoraCommand || 'echo "No command specified for Fedora"'
},
{
id: 'gentoo',
name: 'Gentoo',
icon: SiGentoo,
command: gentooCommand || 'echo "No command specified for Gentoo"'
},
{
id: 'nix',
name: 'NixOS',
icon: SiNixos,
command: nixCommand || 'echo "No command specified for NixOS"'
}
];
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Command"}
</div>
</div>
<button
onClick={() => copyToClipboard(distros.find(d => d.id === activeTab).command)}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Tabs */}
<div className="flex flex-wrap border-b border-foreground/20 bg-background">
{distros.map((distro) => {
const IconComponent = distro.icon;
return (
<button
key={distro.id}
className={`px-3 py-2 text-sm font-medium flex items-center ${
activeTab === distro.id
? 'bg-background border-b-2 border-blue-bright text-blue-bright'
: 'text-foreground/80 hover:text-foreground hover:bg-foreground/5'
}`}
onClick={() => setActiveTab(distro.id)}
>
<span className="mr-1 inline-flex items-center">
<IconComponent size={16} />
</span>
{distro.name}
</button>
);
})}
</div>
{/* Command Display with Horizontal Scrolling */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="flex items-center font-comic-code text-sm whitespace-nowrap">
<span className="text-orange-bright mr-2">$</span>
<span className="text-purple-bright">
{distros.find(d => d.id === activeTab).command}
</span>
</div>
</div>
</div>
);
};
// Single command component
const Command = ({
command,
description,
shell = "bash"
}) => {
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(command)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return (
<div className="w-full rounded-md overflow-hidden border border-foreground/20 bg-background my-4" style={{ maxWidth: '95vw' }}>
{/* Header with Terminal Icon and Copy Button */}
<div className="bg-background border-b border-foreground/20 text-foreground p-2 flex items-center justify-between">
<div className="flex items-center">
<Terminal size={20} className="mr-2 text-yellow-bright" />
<div className="text-sm font-comic-code">
{description || "Terminal Command"}
</div>
</div>
<button
onClick={copyToClipboard}
className="bg-background hover:bg-foreground/10 text-foreground text-xs px-2 py-1 rounded flex items-center"
>
{copied ? (
<>
<Check size={14} className="mr-1 text-green-bright" />
</>
) : (
<>
<Copy size={14} className="mr-1 text-foreground/70" />
</>
)}
</button>
</div>
{/* Command Display with Horizontal Scrolling */}
<div className="text-foreground p-3 overflow-x-auto">
<div className="flex items-center font-comic-code text-sm whitespace-nowrap">
<span className="text-orange-bright mr-2">$</span>
<span className="text-purple-bright">
{command}
</span>
</div>
</div>
</div>
);
};
export { Commands, Command, CommandSequence };

View File

@@ -0,0 +1,68 @@
// src/components/mdx/Video.tsx
import React, { useRef } from "react";
import { Play } from "lucide-react";
type VideoProps = {
url: string;
title: string;
attribution?: string;
className?: string;
};
export function Video({ url, title, attribution, className }: VideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const overlayRef = useRef<HTMLButtonElement>(null);
const handlePlay = () => {
if (!videoRef.current || !overlayRef.current) return;
// Show browser native controls on play
videoRef.current.controls = true;
videoRef.current.play();
// Hide the overlay
overlayRef.current.style.display = "none";
};
return (
<figure className={`w-full ${className ?? ""}`}>
<div className="relative w-full bg-background rounded-lg overflow-hidden">
<video
ref={videoRef}
className="w-full h-auto bg-black cursor-pointer rounded-lg block"
preload="metadata"
playsInline
title={title}
>
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Big overlay play button */}
<button
ref={overlayRef}
onClick={handlePlay}
className="absolute inset-0 flex items-center justify-center bg-background/90 text-foreground hover:text-yellow-bright transition"
aria-label={`Play ${title}`}
>
<Play size={64} strokeWidth={1.5} />
</button>
</div>
{/* Title + attribution */}
<figcaption className="mt-2 text-xs text-foreground flex justify-between items-center">
<span>{title}</span>
{attribution && (
<a
href={attribution}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-bright"
>
{attribution}
</a>
)}
</figcaption>
</figure>
);
}

View File

@@ -1,78 +0,0 @@
import React from "react"
import type { CollectionEntry } from "astro:content";
interface ProjectCardProps {
project: CollectionEntry<"projects">;
}
export function ProjectCard({ project }: ProjectCardProps) {
const hasLinks = project.data.githubUrl || project.data.demoUrl;
return (
<article className="group relative h-full">
<a
href={`/projects/${project.slug}`}
className="block rounded-lg border-2 border-foreground/20
hover:border-blue transition-all duration-300
bg-background overflow-hidden h-full flex flex-col"
>
<div className="aspect-video w-full border-b border-foreground/20 bg-foreground/5 overflow-hidden flex-shrink-0">
{project.data.image ? (
<img
src={project.data.image}
alt={`${project.data.title} preview`}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/30">
<span className="text-sm">No preview available</span>
</div>
)}
</div>
<div className="p-4 sm:p-6 space-y-3 flex flex-col flex-grow">
<h3 className="text-lg sm:text-xl font-bold group-hover:text-blue transition-colors">
{project.data.title}
</h3>
<div className="flex flex-wrap gap-2">
{project.data.techStack.map(tech => (
<span key={tech} className="text-xs px-2 py-1 rounded-full bg-purple-bright/10 text-purple-bright">
{tech}
</span>
))}
</div>
<p className="text-foreground/70 text-sm sm:text-base flex-grow">
{project.data.description}
</p>
{hasLinks && (
<div className="flex gap-4 pt-3 border-t border-foreground/10 mt-auto">
{project.data.githubUrl && (
<a
href={project.data.githubUrl}
className="text-sm text-blue hover:text-blue-bright
transition-colors z-10"
onClick={(e) => e.stopPropagation()}
>
View Source
</a>
)}
{project.data.demoUrl && (
<a
href={project.data.demoUrl}
className="text-sm text-green hover:text-green-bright
transition-colors z-10"
onClick={(e) => e.stopPropagation()}
>
Live Link
</a>
)}
</div>
)}
</div>
</a>
</article>
);
}

View File

@@ -1,49 +1,100 @@
import React from "react"; import React from "react";
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { ProjectCard } from "@/components/projects/project-card"; import { AnimateIn } from "@/components/animate-in";
interface ProjectListProps { interface ProjectListProps {
projects: CollectionEntry<"projects">[]; projects: CollectionEntry<"projects">[];
} }
export function ProjectList({ projects }: ProjectListProps) { export function ProjectList({ projects }: ProjectListProps) {
const latestProjects = projects.slice(0, 3);
const otherProjects = projects.slice(3);
return ( return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32"> <div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center px-4 leading-relaxed"> <AnimateIn>
Here's what I've been <br className="sm:hidden" /> <h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed">
building lately Here's what I've been <br className="sm:hidden" />
</h1> building lately
</h1>
</AnimateIn>
<div className="px-4 mb-16"> <ul className="space-y-6 md:space-y-10">
<h2 className="text-xl font-bold text-foreground/90 mb-6"> {projects.map((project, i) => (
Featured Projects <AnimateIn key={project.id} delay={i * 80}>
</h2> <li className="group">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr justify-items-center"> <a href={`/projects/${project.id}`} className="block">
{latestProjects.map(project => ( <article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-all duration-200">
<div key={project.slug} className="w-full max-w-md"> {/* Image */}
<ProjectCard project={project} /> <div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
</div> {project.data.image ? (
))} <img
</div> src={project.data.image}
</div> alt={`${project.data.title} preview`}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-foreground/30">
<span className="text-sm">No preview available</span>
</div>
)}
</div>
{otherProjects.length > 0 && ( {/* Content */}
<div className="px-4 pb-8"> <div className="w-full md:w-2/3 flex flex-col gap-2 md:gap-3">
<h2 className="text-xl font-bold text-foreground/90 mb-6"> <h2 className="text-lg md:text-2xl font-semibold text-yellow group-hover:text-blue transition-colors duration-200 line-clamp-2">
All Projects {project.data.title}
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-fr justify-items-center">
{otherProjects.map(project => ( <p className="text-foreground/90 text-sm md:text-lg leading-relaxed line-clamp-2">
<div key={project.slug} className="w-full max-w-md"> {project.data.description}
<ProjectCard project={project} /> </p>
</div>
))} {/* Tech stack */}
</div> <div className="flex flex-wrap gap-1.5 md:gap-2 mt-1">
</div> {project.data.techStack.map((tech) => (
)} <span
key={tech}
className="text-xs md:text-sm px-2 py-0.5 rounded-full bg-purple-bright/10 text-purple-bright"
>
{tech}
</span>
))}
</div>
{/* Links */}
{(project.data.githubUrl || project.data.demoUrl) && (
<div className="flex gap-4 mt-1">
{project.data.githubUrl && (
<span
className="text-sm text-foreground/50 hover:text-blue-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.githubUrl, "_blank");
}}
>
Source
</span>
)}
{project.data.demoUrl && (
<span
className="text-sm text-foreground/50 hover:text-green-bright transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(project.data.demoUrl, "_blank");
}}
>
Live
</span>
)}
</div>
)}
</div>
</article>
</a>
</li>
</AnimateIn>
))}
</ul>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
FileDown, FileDown,
Github, Github,
@@ -6,6 +6,144 @@ import {
Globe Globe
} from "lucide-react"; } from "lucide-react";
// --- Typewriter hook ---
function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
// --- Visibility hook ---
function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
// --- Section fade-in ---
function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const { ref, visible } = useScrollVisible();
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
// --- Typed heading + fade-in body ---
function TypedSection({
heading,
headingClass = "text-3xl font-bold text-yellow-bright",
children,
}: {
heading: string;
headingClass?: string;
children: React.ReactNode;
}) {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter(heading, visible, 20);
return (
<div ref={ref} className="space-y-4">
<h3 className={headingClass} style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
{children}
</div>
</div>
);
}
// --- Staggered skill tags ---
function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean }) {
return (
<div className="flex flex-wrap gap-3">
{skills.map((skill, i) => (
<span
key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out"
style={{
transitionDelay: `${i * 60}ms`,
opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)",
}}
>
{skill}
</span>
))}
</div>
);
}
// --- Data ---
const resumeData = { const resumeData = {
name: "Timothy Pidashev", name: "Timothy Pidashev",
title: "Software Engineer", title: "Software Engineer",
@@ -45,7 +183,7 @@ const resumeData = {
achievements: [ achievements: [
"Designed and built the entire application from the ground up, including auth", "Designed and built the entire application from the ground up, including auth",
"Engineered a tagging system to optimize search results by keywords and relativity", "Engineered a tagging system to optimize search results by keywords and relativity",
"Implemented a filter provider to further narrow down search results and enchance the user experience", "Implemented a filter provider to further narrow down search results and enhance the user experience",
"Created a smooth and responsive infinitely scrollable listings page", "Created a smooth and responsive infinitely scrollable listings page",
"Automated deployment & testing processes reducing downtime by 60%" "Automated deployment & testing processes reducing downtime by 60%"
] ]
@@ -57,22 +195,17 @@ const resumeData = {
school: "Clark College", school: "Clark College",
location: "Vancouver, WA", location: "Vancouver, WA",
period: "Graduating 2026", period: "Graduating 2026",
achievements: [] achievements: [] as string[]
} }
], ],
skills: { skills: {
technical: ["JavaScript", "TypeScript", "React", "Node.js", "Python", "SQL", "AWS", "Docker", "C", "Go", "Rust"], technical: ["JavaScript", "TypeScript", "React", "Node.js", "Python", "SQL", "AWS", "Docker", "C", "Go", "Rust"],
soft: ["Leadership", "Problem Solving", "Communication", "Agile Methodologies"] soft: ["Leadership", "Problem Solving", "Communication", "Agile Methodologies"]
}, },
certifications: [
{
name: "AWS Certified Solutions Architect",
issuer: "Amazon Web Services",
date: "2022"
}
]
}; };
// --- Component ---
const Resume = () => { const Resume = () => {
const handleDownloadPDF = () => { const handleDownloadPDF = () => {
window.open("/timothy-pidashev-resume.pdf", "_blank"); window.open("/timothy-pidashev-resume.pdf", "_blank");
@@ -83,188 +216,198 @@ const Resume = () => {
<div className="space-y-16"> <div className="space-y-16">
{/* Header */} {/* Header */}
<header className="text-center space-y-6"> <header className="text-center space-y-6">
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1> <Section>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2> <h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg"> </Section>
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200"> <Section delay={150}>
{resumeData.contact.email} <h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
</a> </Section>
<span className="hidden md:inline"></span> <Section delay={300}>
<a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200"> <div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg">
{resumeData.contact.phone} <a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200">
</a> {resumeData.contact.email}
<span className="hidden md:inline"></span> </a>
<span>{resumeData.contact.location}</span> <span className="hidden md:inline"></span>
</div> <a href={`tel:${resumeData.contact.phone}`} className="hover:text-foreground transition-colors duration-200">
<div className="flex justify-center items-center gap-6 text-lg"> {resumeData.contact.phone}
<a href={`https://${resumeData.contact.github}`} </a>
target="_blank" <span className="hidden md:inline"></span>
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2" <span>{resumeData.contact.location}</span>
> </div>
<Github size={18} /> </Section>
GitHub <Section delay={450}>
</a> <div className="flex justify-center items-center gap-6 text-lg">
<a href={`https://${resumeData.contact.github}`}
<a href={`https://${resumeData.contact.linkedin}`} target="_blank"
target="_blank" className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2" >
> <Github size={18} />
<Linkedin size={18} /> GitHub
LinkedIn </a>
</a> <a href={`https://${resumeData.contact.linkedin}`}
<button target="_blank"
onClick={handleDownloadPDF} className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2" >
> <Linkedin size={18} />
<FileDown size={18} /> LinkedIn
Resume </a>
</button> <button
</div> onClick={handleDownloadPDF}
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
>
<FileDown size={18} />
Resume
</button>
</div>
</Section>
</header> </header>
{/* Summary */} {/* Summary */}
<section className="space-y-4"> <TypedSection heading="Professional Summary">
<h3 className="text-3xl font-bold text-yellow-bright">Professional Summary</h3>
<p className="text-xl leading-relaxed">{resumeData.summary}</p> <p className="text-xl leading-relaxed">{resumeData.summary}</p>
</section> </TypedSection>
{/* Experience */} {/* Experience */}
<section className="space-y-8"> <TypedSection heading="Experience">
<h3 className="text-3xl font-bold text-yellow-bright">Experience</h3> <div className="space-y-8">
{resumeData.experience.map((exp, index) => ( {resumeData.experience.map((exp, index) => (
<div key={index} className="space-y-4"> <Section key={index} delay={index * 100}>
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="space-y-4">
<div> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4> <div>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div> <h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div>
</div>
<ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div> </Section>
</div> ))}
<ul className="list-disc pl-6 space-y-3"> </div>
{exp.achievements.map((achievement, i) => ( </TypedSection>
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
</div>
))}
</section>
{/* Contract Work */} {/* Contract Work */}
<section className="space-y-8"> <TypedSection heading="Contract Work">
<h3 className="text-3xl font-bold text-yellow-bright">Contract Work</h3> <div className="space-y-8">
{resumeData.contractWork.map((project, index) => ( {resumeData.contractWork.map((project, index) => (
<div key={index} className="space-y-4"> <Section key={index} delay={index * 100}>
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="space-y-4">
<div> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div className="flex items-center gap-3"> <div>
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4> <div className="flex items-center gap-3">
{project.url && ( <h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4>
<a {project.url && (
href={project.url} <a
target="_blank" href={project.url}
rel="noopener noreferrer" target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100" rel="noopener noreferrer"
aria-label={`Visit ${project.title}`} className="text-blue-bright hover:text-blue transition-colors duration-200 opacity-60 hover:opacity-100"
> >
<Globe size={16} strokeWidth={1.5} /> <Globe size={16} strokeWidth={1.5} />
</a> </a>
)}
</div>
<div className="text-foreground/60 text-lg">{project.type}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
</div>
<div className="space-y-4">
{project.responsibilities && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li>
))}
</ul>
</div>
)}
{project.achievements && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
)} )}
</div> </div>
<div className="text-foreground/60 text-lg">{project.type}</div>
</div> </div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div> </Section>
</div> ))}
<div className="space-y-4"> </div>
{project.responsibilities && ( </TypedSection>
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((responsibility, i) => (
<li key={i} className="text-lg leading-relaxed">{responsibility}</li>
))}
</ul>
</div>
)}
{project.achievements && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3">
{project.achievements.map((achievement, i) => (
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
</section>
{/* Education */} {/* Education */}
<section className="space-y-8"> <TypedSection heading="Education">
<h3 className="text-3xl font-bold text-yellow-bright">Education</h3> <div className="space-y-8">
{resumeData.education.map((edu, index) => ( {resumeData.education.map((edu, index) => (
<div key={index} className="space-y-4"> <Section key={index}>
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2"> <div className="space-y-4">
<div> <div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4> <div>
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div> <h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4>
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
</div>
{edu.achievements.length > 0 && (
<ul className="list-disc pl-6 space-y-3">
{edu.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
)}
</div> </div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div> </Section>
</div> ))}
<ul className="list-disc pl-6 space-y-3"> </div>
{edu.achievements.map((achievement, i) => ( </TypedSection>
<li key={i} className="text-lg leading-relaxed">{achievement}</li>
))}
</ul>
</div>
))}
</section>
{/* Skills */} {/* Skills */}
<section className="space-y-8"> <SkillsSection />
<h3 className="text-3xl font-bold text-yellow-bright">Skills</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<div className="flex flex-wrap gap-3">
{resumeData.skills.technical.map((skill, index) => (
<span key={index}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-colors duration-200">
{skill}
</span>
))}
</div>
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<div className="flex flex-wrap gap-3">
{resumeData.skills.soft.map((skill, index) => (
<span key={index}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-colors duration-200">
{skill}
</span>
))}
</div>
</div>
</div>
</section>
{/* Certifications */}
{/* Temporarily Hidden
<section className="space-y-6 mb-16">
<h3 className="text-3xl font-bold text-yellow-bright">Certifications</h3>
{resumeData.certifications.map((cert, index) => (
<div key={index} className="space-y-2">
<h4 className="text-2xl font-semibold text-green-bright">{cert.name}</h4>
<div className="text-foreground/60 text-lg">{cert.issuer} - {cert.date}</div>
</div>
))}
</section>
*/}
</div> </div>
</div> </div>
); );
}; };
// --- Skills section ---
function SkillsSection() {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter("Skills", visible, 20);
return (
<div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out"
style={{
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
}}
>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} />
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} />
</div>
</div>
</div>
);
}
export default Resume; export default Resume;

View File

@@ -1,7 +1,9 @@
import { defineCollection, z } from "astro:content"; import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
export const collections = { export const collections = {
blog: defineCollection({ blog: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/blog" }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
@@ -12,9 +14,11 @@ export const collections = {
}), }),
image: z.string().optional(), image: z.string().optional(),
imagePosition: z.string().optional(), imagePosition: z.string().optional(),
isDraft: z.boolean().optional()
}), }),
}), }),
projects: defineCollection({ projects: defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./src/content/projects" }),
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
@@ -22,7 +26,7 @@ export const collections = {
demoUrl: z.string().url().optional(), demoUrl: z.string().url().optional(),
techStack: z.array(z.string()), techStack: z.array(z.string()),
date: z.string(), date: z.string(),
image: z.string().optional(), image: z.string().optional()
}), }),
}), })
}; };

View File

@@ -0,0 +1,9 @@
---
title: Breaking the Chromebook Cage
description: From breaking Chromebooks as a student to breaking Chromebooks to stop students from breaking Chromebooks
author: Timothy Pidashev
tags: ["uefi", "coreboot", "firmware", "chromebooks"]
date: 2025-09-15
image: "/blog/breaking-the-chromebook-cage/thumbnail.png"
isDraft: true
---

View File

@@ -0,0 +1,104 @@
import React from 'react';
interface T440pAdProps {
className?: string;
}
const Advertisement: React.FC<T440pAdProps> = ({ className = '' }) => {
return (
<div className={`bg-gradient-to-br from-blue-50 to-indigo-100 border-2 border-blue-200 rounded-lg p-6 my-8 shadow-lg ${className}`}>
<div className="flex items-start gap-4">
{/* Icon/Logo placeholder */}
<div className="flex-shrink-0 w-12 h-12 bg-blue-600 rounded-lg flex items-center justify-center">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-xl font-bold text-gray-900">
Custom Corebooted ThinkPad T440p
</h3>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Available Now
</span>
</div>
<p className="text-gray-700 mb-4">
Skip the technical complexity and get a professionally corebooted ThinkPad T440p
built to your specifications. Each laptop is carefully modified and tested to ensure
optimal performance and reliability.
</p>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<div>
<h4 className="font-semibold text-gray-900 mb-2"> What's Included:</h4>
<ul className="text-sm text-gray-700 space-y-1">
<li> Coreboot firmware pre-installed</li>
<li> IPS 1080p display upgrade</li>
<li> RAM options: 4GB, 8GB, or 16GB</li>
<li> CPU choice available</li>
<li> Battery upgrade option</li>
<li> Thorough testing & quality assurance</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-900 mb-2">🔧 Benefits:</h4>
<ul className="text-sm text-gray-700 space-y-1">
<li> Faster boot times</li>
<li> Open-source BIOS</li>
<li> Enhanced security</li>
<li> No proprietary firmware</li>
<li> Full hardware control</li>
<li> Professional installation</li>
</ul>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pt-4 border-t border-blue-200">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-blue-600">$500</span>
<span className="text-sm text-gray-600">USD (base configuration)</span>
</div>
<div className="flex gap-3">
<a
href="https://ebay.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.5 2A1.5 1.5 0 007 3.5v1A1.5 1.5 0 008.5 6h7A1.5 1.5 0 0017 4.5v-1A1.5 1.5 0 0015.5 2h-7zM10 3h4v1h-4V3z"/>
<path d="M6 7.5A1.5 1.5 0 017.5 6h9A1.5 1.5 0 0118 7.5v9a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 016 16.5v-9z"/>
</svg>
Order on eBay
</a>
<button className="inline-flex items-center px-4 py-2 border border-blue-600 text-blue-600 font-medium rounded-lg hover:bg-blue-50 transition-colors">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Ask Questions
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default Advertisement;

View File

@@ -0,0 +1,351 @@
---
title: Thinkpad T440p Coreboot Guide
description: The definitive guide on corebooting a Thinkpad T440p
author: Timothy Pidashev
tags: [t440p, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-coreboot-guide/thumbnail.png"
isDraft: true
---
import { Commands, Command, CommandSequence } from "@/components/mdx/command";
import Advertisement from '@/content/blog/components/thinkpad-t440p-coreboot-guide/advertisement';
> **Interactive Script Available!**
> Want to skip the manual steps in this guide?
> I've created an interactive script that can automate the entire process step by step as you follow along.
> This script supports Arch, Debian, Fedora, Gentoo, and Nix!
<Command
description="Interactive script"
command="curl -fsSL https://timmypidashev.dev/scripts/run.sh | sh -s -- -t coreboot-t440p"
client:load
/>
Don't pipe anyone's scripts to **sh** blindly, including mine - <a href="https://github.com/timmypidashev/scripts" target="_blank" rel="noopener noreferrer">audit the source</a>.
## Getting Started
The Thinkpad T440p is a powerful and versatile laptop that can be further enhanced by installing coreboot,
an open-source BIOS replacement. This guide will walk you through the process of corebooting your T440p,
including flashing the BIOS chip and installing the necessary software.
## What You'll Need
Before getting started corebooting your T440p, make sure you have the following:
- **Thinkpad T440p**: This guide is specifically for the T440p model.
- **CH341A Programmer**: This is a USB device used to flash the BIOS chip.
- **Screwdriver**: A torx screwdriver is needed to open the laptop.
## Installing Dependencies
Install the following programs. These will be needed to compile coreboot and flash the BIOS.
<Commands
description="Install prerequisite packages"
archCommand="sudo pacman -S base-devel curl git gcc-ada ncurses zlib nasm sharutils unzip flashrom"
debianCommand="sudo apt install build-essential curl git gnat libncurses-dev zlib1g-dev nasm sharutils unzip flashrom"
fedoraCommand="sudo dnf install @development-tools curl git gcc-gnat ncurses-devel zlib-devel nasm sharutils unzip flashrom"
gentooCommand="sudo emerge --ask sys-devel/base-devel net-misc/curl dev-vcs/git sys-devel/gcc ncurses dev-libs/zlib dev-lang/nasm app-arch/sharutils app-arch/unzip sys-apps/flashrom"
nixCommand="nix-env -i stdenv curl git gcc gnat ncurses zlib nasm sharutils unzip flashrom"
client:load
/>
## Disassembling the Laptop
1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source.
2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out.
3. **Unscrew the back panel**: Use a torx screwdriver to remove the screws securing the back panel.
## Locating the EEPROM Chips
In order to flash the laptop, you will need to have access to two EEPROM chips located next to the sodimm RAM.
![EEPROM Chips Location](/blog/thinkpad-t440p-coreboot-guide/eeprom_chips_location.png)
## Assembling the SPI Flasher
Place the SPI flasher ribbon cable into the correct slot and make sure its the 3.3v variant
![SPI Flasher Assembly](/blog/thinkpad-t440p-coreboot-guide/spi_flasher_assembly.png)
After the flasher is ready, connect it to your machine and ensure its ready to use:
<Command
description="Ensure the CH341A flasher is being detected"
command="flashrom --programmer ch341a_spi"
/>
Flashrom should report that programmer initialization was a success.
## Extracting Original BIOS
To begin, first create a clean directory where all work to coreboot
the T440p will be done.
<Command
description="Create a directory where all work will be done"
command="mkdir ~/t440p-coreboot"
client:load
/>
Next, extract the original rom from both EEPROM chips. This is
done by attaching the programmer to the correct chip and running
the subsequent commands. It may take longer than expected, and
ensuring the bios was properly extracted is important before proceeding
further.
<CommandSequence
commands={[
"sudo flashrom --programmer ch341a_spi -r 4mb_backup1.bin",
"sudo flashrom --programmer ch341a_spi -r 4mb_backup2.bin",
"diff 4mb_backup1.bin 4mb_backup2.bin"
]}
description="Backup and verify 4MB chip"
client:load
/>
<CommandSequence
commands={[
"sudo flashrom --programmer ch341a_spi -r 8mb_backup1.bin",
"sudo flashrom --programmer ch341a_spi -r 8mb_backup2.bin",
"diff 8mb_backup1.bin 8mb_backup2.bin"
]}
description="Backup and verify 8MB chip"
client:load
/>
If the diff checks pass, combine both files into one ROM.
<Command
description="Combine 4MB & 8MB into one ROM"
command="cat 8mb_backup_1.bin 4mb_backup1.bin > t440p-original.rom"
client:load
/>
## Building Required Tools
Now that the original bios has been successfuly extracted, it is time
to clone the coreboot repository and build every tool needed to build
a new bios image.
<CommandSequence
commands={[
"git clone https://review.coreboot.org/coreboot ~/t440p-coreboot/coreboot",
"cd ~/t440p-coreboot/coreboot",
"git checkout e1e762716cf925c621d58163133ed1c3e006a903",
"git submodule update --init --checkout"
]}
description="Clone coreboot and checkout to the correct commit"
client:load
/>
We will need to build `idftool`, which will be used to export all necessary blobs
from our original bios, and `cbfstool`, which will be used to extract __mrc.bin__(a blob
from a haswell chromebook peppy image).
<Command
description="Build util/ifdtool"
command="cd ~/t440p-coreboot/coreboot/util/ifdtool && make"
client:load
/>
<Command
description="Build util/cbfstool"
command="cd ~/t440p-coreboot/coreboot/ && make -C util/cbfstool"
client:load
/>
## Exporting Firmware Blobs
Once the necessary tools have been built, we can export the
3 flash regions from our original bios image.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/util/ifdtool",
"./ifdtool -x ~/t440p-coreboot/t440p-original.rom",
"mv flashregion_0_flashdescriptor.bin ~/t440p-coreboot/ifd.bin",
"mv flashregion_2_intel_me.bin ~/t440p-coreboot/me.bin",
"mv flashregion_3_gbe.bin ~/t440p-coreboot/gbe.bin"
]}
description="Export firmware blobs"
client:load
/>
## Obtaining mrc.bin
In order to obtain __mrc.bin__, we need the chromeos peppy image.
This can be pulled by running the `crosfirmware.sh` script found in util/chromeos.
<Command
description="Download peppy chromeos image"
command="cd ~/t440p-coreboot/coreboot/util/chromeos && ./crosfirmware.sh peppy"
client:load
/>
We can now obtain __mrc.bin__ using cbfstool to extract the blob from the image.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/util/chromeos",
"../cbfstool/cbfstool coreboot-*.bin extract -f mrc.bin -n mrc.bin -r RO_SECTION",
"mv mrc.bin ~/t440p-coreboot/mrc.bin"
]}
description="Extract mrc.bin using cbfstool"
client:load
/>
## Configuring Coreboot
Configuring coreboot is really where most of your time will be spent. To help out,
I've created several handy configs that should suit most use cases, and can be easily
tweaked to your liking. Here is a list of whats available:
### 1. GRUB2 (Recommended)
GRUB2 is the recommended payload for most users. It boots Linux directly without needing a
separate bootloader installation on disk. This configuration includes three secondary payloads:
- **memtest86+** - Memory testing utility
- **nvramcui** - CMOS/NVRAM settings editor
- **coreinfo** - System information viewer
If your T440p has the optional GT730M dGPU, the GRUB2 config also includes the
necessary VGA option ROM for it.
### 2. SeaBIOS
SeaBIOS provides a traditional BIOS interface, making it the most compatible option.
Choose this if you need to boot operating systems that expect a legacy BIOS, such
as Windows or BSD.
### 3. edk2 (UEFI)
edk2 provides a UEFI firmware interface. Choose this if you prefer UEFI boot or
need UEFI-specific features.
---
If using the interactive script, it will prompt you to choose a payload and apply
a preset configuration automatically. You can also choose to open the full
configuration menu (`make nconfig`) to customize further.
For manual configuration, first copy the extracted blobs into place:
<CommandSequence
commands={[
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell",
"mkdir -p ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell",
"cp ~/t440p-coreboot/ifd.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/descriptor.bin",
"cp ~/t440p-coreboot/me.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/me.bin",
"cp ~/t440p-coreboot/gbe.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/mainboard/lenovo/haswell/gbe.bin",
"cp ~/t440p-coreboot/mrc.bin ~/t440p-coreboot/coreboot/3rdparty/blobs/cpu/intel/haswell/mrc.bin"
]}
description="Copy firmware blobs into the coreboot source tree"
client:load
/>
Then open the configuration menu:
<Command
description="Open coreboot configuration"
command="cd ~/t440p-coreboot/coreboot && make nconfig"
client:load
/>
Key settings to configure:
- **Mainboard** &rarr; Mainboard vendor: **Lenovo** &rarr; Mainboard model: **ThinkPad T440p**
- **Chipset** &rarr; Add Intel descriptor.bin, ME firmware, and GbE configuration (set paths to your blobs)
- **Chipset** &rarr; Add haswell MRC file (set path to mrc.bin)
- **Payload** &rarr; Choose your preferred payload (GRUB2, SeaBIOS, or edk2)
## Building and Flashing
After configuring coreboot, it is time to build and flash it onto your unsuspecting T440p :D
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot",
"make crossgcc-i386 CPUS=$(nproc)",
"make"
]}
description="Build coreboot"
client:load
/>
Once the coreboot build has completed, split the built ROM for the 8MB(bottom) chip & 4MB(top) chip.
<CommandSequence
commands={[
"cd ~/t440p-coreboot/coreboot/build",
"dd if=coreboot.rom of=bottom.rom bs=1M count=8",
"dd if=coreboot.rom of=top.rom bs=1M skip=8"
]}
description="Split the built ROM for both EEPROM chips"
client:load
/>
Now flash the new bios onto your thinkpad!
<Command
description="Flash the 4MB chip"
command="sudo flashrom --programmer ch341a_spi -w top.rom"
/>
<Command
description="Flash the 8MB chip"
command="sudo flashrom --programmer ch341a_spi -w bottom.rom"
/>
Thats it! If done properly, your thinkpad should now boot!
## Reverting to Original
If for some reason you feel the need to revert back, or your T440p can't boot,
here are the steps needed to flash the original image back.
### Can't Boot
<CommandSequence
commands={[
"cd ~/t440p-coreboot/",
"dd if=t440p-original.rom of=bottom.rom bs=1M count=8",
"dd if=t440p-original.rom of=top.rom bs=1M skip=8"
]}
description="Split original bios image for both EEPROM chips"
client:load
/>
<Command
description="Flash the 4MB chip"
command="sudo flashrom --programmer ch341a_spi -w top.rom"
/>
<Command
description="Flash the 8MB chip"
command="sudo flashrom --programmer ch341a_spi -w bottom.rom"
/>
### Can Boot
<CommandSequence
commands={[
"sudo sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s/\"/ iomem=relaxed\"/2' /etc/default/grub",
"sudo grub-mkconfig -o /boot/grub/grub.cfg",
]}
description="Set kernel flag iomem=relaxed and update grub config"
client:load
/>
Reboot to apply `iomem=relaxed`
<Command
description="Flash the original bios"
command="sudo flashrom -p internal:laptop=force_I_want_a_brick -r ~/t440p-coreboot/t440p-original.rom"
/>
And that about wraps it up! If you liked the guide, leave a reaction or comment any changes or fixes
I should make below. Your feedback is greatly appreciated!

View File

@@ -1,38 +0,0 @@
---
title: Thinkpad T440p Coreboot Guide
description: The definitive guide on corebooting a Thinkpad T440p
author: Timothy Pidashev
tags: [t440p, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-coreboot-guide/thumbnail.png"
---
> **Interactive Script Available!**
> Want to skip the manual steps in this guide?
> I've created an interactive script that can automate the entire process step by step as you follow along.
> Simply run the following command in your terminal to get started:
>
> ```
> curl -fsSL https://timmypidashev.dev/scripts/run.sh | sh -s -- -t coreboot-t440p
> ```
> NOTE: This script supports Arch, Debian, Fedora, Gentoo, and Nix linux distributions!
## Getting Started
The Thinkpad T440p is a powerful and versatile laptop that can be further enhanced by installing coreboot,
an open-source BIOS replacement. This guide will walk you through the process of corebooting your T440p,
including flashing the BIOS chip and installing the necessary software.
## What You'll Need
Before getting started corebooting your T440p, make sure you have the following:
- **Thinkpad T440p**: This guide is specifically for the T440p model.
- **CH341A Programmer**: This is a USB device used to flash the BIOS chip.
- **Screwdriver**: A torx screwdriver is needed to open the laptop.
## Disassembling the Laptop
1. **Power off your laptop**: Make sure your T440p is completely powered off and unplugged from any power source.
2. **Remove the battery**: Flip the laptop over and remove the battery by sliding the latch to the unlock position and lifting it out.
3. **Unscrew the back panel**: Use a torx screwdriver to remove the screws securing the back panel.
## Locating the EEPROM Chips

View File

@@ -1,86 +0,0 @@
---
title: Thinkpad T440p Modification Guide
description: You purchased a T440p, now what?
author: Timothy Pidashev
tags: [t440p, mods, coreboot, thinkpad]
date: 2025-01-15
image: "/blog/thinkpad-t440p-modification-guide/thumbnail.png"
---
## The T440p
Whether for privacy related reasons, coreboot, or someones advice on the internet,
you are now the proud owner of a T440p. Now what? Well, I have been daily driving
this laptop for over two years now, and would like to share my knowledge on this
lovely machine. If followed properly, this guide should help any privacy seeking
individual or programmer how to setup the "reasonably" perfect T440p.
## Buying the Right Model
Although the T440p comes in various configurations and specs, when searching for
one online there are two things to consider.
1. Online Marketplace
* Purchasing from the right marketplace is important to consider, and while trusted
vendors like Amazon might be preferred, consider Ebay or AliExpress.
* I personally have only purchased my thinkpad's on Ebay, as there are generally more listings
available from companies reselling retired units, usually at a steep discount.
2. Dedicated GPU
* The T440p motherboard comes in two different varieties, one with
a dGPU and the other without. There is only one dGPU model, which is the NVIDIA GT 730M.
Featuring 2GB of VRAM, it will work, however if your looking for longer battery life and
an easier coreboot config should you choose to coreboot, I would recommend sticking to
a non dGPU variant.
* Finding a dGPU variant is quite difficult, as many online
sellers don't always list the motherboard spec, making things quite the guessing game.
When I was shopping for one, my strategy was to purchase the dGPU motherboard on its own,
and then a T440p laptop listed with a dead motherboard, as I was going to swap it out anyways.
3. Quality
* Finding the perfect T440p is hard, and you will likely end up purchasing one that looks ok
in pictures, but comes with a cracked palmrest or front panel. Consider purchasing one which
looks good, and then replacing any cracked or aged parts should you choose to do so in the future.
* T440p plastics are aged. Although this machine is an absolute brick, which can probably be thrown
at the ground without any major damage, it will definitely chip and crack. I myself have replaced my
palm rest/keyboard cover thrice, as every half a year or so I will open the laptop in the morning to
find that my careless "throw it in the backpack" has finally cracked the palmrest yet again.
## Screen
When it comes to the screen, you really don't want to get one of poor quality, especially since the
lousy 1366x768 panel is not great nowadays. Generally, I would recommend going for an ips 1080p panel,
as this is generally most the most supported. I purchased this panel from amazon for ~$60USD and have
never looked back.
## Keyboard
## Trackpad
## Battery
## CPU
The T440p has a trick up its sleeve. The processor can be swapped out and replaced, allowing for an upgrade!
There are many models out there, however some aren't recommended due to thermal constraints, so finding the
right balance can be tough.
## RAM
## Storage
## WLAN
## WAN
## MISC
1. Fingerprint Reader
2. Disc Reader
3. Webcam & Microphone

View File

@@ -1,16 +1,20 @@
--- ---
import "@/style/globals.css"; import "@/style/globals.css";
import { ClientRouter } from "astro:transitions"; import { ClientRouter } from "astro:transitions";
import Header from "@/components/header"; import Header from "@/components/header";
import Footer from "@/components/footer"; import Footer from "@/components/footer";
import Background from "@/components/background"; import Background from "@/components/background";
export interface Props { export interface Props {
title: string; title: string;
description: string; description: string;
} }
const { title, description } = Astro.props; const { title, description } = Astro.props;
const ogImage = "https://timmypidashev.dev/og-image.jpg"; const ogImage = "https://timmypidashev.dev/og-image.jpg";
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<title>{title}</title> <title>{title}</title>
@@ -52,7 +56,9 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<main class="flex-1 flex flex-col"> <main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1"> <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 /> <Background layout="content" position="right" client:only="react" transition:persist />
<slot /> <div>
<slot />
</div>
<Background layout="content" position="left" client:only="react" transition:persist /> <Background layout="content" position="left" client:only="react" transition:persist />
</div> </div>
</main> </main>

View File

@@ -0,0 +1,70 @@
---
import "@/style/globals.css";
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>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- OpenGraph -->
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:description" content={description} />
<!-- Basic meta description for search engines -->
<meta name="description" content={description} />
<!-- Also used in OpenGraph for social media sharing -->
<meta property="og:description" content={description} />
<link rel="icon" type="image/jpeg" href="/me.jpeg" />
<ClientRouter
defaultTransition={false}
handleFocus={false}
/>
<style>
::view-transition-new(:root) {
animation: none;
}
::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 min-h-screen flex flex-col">
<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 />
<div>
<slot />
</div>
<Background layout="content" position="left" client:only="react" transition:persist />
</div>
</main>
<script>
document.addEventListener("astro:after-navigation", () => {
window.scrollTo(0, 0);
});
</script>
</body>
</html>

View File

@@ -41,7 +41,7 @@ export function getArticleSchema(post: CollectionEntry<"blog">) {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
headline: post.data.title, headline: post.data.title,
url: `${import.meta.env.SITE}/blog/${post.slug}/`, url: `${import.meta.env.SITE}/blog/${post.id}/`,
description: post.data.excerpt, description: post.data.excerpt,
datePublished: post.data.date.toString(), datePublished: post.data.date.toString(),
publisher: { publisher: {

View File

@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
<Intro client:load /> <Intro client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[60vh] flex items-center justify-center py-16">
<AllTimeStats client:only="react" /> <AllTimeStats client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-screen flex items-center justify-center py-16">
<DetailedStats client:only="react" /> <DetailedStats client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[80vh] flex items-center justify-center py-16">
<Timeline client:load /> <Timeline client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[80vh] flex items-center justify-center py-16">
<CurrentFocus client:load /> <CurrentFocus client:load />
</section> </section>
<section class="flex items-center justify-center py-16"> <section class="min-h-[50vh] flex items-center justify-center py-16">
<OutsideCoding client:load /> <OutsideCoding client:load />
</section> </section>
</div> </div>

View File

@@ -3,6 +3,13 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY; const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try { try {
const response = await fetch( const response = await fetch(
'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', { 'https://wakatime.com/api/v1/users/current/summaries?range=last_6_months', {

View File

@@ -1,22 +1,35 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
try { const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
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, { if (!WAKATIME_API_KEY) {
status: 200, return new Response(
headers: { JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
"Content-Type": "application/json" { status: 503, headers: { "Content-Type": "application/json" } }
);
}
try {
const response = await fetch(
"https://wakatime.com/api/v1/users/current/all_time_since_today",
{
headers: {
Authorization: `Basic ${Buffer.from(WAKATIME_API_KEY).toString("base64")}`,
},
} }
);
const data = await response.json();
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: "Failed to fetch stats" }), { console.error("WakaTime alltime API error:", error);
status: 500 return new Response(
}); JSON.stringify({ error: "Failed to fetch stats" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
} }
} };

View File

@@ -4,6 +4,13 @@ import type { APIRoute } from 'astro';
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY; const WAKATIME_API_KEY = import.meta.env.WAKATIME_API_KEY;
if (!WAKATIME_API_KEY) {
return new Response(
JSON.stringify({ error: "WAKATIME_API_KEY not configured" }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
try { try {
const response = await fetch( const response = await fetch(
'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', { 'https://wakatime.com/api/v1/users/current/stats/last_7_days?timeout=15', {

View File

@@ -1,5 +1,5 @@
--- ---
import { getCollection } from "astro:content"; import { getCollection, render } from "astro:content";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import ContentLayout from "@/layouts/content.astro"; import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData"; import { getArticleSchema } from "@/lib/structuredData";
@@ -11,17 +11,17 @@ const { slug } = Astro.params;
// Fetch blog posts // Fetch blog posts
const posts = await getCollection("blog"); const posts = await getCollection("blog");
const post = posts.find(post => post.slug === slug); const post = posts.find(post => post.id === slug);
if (!post) { if (!post || (!import.meta.env.DEV && post.data.isDraft === true)) {
return new Response(null, { return new Response(null, {
status: 404, status: 404,
statusText: 'Not found' statusText: "Not found"
}); });
} }
// Dynamically render the content // Dynamically render the content
const { Content } = await post.render(); const { Content } = await render(post);
// Format the date // Format the date
const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", { const formattedDate = new Date(post.data.date).toLocaleDateString("en-US", {
@@ -46,7 +46,7 @@ const breadcrumbsStructuredData = {
"@type": "ListItem", "@type": "ListItem",
position: 2, position: 2,
name: post.data.title, name: post.data.title,
item: `${import.meta.env.SITE}/blog/${post.slug}/`, item: `${import.meta.env.SITE}/blog/${post.id}/`,
}, },
], ],
}; };
@@ -63,9 +63,9 @@ const jsonLd = {
> >
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} /> <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<div class="relative max-w-8xl mx-auto"> <div class="relative max-w-8xl mx-auto">
<article class="prose prose-lg mx-auto max-w-4xl"> <article class="prose prose-invert prose-lg mx-auto max-w-4xl">
{post.data.image && ( {post.data.image && (
<div class="-mx-4 sm:mx-0 mb-8"> <div class="-mx-4 sm:mx-0 mb-4">
<Image <Image
src={post.data.image} src={post.data.image}
alt={post.data.title} alt={post.data.title}
@@ -76,18 +76,16 @@ const jsonLd = {
/> />
</div> </div>
)} )}
<h1 class="text-3xl pt-4">{post.data.title}</h1> <h1 class="text-3xl !mt-2 !mb-2">{post.data.title}</h1>
<p class="lg:text-2xl sm:text-lg">{post.data.description}</p> <p class="lg:text-2xl sm:text-lg !mt-0 !mb-3">{post.data.description}</p>
<div class="mt-4 md:mt-6"> <div class="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80">
<div class="flex flex-wrap items-center gap-2 md:gap-3 text-sm md:text-base text-foreground/80"> <span class="text-orange">{post.data.author}</span>
<span class="text-orange">{post.data.author}</span> <span class="text-foreground/50">•</span>
<span class="text-foreground/50">•</span> <time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue">
<time dateTime={post.data.date instanceof Date ? post.data.date.toISOString() : post.data.date} class="text-blue"> {formattedDate}
{formattedDate} </time>
</time>
</div>
</div> </div>
<div class="flex flex-wrap gap-2 mt-4 md:mt-6"> <div class="flex flex-wrap gap-2 mt-2">
{post.data.tags.map((tag) => ( {post.data.tags.map((tag) => (
<span <span
class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200" class="text-xs md:text-base text-aqua hover:text-aqua-bright transition-colors duration-200"
@@ -97,7 +95,7 @@ const jsonLd = {
</span> </span>
))} ))}
</div> </div>
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</article> </article>

View File

@@ -5,7 +5,7 @@ import { BlogHeader } from "@/components/blog/header";
import { BlogPostList } from "@/components/blog/post-list"; import { BlogPostList } from "@/components/blog/post-list";
const posts = (await getCollection("blog", ({ data }) => { const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true; return import.meta.env.DEV || data.isDraft !== true;
})).sort((a, b) => { })).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf() return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({ }).map(post => ({

View File

@@ -5,7 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import TagList from "@/components/blog/tag-list"; import TagList from "@/components/blog/tag-list";
const posts = (await getCollection("blog", ({ data }) => { const posts = (await getCollection("blog", ({ data }) => {
return data.isDraft !== true; return import.meta.env.DEV || data.isDraft !== true;
})).sort((a, b) => { })).sort((a, b) => {
return b.data.date.valueOf() - a.data.date.valueOf() return b.data.date.valueOf() - a.data.date.valueOf()
}).map(post => ({ }).map(post => ({

View File

@@ -1,20 +1,21 @@
--- ---
export const prerender = true; export const prerender = true;
import { getCollection } from "astro:content"; import { getCollection, render } from "astro:content";
import ContentLayout from "@/layouts/content.astro"; import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments"; import { Comments } from "@/components/blog/comments";
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getCollection("projects"); const projects = await getCollection("projects");
return projects.map(project => ({ return projects.map(project => ({
params: { slug: project.slug }, params: { slug: project.id },
props: { project }, props: { project },
})); }));
} }
const { project } = Astro.props; const { project } = Astro.props;
const { Content } = await project.render(); const { Content } = await render(project);
--- ---
<ContentLayout title={`${project.data.title} | Timothy Pidashev`}> <ContentLayout title={`${project.data.title} | Timothy Pidashev`}>
@@ -59,7 +60,7 @@ const { Content } = await project.render();
</div> </div>
</header> </header>
<div class="prose prose-invert max-w-none"> <div class="prose prose-invert prose-lg max-w-none">
<Content /> <Content />
</div> </div>
</article> </article>

View File

@@ -16,11 +16,11 @@ export async function GET(context: APIContext) {
title: post.data.title, title: post.data.title,
pubDate: post.data.date, pubDate: post.data.date,
description: post.data.description, description: post.data.description,
link: `/blog/${post.slug}/`, link: `/blog/${post.id}/`,
author: post.data.author, author: post.data.author,
categories: post.data.tags, categories: post.data.tags,
enclosure: post.data.image ? { enclosure: post.data.image ? {
url: new URL(`blog/${post.slug}/thumbnail.png`, context.site).toString(), url: new URL(`blog/${post.id}/thumbnail.png`, context.site).toString(),
type: 'image/jpeg', type: 'image/jpeg',
length: 0 length: 0
} : undefined } : undefined

View File

@@ -27,3 +27,38 @@
body { body {
font-family: "ComicRegular"; font-family: "ComicRegular";
} }
code[data-line-numbers] {
counter-reset: line;
}
code[data-line-numbers] > [data-line]::before {
counter-increment: line;
content: counter(line);
/* Other styling */
display: inline-block;
width: 0.75rem;
margin-right: 2rem;
text-align: right;
color: gray;
}
code[data-line-numbers-max-digits="2"] > [data-line]::before {
width: 1.25rem;
}
code[data-line-numbers-max-digits="3"] > [data-line]::before {
width: 1.75rem;
}
code[data-line-numbers-max-digits="4"] > [data-line]::before {
width: 2.25rem;
}
code {
overflow-x: scroll !important;
max-width: calc(82vw - 2rem) !important;
width: 100% !important;
}

View File

@@ -41,10 +41,15 @@ module.exports = {
"draw-line": { "draw-line": {
"0%": { "stroke-dashoffset": "100" }, "0%": { "stroke-dashoffset": "100" },
"100%": { "stroke-dashoffset": "0" } "100%": { "stroke-dashoffset": "0" }
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" }
} }
}, },
animation: { animation: {
"draw-line": "draw-line 0.6s ease-out forwards" "draw-line": "draw-line 0.6s ease-out forwards",
"fade-in": "fade-in 0.3s ease-in-out forwards"
}, },
typography: (theme) => ({ typography: (theme) => ({
DEFAULT: { DEFAULT: {
@@ -91,6 +96,56 @@ module.exports = {
transition: "all 0.2s ease-in-out", transition: "all 0.2s ease-in-out",
}, },
// Code
'code:not([data-language])': {
color: theme('colors.purple.bright'),
backgroundColor: '#282828',
padding: '0',
borderRadius: '0.25rem',
fontFamily: 'Comic Code, monospace',
fontWeight: '400',
fontSize: 'inherit', // Match the parent text size
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'pre': {
backgroundColor: '#282828',
color: theme("colors.foreground"),
borderRadius: '0.5rem',
overflow: 'visible', // This allows the copy button to be positioned outside
position: 'relative', // For the copy button positioning
marginTop: '1.5rem', // Space for the copy button and language label
fontSize: 'inherit', // Match the parent font size
},
'pre code': {
display: 'block',
fontFamily: 'Comic Code, monospace',
fontSize: '1em', // This will inherit from the prose-lg setting
padding: '0',
overflow: 'auto', // Enable horizontal scrolling
whiteSpace: 'pre',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'[data-rehype-pretty-code-fragment]:nth-of-type(2) pre': {
'[data-line]::before': {
content: 'counter(line)',
counterIncrement: 'line',
display: 'inline-block',
width: '1rem',
marginRight: '1rem',
textAlign: 'right',
color: '#86e1fc',
},
'[data-highlighted-line]::before': {
color: '#86e1fc',
},
},
// Bold // Bold
strong: { strong: {
color: theme("colors.orange.bright"), color: theme("colors.orange.bright"),
@@ -118,81 +173,6 @@ module.exports = {
}, },
}, },
// Code
'code:not([data-language])': {
color: theme('colors.purple.bright'),
backgroundColor: '#282828',
padding: '0.2em 0.4em',
borderRadius: '0.25rem',
fontFamily: 'Comic Code, monospace',
fontWeight: '400',
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'pre code': {
display: 'grid', // This ensures line backgrounds stretch full width
minWidth: '100%',
fontFamily: 'Comic Code, monospace',
fontSize: '0.875rem', // text-sm
lineHeight: '1.7142857', // leading-6
padding: '1rem', // p-4
'&::before': { content: 'none' },
'&::after': { content: 'none' },
},
'.highlighted': {
backgroundColor: theme('colors.foreground/5'),
paddingLeft: '1rem',
paddingRight: '1rem',
marginLeft: '-1rem',
marginRight: '-1rem',
},
'.word': {
backgroundColor: theme('colors.foreground/20'),
padding: '0.2em',
borderRadius: '0.25rem',
},
code: {
color: theme("colors.purple.bright"),
backgroundColor: "#282828", // A dark gray that works with black
padding: "0.2em 0.4em",
borderRadius: "0.25rem",
fontWeight: "400",
"&::before": {
content: "\"\"",
},
"&::after": {
content: "\"\"",
},
},
// Inline code
"code::before": {
content: "\"\"",
},
"code::after": {
content: "\"\"",
},
// Pre
pre: {
backgroundColor: "#282828",
color: theme("colors.foreground"),
code: {
backgroundColor: "transparent",
padding: "0",
color: "inherit",
fontSize: "inherit",
fontWeight: "inherit",
"&::before": { content: "none" },
"&::after": { content: "none" },
},
},
// Horizontal rules // Horizontal rules
hr: { hr: {
borderColor: theme("colors.foreground"), borderColor: theme("colors.foreground"),
@@ -226,6 +206,13 @@ module.exports = {
}, },
}, },
}, },
lg: {
css: {
"pre code": {
fontSize: "1rem",
},
},
},
}), }),
}, },
}, },