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",
+ },
+ });
+};