Update hero section; part 1
BIN
public/emoji/coffee.webp
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/emoji/lightbulb.webp
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
public/emoji/memo.webp
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
public/emoji/mood-cold.webp
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/emoji/mood-cool.webp
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/emoji/mood-dotted.webp
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
public/emoji/mood-expressionless.webp
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/emoji/mood-fire.webp
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
public/emoji/mood-melting.webp
Normal file
|
After Width: | Height: | Size: 867 KiB |
BIN
public/emoji/mood-nerd.webp
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
public/emoji/mood-neutral.webp
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/emoji/mood-nod.webp
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
public/emoji/mood-nomouth.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/emoji/mood-salute.webp
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
public/emoji/mood-sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/mood-starstruck.webp
Normal file
|
After Width: | Height: | Size: 567 KiB |
BIN
public/emoji/mood-think.webp
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
public/emoji/point-down.webp
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
public/emoji/sparkles.webp
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/emoji/tinker.webp
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
public/emoji/wave.webp
Normal file
|
After Width: | Height: | Size: 390 KiB |
@@ -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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
interface TypewriterInstance {
|
||||
typeString: (str: string) => TypewriterInstance;
|
||||
pauseFor: (ms: number) => TypewriterInstance;
|
||||
deleteAll: () => TypewriterInstance;
|
||||
callFunction: (cb: () => void) => TypewriterInstance;
|
||||
start: () => TypewriterInstance;
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
const SECTION_1 = html`
|
||||
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`
|
||||
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`
|
||||
const SECTION_3 = html`
|
||||
<span>Check out my</span>
|
||||
<br><div class="mb-4"></div>
|
||||
<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
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||