Update hero section; part 1

This commit is contained in:
2026-04-06 17:57:29 -07:00
parent 153bd0cf39
commit 9b626faba8
23 changed files with 238 additions and 50 deletions

BIN
public/emoji/coffee.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
public/emoji/lightbulb.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
public/emoji/memo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

BIN
public/emoji/mood-cold.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

BIN
public/emoji/mood-cool.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
public/emoji/mood-fire.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

BIN
public/emoji/mood-nerd.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
public/emoji/mood-nod.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
public/emoji/sparkles.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
public/emoji/tinker.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

BIN
public/emoji/wave.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -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,35 +15,49 @@ 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
interface TypewriterInstance {
typeString: (str: string) => TypewriterInstance;
pauseFor: (ms: number) => TypewriterInstance;
deleteAll: () => TypewriterInstance;
callFunction: (cb: () => void) => TypewriterInstance;
start: () => TypewriterInstance;
}
export default function Hero() {
const emoji = (name: string) =>
`<img src="/emoji/${name}.webp" alt="" style="display:inline;height:1em;width:1em;vertical-align:middle">`;
const SECTION_1 = html`
<span>Hello, I'm</span>
<br><div class="mb-4"></div>
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a></span>
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a> ${emoji("wave")}</span>
`;
const SECTION_2 = html`
<span>I've been turning</span>
<br><div class="mb-4"></div>
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into
<a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into <a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
<br><div class="mb-4"></div>
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a>!</span>
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
`;
const SECTION_3 = html`
@@ -45,38 +66,109 @@ export default function Hero() {
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
<br><div class="mb-4"></div>
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below!</span>
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below ${emoji("point-down")}</span>
`;
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 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 =
`<span>My current mood ${moodImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="https://github.com/timmypidashev" target="_blank" class="text-orange-bright hover:underline">${escapeHtml(github.status.message)}</a>`;
tw.typeString(statusStr).pauseFor(3000).deleteAll();
}
if (github.tinkering) {
const tinkerImg = emoji("tinker");
const tinkerStr =
`<span>Currently tinkering with ${tinkerImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.tinkering.url}" target="_blank" class="text-yellow hover:underline">${github.tinkering.url}</a>`;
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 =
`<span>My latest <span class="text-foreground/40">(unbroken?)</span> commit ${memoImg}</span>` +
`<br><div class="mb-4"></div>` +
`<a href="${github.commit.url}" target="_blank" class="text-green hover:underline">"${escapeHtml(github.commit.message)}"</a>` +
`<br><div class="mb-4"></div>` +
`<a href="${repoUrl}" target="_blank" class="text-yellow hover:underline">${escapeHtml(github.commit.repo)}</a>` +
`<span class="text-foreground/40"> · ${ago}</span>`;
tw.typeString(commitStr).pauseFor(3000).deleteAll();
}
}
export default function Hero() {
const [phase, setPhase] = useState<"intro" | "full">("intro");
const githubRef = useRef<GithubData | null>(null);
useEffect(() => {
fetch("/api/github")
.then((r) => r.json())
.then((data) => { githubRef.current = data; })
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
}, []);
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 (
<div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
{phase === "intro" ? (
<Typewriter
options={typewriterOptions}
onInit={handleInit}
key="intro"
options={{ ...baseOptions, autoStart: true, loop: false }}
onInit={handleIntroInit}
/>
) : (
<Typewriter
key="full"
options={{ ...baseOptions, autoStart: true, loop: true }}
onInit={handleFullInit}
/>
)}
</div>
</div>
);

96
src/pages/api/github.ts Normal file
View File

@@ -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<string, string> = {
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<string, number> = {};
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",
},
});
};