diff --git a/public/emoji/coffee.webp b/public/emoji/coffee.webp new file mode 100644 index 0000000..b870a0d Binary files /dev/null and b/public/emoji/coffee.webp differ diff --git a/public/emoji/lightbulb.webp b/public/emoji/lightbulb.webp new file mode 100644 index 0000000..d3291cb Binary files /dev/null and b/public/emoji/lightbulb.webp differ diff --git a/public/emoji/memo.webp b/public/emoji/memo.webp new file mode 100644 index 0000000..a23f8ea Binary files /dev/null and b/public/emoji/memo.webp differ diff --git a/public/emoji/mood-cold.webp b/public/emoji/mood-cold.webp new file mode 100644 index 0000000..9ae228d Binary files /dev/null and b/public/emoji/mood-cold.webp differ diff --git a/public/emoji/mood-cool.webp b/public/emoji/mood-cool.webp new file mode 100644 index 0000000..ec6f9de Binary files /dev/null and b/public/emoji/mood-cool.webp differ diff --git a/public/emoji/mood-dotted.webp b/public/emoji/mood-dotted.webp new file mode 100644 index 0000000..4d9ef0f Binary files /dev/null and b/public/emoji/mood-dotted.webp differ diff --git a/public/emoji/mood-expressionless.webp b/public/emoji/mood-expressionless.webp new file mode 100644 index 0000000..2cde9e9 Binary files /dev/null and b/public/emoji/mood-expressionless.webp differ diff --git a/public/emoji/mood-fire.webp b/public/emoji/mood-fire.webp new file mode 100644 index 0000000..bf1091b Binary files /dev/null and b/public/emoji/mood-fire.webp differ diff --git a/public/emoji/mood-melting.webp b/public/emoji/mood-melting.webp new file mode 100644 index 0000000..35e2761 Binary files /dev/null and b/public/emoji/mood-melting.webp differ diff --git a/public/emoji/mood-nerd.webp b/public/emoji/mood-nerd.webp new file mode 100644 index 0000000..5dda578 Binary files /dev/null and b/public/emoji/mood-nerd.webp differ diff --git a/public/emoji/mood-neutral.webp b/public/emoji/mood-neutral.webp new file mode 100644 index 0000000..ecc4392 Binary files /dev/null and b/public/emoji/mood-neutral.webp differ diff --git a/public/emoji/mood-nod.webp b/public/emoji/mood-nod.webp new file mode 100644 index 0000000..8ba7309 Binary files /dev/null and b/public/emoji/mood-nod.webp differ diff --git a/public/emoji/mood-nomouth.webp b/public/emoji/mood-nomouth.webp new file mode 100644 index 0000000..e10cc42 Binary files /dev/null and b/public/emoji/mood-nomouth.webp differ diff --git a/public/emoji/mood-salute.webp b/public/emoji/mood-salute.webp new file mode 100644 index 0000000..a209af0 Binary files /dev/null and b/public/emoji/mood-salute.webp differ diff --git a/public/emoji/mood-sparkles.webp b/public/emoji/mood-sparkles.webp new file mode 100644 index 0000000..1a660aa Binary files /dev/null and b/public/emoji/mood-sparkles.webp differ diff --git a/public/emoji/mood-starstruck.webp b/public/emoji/mood-starstruck.webp new file mode 100644 index 0000000..11d1186 Binary files /dev/null and b/public/emoji/mood-starstruck.webp differ diff --git a/public/emoji/mood-think.webp b/public/emoji/mood-think.webp new file mode 100644 index 0000000..0989aea Binary files /dev/null and b/public/emoji/mood-think.webp differ diff --git a/public/emoji/point-down.webp b/public/emoji/point-down.webp new file mode 100644 index 0000000..6a91541 Binary files /dev/null and b/public/emoji/point-down.webp differ diff --git a/public/emoji/sparkles.webp b/public/emoji/sparkles.webp new file mode 100644 index 0000000..1a660aa Binary files /dev/null and b/public/emoji/sparkles.webp differ diff --git a/public/emoji/tinker.webp b/public/emoji/tinker.webp new file mode 100644 index 0000000..94b7422 Binary files /dev/null and b/public/emoji/tinker.webp differ diff --git a/public/emoji/wave.webp b/public/emoji/wave.webp new file mode 100644 index 0000000..c9672f8 Binary files /dev/null and b/public/emoji/wave.webp differ diff --git a/src/components/hero/index.tsx b/src/components/hero/index.tsx index 4acdf84..2ad7547 100644 --- a/src/components/hero/index.tsx +++ b/src/components/hero/index.tsx @@ -1,5 +1,12 @@ +import { useState, useEffect, useRef } from "react"; import Typewriter from "typewriter-effect"; +interface GithubData { + status: { message: string } | null; + commit: { message: string; repo: string; date: string; url: string } | null; + tinkering: { repo: string; url: string } | null; +} + const html = (strings: TemplateStringsArray, ...values: any[]) => { let result = strings[0]; for (let i = 0; i < values.length; i++) { @@ -8,75 +15,160 @@ const html = (strings: TemplateStringsArray, ...values: any[]) => { return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim(); }; -interface TypewriterOptions { - autoStart: boolean; - loop: boolean; - delay: number; - deleteSpeed: number; - cursor: string; +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } interface TypewriterInstance { typeString: (str: string) => TypewriterInstance; pauseFor: (ms: number) => TypewriterInstance; deleteAll: () => TypewriterInstance; + callFunction: (cb: () => void) => TypewriterInstance; start: () => TypewriterInstance; } +const emoji = (name: string) => + ``; + +const SECTION_1 = html` + Hello, I'm +
+ Timothy Pidashev ${emoji("wave")} +`; + +const SECTION_2 = html` + I've been turning +
+ coffee into code +
+ since 2018 ${emoji("sparkles")} +`; + +const SECTION_3 = html` + Check out my +
+ blog/ + projects or +
+ contact me below ${emoji("point-down")} +`; + +const MOODS = [ + "mood-cool", "mood-nerd", "mood-think", "mood-starstruck", + "mood-fire", "mood-cold", "mood-salute", + "mood-dotted", "mood-expressionless", "mood-neutral", + "mood-nomouth", "mood-nod", "mood-melting", +]; + +function addGreetings(tw: TypewriterInstance) { + tw.typeString(SECTION_1).pauseFor(2000).deleteAll() + .typeString(SECTION_2).pauseFor(2000).deleteAll() + .typeString(SECTION_3).pauseFor(2000).deleteAll(); +} + +function addGithubSections(tw: TypewriterInstance, github: GithubData) { + if (github.status) { + const moodImg = emoji(MOODS[Math.floor(Math.random() * MOODS.length)]); + const statusStr = + `My current mood ${moodImg}` + + `
` + + `${escapeHtml(github.status.message)}`; + tw.typeString(statusStr).pauseFor(3000).deleteAll(); + } + + if (github.tinkering) { + const tinkerImg = emoji("tinker"); + const tinkerStr = + `Currently tinkering with ${tinkerImg}` + + `
` + + `${github.tinkering.url}`; + tw.typeString(tinkerStr).pauseFor(3000).deleteAll(); + } + + if (github.commit) { + const ago = timeAgo(github.commit.date); + const memoImg = emoji("memo"); + const repoUrl = `https://github.com/timmypidashev/${github.commit.repo}`; + const commitStr = + `My latest (unbroken?) commit ${memoImg}` + + `
` + + `"${escapeHtml(github.commit.message)}"` + + `
` + + `${escapeHtml(github.commit.repo)}` + + ` ยท ${ago}`; + tw.typeString(commitStr).pauseFor(3000).deleteAll(); + } +} + export default function Hero() { - const SECTION_1 = html` - Hello, I'm -
- Timothy Pidashev - `; + const [phase, setPhase] = useState<"intro" | "full">("intro"); + const githubRef = useRef(null); - const SECTION_2 = html` - I've been turning -
- coffee into - code -
- since 2018! - `; + useEffect(() => { + fetch("/api/github") + .then((r) => r.json()) + .then((data) => { githubRef.current = data; }) + .catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; }); + }, []); - const SECTION_3 = html` - Check out my -
- blog/ - projects or -
- contact me below! - `; - - const handleInit = (typewriter: TypewriterInstance): void => { - typewriter - .typeString(SECTION_1) - .pauseFor(2000) - .deleteAll() - .typeString(SECTION_2) - .pauseFor(2000) - .deleteAll() - .typeString(SECTION_3) - .pauseFor(2000) - .deleteAll() - .start(); + const handleIntroInit = (typewriter: TypewriterInstance): void => { + addGreetings(typewriter); + typewriter.callFunction(() => { + // Greetings done โ€” data is almost certainly ready (API ~500ms, greetings ~20s) + const check = () => { + if (githubRef.current) { + setPhase("full"); + } else { + setTimeout(check, 200); + } + }; + check(); + }).start(); }; - const typewriterOptions: TypewriterOptions = { - autoStart: true, - loop: true, - delay: 50, - deleteSpeed: 800, - cursor: '|' + const handleFullInit = (typewriter: TypewriterInstance): void => { + const github = githubRef.current!; + // GitHub sections first (greetings just played in intro phase) + addGithubSections(typewriter, github); + // Then greetings for the loop + addGreetings(typewriter); + typewriter.start(); }; + const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" }; + return (
- + {phase === "intro" ? ( + + ) : ( + + )}
); diff --git a/src/pages/api/github.ts b/src/pages/api/github.ts new file mode 100644 index 0000000..1d661ba --- /dev/null +++ b/src/pages/api/github.ts @@ -0,0 +1,96 @@ +import type { APIRoute } from "astro"; + +const GITHUB_USER = "timmypidashev"; + +export const GET: APIRoute = async () => { + const token = import.meta.env.GITHUB_TOKEN; + const headers: Record = { + Accept: "application/json", + "User-Agent": "timmypidashev-web", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let status: { message: string } | null = null; + let commit: { message: string; repo: string; date: string; url: string } | null = null; + let tinkering: { repo: string; url: string } | null = null; + + // Fetch user status via GraphQL (requires token) + if (token) { + try { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `{ user(login: "${GITHUB_USER}") { status { message } } }`, + }), + }); + const data = await res.json(); + const s = data?.data?.user?.status; + if (s?.message) { + status = { message: s.message }; + } + } catch { + // Status unavailable โ€” skip + } + } + + // Fetch latest public push event, then fetch commit details + try { + const eventsRes = await fetch( + `https://api.github.com/users/${GITHUB_USER}/events/public?per_page=30`, + { headers }, + ); + const events = await eventsRes.json(); + // Find most active repo from recent push events + if (Array.isArray(events)) { + const repoCounts: Record = {}; + for (const e of events) { + if (e.type === "PushEvent") { + const name = e.repo.name.replace(`${GITHUB_USER}/`, ""); + repoCounts[name] = (repoCounts[name] || 0) + 1; + } + } + const topRepo = Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0]; + if (topRepo) { + tinkering = { + repo: topRepo[0], + url: `https://github.com/${GITHUB_USER}/${topRepo[0]}`, + }; + } + } + + const push = Array.isArray(events) + ? events.find((e: any) => e.type === "PushEvent") + : null; + if (push) { + const repo = push.repo.name.replace(`${GITHUB_USER}/`, ""); + const sha = push.payload?.head; + if (sha) { + const commitRes = await fetch( + `https://api.github.com/repos/${GITHUB_USER}/${repo}/commits/${sha}`, + { headers }, + ); + const commitData = await commitRes.json(); + if (commitData?.commit?.message) { + commit = { + message: commitData.commit.message.split("\n")[0], + repo, + date: push.created_at, + url: commitData.html_url, + }; + } + } + } + } catch { + // Commit unavailable โ€” skip + } + + return new Response(JSON.stringify({ status, commit, tinkering }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=300", + }, + }); +};