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";
|
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[]) => {
|
const html = (strings: TemplateStringsArray, ...values: any[]) => {
|
||||||
let result = strings[0];
|
let result = strings[0];
|
||||||
for (let i = 0; i < values.length; i++) {
|
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();
|
return result.replace(/\n\s*/g, "").replace(/\s+/g, " ").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TypewriterOptions {
|
function timeAgo(dateStr: string): string {
|
||||||
autoStart: boolean;
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||||
loop: boolean;
|
if (seconds < 60) return "just now";
|
||||||
delay: number;
|
const minutes = Math.floor(seconds / 60);
|
||||||
deleteSpeed: number;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
cursor: string;
|
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 {
|
interface TypewriterInstance {
|
||||||
typeString: (str: string) => TypewriterInstance;
|
typeString: (str: string) => TypewriterInstance;
|
||||||
pauseFor: (ms: number) => TypewriterInstance;
|
pauseFor: (ms: number) => TypewriterInstance;
|
||||||
deleteAll: () => TypewriterInstance;
|
deleteAll: () => TypewriterInstance;
|
||||||
|
callFunction: (cb: () => void) => TypewriterInstance;
|
||||||
start: () => TypewriterInstance;
|
start: () => TypewriterInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> ${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>
|
||||||
|
<br><div class="mb-4"></div>
|
||||||
|
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a> ${emoji("sparkles")}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 ${emoji("point-down")}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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() {
|
export default function Hero() {
|
||||||
const SECTION_1 = html`
|
const [phase, setPhase] = useState<"intro" | "full">("intro");
|
||||||
<span>Hello, I'm</span>
|
const githubRef = useRef<GithubData | null>(null);
|
||||||
<br><div class="mb-4"></div>
|
|
||||||
<span><a href="/about" class="text-aqua hover:underline"><strong>Timothy Pidashev</strong></a></span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SECTION_2 = html`
|
useEffect(() => {
|
||||||
<span>I've been turning</span>
|
fetch("/api/github")
|
||||||
<br><div class="mb-4"></div>
|
.then((r) => r.json())
|
||||||
<span><a href="/projects" class="text-green hover:underline"><strong>coffee</strong></a> into
|
.then((data) => { githubRef.current = data; })
|
||||||
<a href="/projects" class="text-yellow hover:underline"><strong>code</strong></a></span>
|
.catch(() => { githubRef.current = { status: null, commit: null, tinkering: null }; });
|
||||||
<br><div class="mb-4"></div>
|
}, []);
|
||||||
<span>since <a href="/about" class="text-blue hover:underline"><strong>2018</strong></a>!</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SECTION_3 = html`
|
const handleIntroInit = (typewriter: TypewriterInstance): void => {
|
||||||
<span>Check out my</span>
|
addGreetings(typewriter);
|
||||||
<br><div class="mb-4"></div>
|
typewriter.callFunction(() => {
|
||||||
<span><a href="/blog" class="text-purple hover:underline"><strong>blog</strong></a>/
|
// Greetings done — data is almost certainly ready (API ~500ms, greetings ~20s)
|
||||||
<a href="/projects" class="text-blue hover:underline"><strong>projects</strong></a> or</span>
|
const check = () => {
|
||||||
<br><div class="mb-4"></div>
|
if (githubRef.current) {
|
||||||
<span><a href="/contact" class="text-green hover:underline"><strong>contact</strong></a> me below!</span>
|
setPhase("full");
|
||||||
`;
|
} else {
|
||||||
|
setTimeout(check, 200);
|
||||||
const handleInit = (typewriter: TypewriterInstance): void => {
|
}
|
||||||
typewriter
|
};
|
||||||
.typeString(SECTION_1)
|
check();
|
||||||
.pauseFor(2000)
|
}).start();
|
||||||
.deleteAll()
|
|
||||||
.typeString(SECTION_2)
|
|
||||||
.pauseFor(2000)
|
|
||||||
.deleteAll()
|
|
||||||
.typeString(SECTION_3)
|
|
||||||
.pauseFor(2000)
|
|
||||||
.deleteAll()
|
|
||||||
.start();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const typewriterOptions: TypewriterOptions = {
|
const handleFullInit = (typewriter: TypewriterInstance): void => {
|
||||||
autoStart: true,
|
const github = githubRef.current!;
|
||||||
loop: true,
|
// GitHub sections first (greetings just played in intro phase)
|
||||||
delay: 50,
|
addGithubSections(typewriter, github);
|
||||||
deleteSpeed: 800,
|
// Then greetings for the loop
|
||||||
cursor: '|'
|
addGreetings(typewriter);
|
||||||
|
typewriter.start();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseOptions = { delay: 35, deleteSpeed: 35, cursor: "|" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-screen pointer-events-none">
|
<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">
|
<div className="text-2xl md:text-4xl font-bold text-center pointer-events-none [&_a]:pointer-events-auto">
|
||||||
<Typewriter
|
{phase === "intro" ? (
|
||||||
options={typewriterOptions}
|
<Typewriter
|
||||||
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>
|
||||||
</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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||