Mobile optimizations

This commit is contained in:
2026-04-06 13:08:41 -07:00
parent bab4a516be
commit c2407408fa
33 changed files with 936 additions and 318 deletions

View File

@@ -12,6 +12,7 @@ export default defineConfig({
output: "server",
adapter: vercel(),
site: "https://timmypidashev.dev",
devToolbar: { enabled: false },
build: {
// Enable build-time optimizations
inlineStylesheets: "auto",

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Code2, BookOpen, RocketIcon, Compass } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
}}
>
{children}
</div>
);
}
import { AnimateIn } from "@/components/animate-in";
export default function CurrentFocus() {
const recentProjects = [
@@ -98,7 +46,7 @@ export default function CurrentFocus() {
<a
href={project.href}
className="block p-4 sm:p-6 rounded-lg border border-foreground/10 hover:border-yellow-bright/50
transition-all duration-300 group bg-background/50 h-full"
transition-colors duration-300 group bg-background/50 h-full"
>
<h4 className="font-bold text-lg group-hover:text-yellow-bright transition-colors">
{project.title}

View File

@@ -48,36 +48,37 @@ export default function Intro() {
const anim = (delay: number) =>
({
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
transition: `all 0.7s ease-out ${delay}ms`,
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,20px,0)",
transition: `opacity 0.7s ease-out ${delay}ms, transform 0.7s ease-out ${delay}ms`,
willChange: "transform, opacity",
}) as React.CSSProperties;
return (
<div ref={ref} className="w-full max-w-4xl px-4">
<div className="space-y-8 md:space-y-12">
<div className="flex flex-col sm:flex-row items-center sm:items-center justify-center gap-8 sm:gap-16">
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 sm:gap-16">
<div
className="w-32 h-32 sm:w-48 sm:h-48 shrink-0"
className="w-44 h-44 sm:w-40 sm:h-40 lg:w-48 lg:h-48 shrink-0"
style={anim(0)}
>
<img
src="/me.jpeg"
alt="Timothy Pidashev"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-all duration-300"
className="rounded-lg object-cover w-full h-full ring-2 ring-yellow-bright hover:ring-orange-bright transition-colors duration-300"
/>
</div>
<div className="text-center sm:text-left space-y-4 sm:space-y-6" style={anim(150)}>
<h2 className="text-xl sm:text-5xl font-bold text-yellow-bright">
<h2 className="text-3xl sm:text-3xl lg:text-5xl font-bold text-yellow-bright">
Timothy Pidashev
</h2>
<div className="text-sm sm:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(300)}>
<div className="text-base sm:text-lg lg:text-xl text-foreground/70 space-y-2 sm:space-y-3">
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(300)}>
<span className="text-blue">Software Systems Engineer</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(450)}>
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(450)}>
<span className="text-green">Open Source Enthusiast</span>
</p>
<p className="flex items-center justify-center font-bold sm:justify-start gap-2" style={anim(600)}>
<p className="flex items-center justify-center sm:justify-start font-bold gap-2" style={anim(600)}>
<span className="text-yellow">Coffee Connoisseur</span>
</p>
</div>

View File

@@ -1,57 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Cross, Fish, Mountain, Book } from "lucide-react";
function AnimateIn({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = performance.getEntriesByType?.("navigation")?.[0]?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={skip ? "" : "transition-all duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0) scale(1)" : "translateY(20px) scale(0.97)",
}}
>
{children}
</div>
);
}
import { AnimateIn } from "@/components/animate-in";
const interests = [
{
@@ -86,12 +34,12 @@ export default function OutsideCoding() {
</h2>
</AnimateIn>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
{interests.map((interest, i) => (
<AnimateIn key={interest.title} delay={100 + i * 100}>
<div
className="flex flex-col items-center text-center p-4 rounded-lg border border-foreground/10
hover:border-yellow-bright/50 transition-all duration-300 bg-background/50 h-full"
hover:border-yellow-bright/50 transition-colors duration-300 bg-background/50 h-full"
>
<div className="mb-3">{interest.icon}</div>
<h3 className="font-bold text-foreground/90 mb-2">{interest.title}</h3>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
interface ActivityDay {
grand_total: { total_seconds: number };
@@ -9,6 +10,7 @@ interface ActivityGridProps {
}
export const ActivityGrid = ({ data }: ActivityGridProps) => {
const [tapped, setTapped] = useState<string | null>(null);
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -46,7 +48,7 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
}
return (
<div className="bg-background border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-foreground/20 transition-colors">
<div className="text-lg text-aqua-bright mb-6">Activity</div>
<div className="flex gap-4">
@@ -69,12 +71,13 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
<div
key={dayIndex}
className={`w-3 h-3 rounded-sm ${getColorClass(intensity)}
hover:ring-1 hover:ring-foreground/30 transition-all cursor-pointer
hover:ring-1 hover:ring-foreground/30 transition-colors cursor-pointer
group relative`}
onClick={() => setTapped(tapped === day.date ? null : day.date)}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md opacity-0
group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap text-xs">
<div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2
bg-background border border-foreground/10 rounded-md transition-opacity z-10 whitespace-nowrap text-xs
${tapped === day.date ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
{hours.toFixed(1)} hours on {day.date}
</div>
</div>

View File

@@ -60,21 +60,23 @@ const Stats = () => {
const totalSeconds = stats.total_seconds;
const duration = 2000;
const steps = 60;
let currentStep = 0;
let start: number | null = null;
let rafId: number;
const timer = setInterval(() => {
currentStep += 1;
if (currentStep >= steps) {
setCount(totalSeconds);
clearInterval(timer);
return;
const step = (timestamp: number) => {
if (!start) start = timestamp;
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(totalSeconds * eased));
if (progress < 1) {
rafId = requestAnimationFrame(step);
}
const progress = 1 - Math.pow(1 - currentStep / steps, 4);
setCount(Math.floor(totalSeconds * progress));
}, duration / steps);
};
return () => clearInterval(timer);
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}, [isVisible, stats]);
if (error) return null;
@@ -88,25 +90,25 @@ const Stats = () => {
return (
<div ref={sectionRef} className="flex flex-col items-center justify-center min-h-[50vh] gap-6">
<div className={skipAnim ? "text-2xl opacity-80" : `text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
<div className={skipAnim ? "text-lg md:text-2xl opacity-80" : `text-lg md:text-2xl opacity-0 ${isVisible ? "animate-fade-in-first" : ""}`}>
I've spent
</div>
<div className="relative">
<div className="text-8xl text-center relative z-10">
<div className="text-5xl md:text-8xl text-center relative z-10">
<span className="font-bold relative">
<span className={skipAnim ? "bg-gradient-text" : `bg-gradient-text opacity-0 ${isVisible ? "animate-fade-in-second" : ""}`}>
{formattedHours}
</span>
</span>
<span className={skipAnim ? "text-4xl opacity-60 ml-4" : `text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
<span className={skipAnim ? "text-2xl md:text-4xl opacity-60 ml-4" : `text-2xl md:text-4xl opacity-0 ${isVisible ? "animate-slide-in-hours" : ""}`}>
hours
</span>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center">
<div className={skipAnim ? "text-xl opacity-80" : `text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
<div className={skipAnim ? "text-base md:text-xl opacity-80" : `text-base md:text-xl opacity-0 ${isVisible ? "animate-fade-in-third" : ""}`}>
writing code & building apps
</div>
<div className={skipAnim ? "flex items-center gap-3 text-lg opacity-60" : `flex items-center gap-3 text-lg opacity-0 ${isVisible ? "animate-fade-in-fourth" : ""}`}>

View File

@@ -131,7 +131,7 @@ const DetailedStats = () => {
<>
{/* Header */}
<h2
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
className={`text-2xl md:text-4xl font-bold text-center text-yellow-bright ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : {
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(20px)",
@@ -147,7 +147,7 @@ const DetailedStats = () => {
return (
<div
key={card.title}
className={`bg-background border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-all duration-500 ease-out"}`}
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 ${card.borderHover} ${skipAnim ? "" : "transition-[opacity,transform] duration-500 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: `${150 + i * 100}ms`,
opacity: visible ? 1 : 0,
@@ -174,7 +174,7 @@ const DetailedStats = () => {
{/* Languages */}
<div
className={`bg-background border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-all duration-700 ease-out"}`}
className={`bg-background/50 border border-foreground/10 rounded-lg p-6 hover:border-purple-bright/50 ${skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}`}
style={skipAnim ? {} : {
transitionDelay: "550ms",
opacity: visible ? 1 : 0,
@@ -194,9 +194,11 @@ const DetailedStats = () => {
<div
className={`h-full ${lang.color} rounded-full`}
style={{
width: visible ? `${lang.percent}%` : "0%",
width: `${lang.percent}%`,
opacity: 0.85,
transition: skipAnim ? "none" : `width 1s ease-out ${700 + i * 80}ms`,
transform: visible ? "scaleX(1)" : "scaleX(0)",
transformOrigin: "left",
transition: skipAnim ? "none" : `transform 1s ease-out ${700 + i * 80}ms`,
}}
/>
</div>
@@ -212,7 +214,7 @@ const DetailedStats = () => {
{/* Activity Grid */}
{activity && (
<div
className={skipAnim ? "" : "transition-all duration-700 ease-out"}
className={skipAnim ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skipAnim ? {} : {
transitionDelay: "750ms",
opacity: visible ? 1 : 0,

View File

@@ -87,7 +87,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
absolute -left-8 sm:left-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-background
rounded-full border-2 border-yellow-bright sm:-translate-x-1/2
flex items-center justify-center z-10
${skip ? "" : "transition-all duration-500"}
${skip ? "" : "transition-[opacity,transform] duration-500"}
${visible ? "scale-100 opacity-100" : "scale-0 opacity-0"}
`}
>
@@ -99,7 +99,7 @@ function TimelineCard({ item, index }: { item: (typeof timelineItems)[number]; i
className={`
w-full sm:w-[calc(50%-32px)]
${isLeft ? "sm:pr-8 md:pr-12" : "sm:pl-8 md:pl-12"}
${skip ? "" : "transition-all duration-700 ease-out"}
${skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
${visible
? "opacity-100 translate-x-0"
: `opacity-0 ${isLeft ? "sm:translate-x-8" : "sm:-translate-x-8"} translate-y-4 sm:translate-y-0`
@@ -168,8 +168,8 @@ export default function Timeline() {
<div className="absolute left-4 sm:left-1/2 h-full w-0.5 -translate-x-1/2">
<div
ref={lineRef}
className="w-full bg-foreground/10 transition-all duration-[1500ms] ease-out origin-top"
style={{ height: `${lineHeight}%` }}
className="w-full h-full bg-foreground/10 transition-transform duration-[1500ms] ease-out origin-top"
style={{ transform: `scaleY(${lineHeight / 100})` }}
/>
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
interface AnimateInProps {
children: React.ReactNode;
@@ -9,13 +10,28 @@ interface AnimateInProps {
export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [skip, setSkip] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (prefersReducedMotion()) {
setSkip(true);
setVisible(true);
return;
}
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
const inView = rect.top < window.innerHeight && rect.bottom > 0;
const isReload = (performance.getEntriesByType?.("navigation")?.[0] as PerformanceNavigationTiming)?.type === "reload";
if (inView && isReload) {
setSkip(true);
setVisible(true);
return;
}
if (inView) {
requestAnimationFrame(() => setVisible(true));
return;
}
@@ -37,11 +53,12 @@ export function AnimateIn({ children, delay = 0, threshold = 0.15 }: AnimateInPr
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
style={{
className={skip ? "" : "transition-[opacity,transform] duration-700 ease-out"}
style={skip ? {} : {
transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}}
>
{children}

View File

@@ -41,7 +41,7 @@ export default function AnimationSwitcher() {
return (
<div
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden md:block"
className="fixed bottom-4 left-4 z-[101] pointer-events-auto hidden lg:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}

View File

@@ -1,25 +1,50 @@
import * as React from "react";
import Giscus from "@giscus/react";
import { getStoredThemeId } from "@/lib/themes/engine";
const id = "inject-comments";
function getThemeUrl(themeId: string): string {
// Giscus iframe needs a publicly accessible URL — always use production domain
return `https://timmypidashev.dev/api/giscus-theme?theme=${themeId}`;
}
export const Comments = () => {
const [mounted, setMounted] = React.useState(false);
const [themeUrl, setThemeUrl] = React.useState("");
React.useEffect(() => {
setThemeUrl(getThemeUrl(getStoredThemeId()));
setMounted(true);
const handleThemeChange = () => {
const newUrl = getThemeUrl(getStoredThemeId());
setThemeUrl(newUrl);
// Tell the giscus iframe to update its theme
const iframe = document.querySelector<HTMLIFrameElement>("iframe.giscus-frame");
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ giscus: { setConfig: { theme: newUrl } } },
"https://giscus.app"
);
}
};
document.addEventListener("theme-changed", handleThemeChange);
return () => document.removeEventListener("theme-changed", handleThemeChange);
}, []);
return (
<div id={id}>
{mounted ? (
<div id={id} className="mt-8">
{mounted && themeUrl ? (
<Giscus
id={id}
repo="timmypidashev/web"
repoId="MDEwOlJlcG9zaXRvcnkzODYxMjk5Mjk="
category="Blog & Project Comments"
categoryId="DIC_kwDOFwPgCc4CpKtV"
theme="https://timmypidashev.us-sea-1.linodeobjects.com/comments.css"
theme={themeUrl}
mapping="pathname"
strict="0"
reactionsEnabled="1"

View File

@@ -1,15 +1,18 @@
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
import { AnimateIn } from "@/components/animate-in";
import { TypedText } from "@/components/typed-text";
export const BlogHeader = () => {
return (
<div className="w-full max-w-6xl mx-auto px-4 pt-24 sm:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
Latest Thoughts <br className="sm:hidden" />
& Writings
</h1>
</AnimateIn>
<div className="w-full max-w-6xl mx-auto px-4 pt-12 md:pt-24">
<div className="mb-3 text-center px-4">
<TypedText
text="Latest Thoughts & Writings"
as="h1"
className="text-2xl sm:text-3xl font-bold text-purple leading-relaxed"
speed={20}
/>
</div>
<AnimateIn delay={100}>
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
<a

View File

@@ -36,7 +36,7 @@ export const BlogPostList = ({ posts }: BlogPostListProps) => {
href={`/blog/${post.id}`}
className="block"
>
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-[outline-color] duration-200">
{/* Image container with fixed aspect ratio */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img

View File

@@ -1,4 +1,5 @@
import { AnimateIn } from "@/components/animate-in";
import { TypedText } from "@/components/typed-text";
import { RssIcon, TagIcon, TrendingUpIcon } from "lucide-react";
type BlogPost = {
@@ -30,12 +31,15 @@ const formatDate = (dateString: string) => {
const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
return (
<div className="w-full max-w-6xl mx-auto">
<div className="w-full px-4 pt-24 sm:pt-24">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-purple mb-3 text-center px-4 leading-relaxed">
#{tag}
</h1>
</AnimateIn>
<div className="w-full px-4 pt-12 md:pt-24">
<div className="mb-3 text-center px-4">
<TypedText
text={`#${tag}`}
as="h1"
className="text-2xl sm:text-3xl font-bold text-purple leading-relaxed"
speed={20}
/>
</div>
<AnimateIn delay={100}>
<div className="flex flex-wrap justify-center gap-4 mb-12 text-sm sm:text-base">
<a
@@ -68,7 +72,7 @@ const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => {
<AnimateIn key={post.id} delay={200 + i * 80}>
<li className="group px-4 md:px-0">
<a href={`/blog/${post.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-all duration-200">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-purple transition-[outline-color] duration-200">
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-background flex-shrink-0">
<img
src={post.data.image || "/blog/placeholder.png"}

View File

@@ -12,7 +12,7 @@ export default function Footer({ fixed = false }) {
return (
<footer className={`w-full font-bold pointer-events-none ${fixed ? "fixed bottom-0 left-0 right-0" : ""}`}>
<div className="flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
<div className="hidden lg:flex flex-row px-2 py-1 text-lg lg:px-6 lg:py-1.5 lg:text-3xl md:text-2xl justify-between md:justify-center space-x-2 md:space-x-10 lg:space-x-20 pointer-events-none [&_a]:pointer-events-auto">
{footerLinks}
</div>
</footer>

View File

@@ -92,7 +92,7 @@ export default function Header({ transparent = false }: { transparent?: boolean
`}
>
<div className={`
w-full flex flex-row items-center justify-center
w-full hidden lg:flex flex-row items-center justify-center
pointer-events-none
${!isIndexPage ? 'bg-background md:bg-transparent' : ''}
`}>

View File

@@ -72,7 +72,7 @@ export default function Hero() {
return (
<div className="flex justify-center items-center min-h-screen pointer-events-none">
<div className="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
options={typewriterOptions}
onInit={handleInit}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect, useRef } from "react";
import { Home, User, FolderOpen, BookOpen, FileText, Settings } from "lucide-react";
import { SettingsSheet } from "./settings-sheet";
const tabs = [
{ href: "/", label: "Home", icon: Home, color: "text-green" },
{ href: "/about", label: "About", icon: User, color: "text-yellow" },
{ href: "/projects", label: "Projects", icon: FolderOpen, color: "text-blue" },
{ href: "/blog", label: "Blog", icon: BookOpen, color: "text-purple" },
{ href: "/resume", label: "Resume", icon: FileText, color: "text-aqua" },
];
export default function MobileNav({ transparent = false }: { transparent?: boolean }) {
const [path, setPath] = useState("/");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visible, setVisible] = useState(true);
const lastScrollY = useRef(0);
const lastTime = useRef(Date.now());
useEffect(() => {
setPath(window.location.pathname);
}, []);
useEffect(() => {
const handleScroll = () => {
const y = document.documentElement.scrollTop;
const now = Date.now();
const dt = now - lastTime.current;
const dy = lastScrollY.current - y; // positive = scrolling up
const velocity = dt > 0 ? dy / dt : 0; // px/ms
if (y < 10) {
setVisible(true);
} else if (dy > 0 && velocity > 1.5) {
// Fast upward scroll
setVisible(true);
} else if (dy < 0) {
// Scrolling down
setVisible(false);
}
lastScrollY.current = y;
lastTime.current = now;
};
document.addEventListener("scroll", handleScroll, { passive: true });
return () => document.removeEventListener("scroll", handleScroll);
}, []);
const isActive = (href: string) => {
if (href === "/") return path === "/";
return path.startsWith(href);
};
return (
<>
<nav
className={`fixed bottom-0 left-0 right-0 z-50 lg:hidden transition-transform duration-300 ${
visible ? "translate-y-0" : "translate-y-full"
} ${
transparent
? "bg-transparent"
: "bg-background/30 backdrop-blur-sm"
}`}
style={{
paddingBottom: "env(safe-area-inset-bottom, 0px)",
touchAction: "manipulation",
}}
>
<div className="flex items-center justify-around px-1 h-14">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<a
key={tab.href}
href={tab.href}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
active ? tab.color : "text-foreground/40"
}`}
>
<Icon size={20} strokeWidth={active ? 2 : 1.5} />
<span className="text-[10px]">{tab.label}</span>
</a>
);
})}
<button
onClick={() => setSettingsOpen(true)}
className={`flex flex-col items-center justify-center gap-0.5 flex-1 py-1 ${
settingsOpen ? "text-foreground" : "text-foreground/40"
}`}
>
<Settings size={20} strokeWidth={1.5} />
<span className="text-[10px]">Settings</span>
</button>
</div>
</nav>
<SettingsSheet open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { X, ExternalLink } from "lucide-react";
import { THEMES } from "@/lib/themes";
import { applyTheme, getStoredThemeId } from "@/lib/themes/engine";
import { ANIMATION_IDS, ANIMATION_LABELS, type AnimationId } from "@/lib/animations";
const footerLinks = [
{ href: "mailto:contact@timmypidashev.dev", label: "Contact", color: "text-green" },
{ href: "https://github.com/timmypidashev", label: "Github", color: "text-yellow" },
{ href: "https://www.linkedin.com/in/timothy-pidashev-4353812b8", label: "LinkedIn", color: "text-blue" },
{ href: "https://github.com/timmypidashev/web", label: "Source", color: "text-purple" },
];
const themeOptions = [
{ id: "darkbox", label: "classic", color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ id: "darkbox-retro", label: "retro", color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ id: "darkbox-dim", label: "dim", color: "text-purple-bright", activeBg: "bg-purple-bright/15", activeBorder: "border-purple-bright/40" },
];
const animOptions = [
{ id: "shuffle", color: "text-red-bright", activeBg: "bg-red-bright/15", activeBorder: "border-red-bright/40" },
{ id: "game-of-life", color: "text-green-bright", activeBg: "bg-green-bright/15", activeBorder: "border-green-bright/40" },
{ id: "lava-lamp", color: "text-orange-bright", activeBg: "bg-orange-bright/15", activeBorder: "border-orange-bright/40" },
{ id: "confetti", color: "text-yellow-bright", activeBg: "bg-yellow-bright/15", activeBorder: "border-yellow-bright/40" },
{ id: "asciiquarium", color: "text-aqua-bright", activeBg: "bg-aqua-bright/15", activeBorder: "border-aqua-bright/40" },
{ id: "pipes", color: "text-blue-bright", activeBg: "bg-blue-bright/15", activeBorder: "border-blue-bright/40" },
];
export function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const [currentTheme, setCurrentTheme] = useState(getStoredThemeId());
const [currentAnim, setCurrentAnim] = useState<string>("shuffle");
useEffect(() => {
setCurrentAnim(localStorage.getItem("animation") || "shuffle");
}, [open]);
const handleTheme = (id: string) => {
applyTheme(id);
setCurrentTheme(id);
onClose();
};
const handleAnim = (id: string) => {
localStorage.setItem("animation", id);
document.documentElement.dataset.animation = id;
document.dispatchEvent(new CustomEvent("animation-changed", { detail: { id } }));
setCurrentAnim(id);
onClose();
};
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-[60] bg-black/50 transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* Sheet */}
<div
className={`fixed left-0 right-0 bottom-0 z-[70] bg-background border-t border-foreground/10 rounded-t-2xl transition-transform duration-300 ease-out ${
open ? "translate-y-0" : "translate-y-full"
}`}
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
<div className="flex items-center justify-between px-5 pt-4 pb-2">
<span className="text-foreground/80 font-bold text-lg">Settings</span>
<button onClick={onClose} className="p-2 text-foreground/50">
<X size={20} />
</button>
</div>
<div className="px-5 pb-6 space-y-6">
{/* Theme */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Theme</div>
<div className="flex gap-2">
{themeOptions.map((opt) => (
<button
key={opt.id}
onClick={() => handleTheme(opt.id)}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentTheme === opt.id
? `${opt.activeBg} ${opt.color} ${opt.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Animation */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Animation</div>
<div className="grid grid-cols-3 gap-2">
{animOptions.map((opt) => (
<button
key={opt.id}
onClick={() => handleAnim(opt.id)}
className={`py-2.5 rounded-lg text-sm font-medium border transition-colors duration-200 ${
currentAnim === opt.id
? `${opt.activeBg} ${opt.color} ${opt.activeBorder}`
: "bg-foreground/5 text-foreground/40 border-transparent hover:text-foreground/60"
}`}
>
{ANIMATION_LABELS[opt.id as AnimationId]}
</button>
))}
</div>
</div>
{/* Links */}
<div>
<div className="text-foreground/50 text-xs uppercase tracking-wider mb-2">Links</div>
<div className="flex flex-wrap justify-center gap-3">
{footerLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={`${link.color} inline-flex items-center gap-1 text-sm`}
>
{link.label}
<ExternalLink size={12} className="opacity-50" />
</a>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,5 +1,6 @@
import type { CollectionEntry } from "astro:content";
import { AnimateIn } from "@/components/animate-in";
import { TypedText } from "@/components/typed-text";
interface ProjectListProps {
projects: CollectionEntry<"projects">[];
@@ -7,20 +8,22 @@ interface ProjectListProps {
export function ProjectList({ projects }: ProjectListProps) {
return (
<div className="w-full max-w-6xl mx-auto pt-24 sm:pt-32 px-4">
<AnimateIn>
<h1 className="text-2xl sm:text-3xl font-bold text-blue mb-12 text-center leading-relaxed">
Here's what I've been <br className="sm:hidden" />
building lately
</h1>
</AnimateIn>
<div className="w-full max-w-6xl mx-auto pt-12 md:pt-24 lg:pt-32 px-4">
<div className="mb-12 text-center">
<TypedText
text="Here's what I've been building lately"
as="h1"
className="text-2xl sm:text-3xl font-bold text-blue leading-relaxed"
speed={20}
/>
</div>
<ul className="space-y-6 md:space-y-10">
{projects.map((project, i) => (
<AnimateIn key={project.id} delay={i * 80}>
<li className="group">
<a href={`/projects/${project.id}`} className="block">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-all duration-200">
<article className="flex flex-col md:flex-row md:items-center gap-4 md:gap-8 p-2 md:p-4 border-b border-foreground/20 last:border-b-0 rounded-lg group-hover:outline group-hover:outline-2 group-hover:outline-blue transition-[outline-color] duration-200">
{/* Image */}
<div className="w-full md:w-1/3 aspect-[16/9] overflow-hidden rounded-lg bg-foreground/5 flex-shrink-0">
{project.data.image ? (

View File

@@ -1,71 +1,11 @@
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import {
FileDown,
Github,
Linkedin,
Globe
} from "lucide-react";
// --- Typewriter hook ---
function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
// --- Visibility hook ---
function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
import { useTypewriter, useScrollVisible } from "@/components/typed-text";
// --- Section fade-in ---
@@ -75,11 +15,12 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
return (
<div
ref={ref}
className="transition-all duration-700 ease-out"
className="transition-[opacity,transform] duration-700 ease-out"
style={{
transitionDelay: `${delay}ms`,
willChange: "transform, opacity",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transform: visible ? "translate3d(0,0,0)" : "translate3d(0,24px,0)",
}}
>
{children}
@@ -91,7 +32,7 @@ function Section({ children, delay = 0 }: { children: React.ReactNode; delay?: n
function TypedSection({
heading,
headingClass = "text-3xl font-bold text-yellow-bright",
headingClass = "text-2xl md:text-3xl font-bold text-yellow-bright",
children,
}: {
heading: string;
@@ -108,10 +49,11 @@ function TypedSection({
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="transition-all duration-500 ease-out"
className="transition-[opacity,transform] duration-500 ease-out"
style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}}
>
{children}
@@ -128,11 +70,12 @@ function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean })
{skills.map((skill, i) => (
<span
key={i}
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-all duration-500 ease-out"
className="border border-foreground/20 px-4 py-2 rounded-lg hover:border-foreground/40 transition-[opacity,transform] duration-500 ease-out"
style={{
transitionDelay: `${i * 60}ms`,
willChange: "transform, opacity",
opacity: trigger ? 1 : 0,
transform: trigger ? "translateY(0) scale(1)" : "translateY(12px) scale(0.95)",
transform: trigger ? "translate3d(0,0,0) scale(1)" : "translate3d(0,12px,0) scale(0.95)",
}}
>
{skill}
@@ -212,19 +155,19 @@ const Resume = () => {
};
return (
<div className="max-w-4xl mx-auto px-6 md:px-8 pt-24 pb-16">
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-16 md:pt-24 pb-16">
<div className="space-y-16">
{/* Header */}
<header className="text-center space-y-6">
<Section>
<h1 className="text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
<h1 className="text-3xl md:text-6xl font-bold text-aqua-bright">{resumeData.name}</h1>
</Section>
<Section delay={150}>
<h2 className="text-3xl text-foreground/80">{resumeData.title}</h2>
<h2 className="text-xl md:text-3xl text-foreground/80">{resumeData.title}</h2>
</Section>
<Section delay={300}>
<div className="flex flex-col md:flex-row justify-center gap-4 md:gap-6 text-foreground/60 text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200">
<div className="flex flex-col md:flex-row justify-center gap-2 md:gap-6 text-foreground/60 text-sm md:text-lg">
<a href={`mailto:${resumeData.contact.email}`} className="hover:text-foreground transition-colors duration-200 break-all md:break-normal">
{resumeData.contact.email}
</a>
<span className="hidden md:inline"></span>
@@ -236,7 +179,7 @@ const Resume = () => {
</div>
</Section>
<Section delay={450}>
<div className="flex justify-center items-center gap-6 text-lg">
<div className="flex justify-center items-center gap-4 md:gap-6 text-base md:text-lg">
<a href={`https://${resumeData.contact.github}`}
target="_blank"
className="text-blue-bright hover:text-blue transition-colors duration-200 flex items-center gap-2"
@@ -264,7 +207,7 @@ const Resume = () => {
{/* Summary */}
<TypedSection heading="Professional Summary">
<p className="text-xl leading-relaxed">{resumeData.summary}</p>
<p className="text-base md:text-xl leading-relaxed">{resumeData.summary}</p>
</TypedSection>
{/* Experience */}
@@ -275,14 +218,14 @@ const Resume = () => {
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-lg">{exp.company} - {exp.location}</div>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">{exp.title}</h4>
<div className="text-foreground/60 text-base md:text-lg">{exp.company} - {exp.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{exp.period}</div>
<div className="text-foreground/60 text-sm md:text-lg font-medium">{exp.period}</div>
</div>
<ul className="list-disc pl-6 space-y-3">
{exp.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
<li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
@@ -300,7 +243,7 @@ const Resume = () => {
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<div className="flex items-center gap-3">
<h4 className="text-2xl font-semibold text-green-bright">{project.title}</h4>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">{project.title}</h4>
{project.url && (
<a
href={project.url}
@@ -312,27 +255,27 @@ const Resume = () => {
</a>
)}
</div>
<div className="text-foreground/60 text-lg">{project.type}</div>
<div className="text-foreground/60 text-base md:text-lg">{project.type}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">Since {project.startDate}</div>
<div className="text-foreground/60 text-sm md:text-lg font-medium">Since {project.startDate}</div>
</div>
<div className="space-y-4">
{project.responsibilities && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Responsibilities</h5>
<ul className="list-disc pl-6 space-y-3">
{project.responsibilities.map((r, i) => (
<li key={i} className="text-lg leading-relaxed">{r}</li>
<li key={i} className="text-base md:text-lg leading-relaxed">{r}</li>
))}
</ul>
</div>
)}
{project.achievements && (
<div>
<h5 className="text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<h5 className="text-base md:text-lg text-aqua font-semibold mb-2">Key Achievements</h5>
<ul className="list-disc pl-6 space-y-3">
{project.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
<li key={i} className="text-base md:text-lg leading-relaxed">{a}</li>
))}
</ul>
</div>
@@ -344,32 +287,6 @@ const Resume = () => {
</div>
</TypedSection>
{/* Education */}
<TypedSection heading="Education">
<div className="space-y-8">
{resumeData.education.map((edu, index) => (
<Section key={index}>
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-2">
<div>
<h4 className="text-2xl font-semibold text-green-bright">{edu.degree}</h4>
<div className="text-foreground/60 text-lg">{edu.school} - {edu.location}</div>
</div>
<div className="text-foreground/60 text-lg font-medium">{edu.period}</div>
</div>
{edu.achievements.length > 0 && (
<ul className="list-disc pl-6 space-y-3">
{edu.achievements.map((a, i) => (
<li key={i} className="text-lg leading-relaxed">{a}</li>
))}
</ul>
)}
</div>
</Section>
))}
</div>
</TypedSection>
{/* Skills */}
<SkillsSection />
</div>
@@ -385,24 +302,25 @@ function SkillsSection() {
return (
<div ref={ref} className="space-y-8">
<h3 className="text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
<h3 className="text-2xl md:text-3xl font-bold text-yellow-bright" style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{visible && !done && <span className="animate-pulse text-foreground/40">|</span>}
</h3>
<div
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-all duration-500 ease-out"
className="grid grid-cols-1 md:grid-cols-2 gap-8 transition-[opacity,transform] duration-500 ease-out"
style={{
willChange: "transform, opacity",
opacity: done ? 1 : 0,
transform: done ? "translateY(0)" : "translateY(12px)",
transform: done ? "translate3d(0,0,0)" : "translate3d(0,12px,0)",
}}
>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Technical Skills</h4>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">Technical Skills</h4>
<SkillTags skills={resumeData.skills.technical} trigger={done} />
</div>
<div className="space-y-4">
<h4 className="text-2xl font-semibold text-green-bright">Soft Skills</h4>
<h4 className="text-xl md:text-2xl font-semibold text-green-bright">Soft Skills</h4>
<SkillTags skills={resumeData.skills.soft} trigger={done} />
</div>
</div>

View File

@@ -0,0 +1,96 @@
import { useEffect, useRef } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
const HEADING_TAGS = new Set(["H1", "H2", "H3", "H4", "H5", "H6"]);
function typeInHeading(el: HTMLElement): Promise<void> {
return new Promise((resolve) => {
const text = el.textContent || "";
const textLength = text.length;
if (textLength === 0) { resolve(); return; }
// Preserve height
const rect = el.getBoundingClientRect();
el.style.minHeight = `${rect.height}px`;
// Speed scales with length
const speed = Math.max(8, Math.min(25, 600 / textLength));
// Store original HTML, clear visible text but keep element structure
const originalHTML = el.innerHTML;
el.textContent = "";
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
let i = 0;
const step = () => {
if (i >= text.length) {
el.innerHTML = originalHTML;
el.style.minHeight = "";
resolve();
return;
}
i++;
el.textContent = text.slice(0, i);
setTimeout(step, speed);
};
step();
});
}
export function StreamContent({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = ref.current;
if (!container || prefersReducedMotion()) {
if (container) container.classList.remove("stream-hidden");
return;
}
const prose = container.querySelector(".prose") || container;
const blocks = Array.from(prose.querySelectorAll(":scope > *")) as HTMLElement[];
if (blocks.length === 0) {
container.classList.remove("stream-hidden");
return;
}
container.classList.remove("stream-hidden");
blocks.forEach((el) => {
el.style.opacity = "0";
el.style.transform = "translate3d(0,16px,0)";
el.style.transition = "opacity 0.6s ease-out, transform 0.6s ease-out";
el.style.willChange = "transform, opacity";
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement;
observer.unobserve(el);
if (HEADING_TAGS.has(el.tagName)) {
typeInHeading(el);
} else {
el.style.opacity = "1";
el.style.transform = "translate3d(0,0,0)";
}
}
});
},
{ threshold: 0.05 }
);
blocks.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className="stream-hidden">
{children}
</div>
);
}

View File

@@ -74,7 +74,7 @@ export default function ThemeSwitcher() {
return (
<>
<div
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden md:block"
className="fixed bottom-4 right-4 z-[101] pointer-events-auto hidden lg:block"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={handleClick}

View File

@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "@/lib/reduced-motion";
export function useTypewriter(text: string, trigger: boolean, speed = 12) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
if (!trigger) return;
if (prefersReducedMotion()) {
setDisplayed(text);
setDone(true);
return;
}
let i = 0;
setDisplayed("");
setDone(false);
const interval = setInterval(() => {
i++;
setDisplayed(text.slice(0, i));
if (i >= text.length) {
setDone(true);
clearInterval(interval);
}
}, speed);
return () => clearInterval(interval);
}, [trigger, text, speed]);
return { displayed, done };
}
export function useScrollVisible(threshold = 0.1) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
requestAnimationFrame(() => setVisible(true));
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, visible };
}
interface TypedTextProps {
text: string;
as?: "h1" | "h2" | "h3" | "h4" | "span" | "p";
className?: string;
speed?: number;
cursor?: boolean;
}
export function TypedText({
text,
as: Tag = "span",
className = "",
speed = 12,
cursor = true,
}: TypedTextProps) {
const { ref, visible } = useScrollVisible();
const { displayed, done } = useTypewriter(text, visible, speed);
return (
<div ref={ref}>
<Tag className={className} style={{ minHeight: "1.2em" }}>
{visible ? displayed : "\u00A0"}
{cursor && visible && !done && (
<span className="animate-pulse text-foreground/40">|</span>
)}
</Tag>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -55,8 +56,13 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<Header client:load />
<!-- Mobile: full-screen background -->
<div class="lg:hidden">
<Background layout="index" client:only="react" transition:persist />
</div>
<main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1">
<div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 pb-20 lg:pb-8 flex-1 relative z-10">
<!-- Desktop: sidebar strips -->
<Background layout="content" position="right" client:only="react" transition:persist />
<div>
<slot />
@@ -70,6 +76,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

View File

@@ -9,6 +9,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -50,6 +51,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load transparent />
<script is:inline set:html={THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

View File

@@ -8,6 +8,7 @@ import Background from "@/components/background";
import ThemeSwitcher from "@/components/theme-switcher";
import AnimationSwitcher from "@/components/animation-switcher";
import VercelAnalytics from "@/components/analytics";
import MobileNav from "@/components/mobile-nav";
import { THEME_LOADER_SCRIPT, THEME_NAV_SCRIPT } from "@/lib/themes/loader";
import { ANIMATION_LOADER_SCRIPT, ANIMATION_NAV_SCRIPT } from "@/lib/animations/loader";
@@ -54,8 +55,13 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<script is:inline set:html={ANIMATION_LOADER_SCRIPT} />
</head>
<body class="bg-background text-foreground min-h-screen flex flex-col">
<!-- Mobile: full-screen background -->
<div class="lg:hidden">
<Background layout="index" client:only="react" transition:persist />
</div>
<main class="flex-1 flex flex-col">
<div class="max-w-5xl mx-auto pt-12 px-4 py-8 flex-1">
<div class="max-w-5xl mx-auto pt-2 lg:pt-12 px-4 py-4 lg:py-8 flex-1 relative z-10">
<!-- Desktop: sidebar strips -->
<Background layout="content" position="right" client:only="react" transition:persist />
<div>
<slot />
@@ -66,6 +72,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg";
<ThemeSwitcher client:only="react" transition:persist />
<AnimationSwitcher client:only="react" transition:persist />
<VercelAnalytics client:load />
<MobileNav client:load />
<script is:inline set:html={`window.scrollTo(0,0);` + THEME_NAV_SCRIPT} />
<script is:inline set:html={ANIMATION_NAV_SCRIPT} />
</body>

View File

@@ -0,0 +1,4 @@
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

View File

@@ -17,23 +17,23 @@ import OutsideCoding from "@/components/about/outside-coding";
<Intro client:load />
</section>
<section class="min-h-[60vh] flex items-center justify-center py-16">
<section class="min-h-[40vh] md:min-h-[60vh] flex items-center justify-center py-8 md:py-16">
<AllTimeStats client:load />
</section>
<section class="min-h-screen flex items-center justify-center py-16">
<section class="min-h-[60vh] md:min-h-screen flex items-center justify-center py-8 md:py-16">
<DetailedStats client:load />
</section>
<section class="min-h-[80vh] flex items-center justify-center py-16">
<section class="min-h-[50vh] md:min-h-[80vh] flex items-center justify-center py-8 md:py-16">
<Timeline client:load />
</section>
<section class="min-h-[80vh] flex items-center justify-center py-16">
<section class="min-h-[50vh] md:min-h-[80vh] flex items-center justify-center py-8 md:py-16">
<CurrentFocus client:load />
</section>
<section class="min-h-[50vh] flex items-center justify-center py-16">
<section class="min-h-[30vh] md:min-h-[50vh] flex items-center justify-center py-8 md:py-16">
<OutsideCoding client:load />
</section>
</div>

View File

@@ -0,0 +1,275 @@
import type { APIRoute } from "astro";
import { THEMES, DEFAULT_THEME_ID } from "@/lib/themes";
function rgbToHex(rgb: string): string {
return "#" + rgb.split(" ").map(n => parseInt(n).toString(16).padStart(2, "0")).join("");
}
function rgbToRgba(rgb: string, alpha: number): string {
return `rgba(${rgb.replaceAll(" ", ", ")}, ${alpha})`;
}
export const GET: APIRoute = ({ url }) => {
const themeId = url.searchParams.get("theme") || DEFAULT_THEME_ID;
const theme = THEMES[themeId];
if (!theme) {
return new Response("Unknown theme", { status: 404 });
}
const c = theme.colors;
const fg = rgbToHex(c.foreground);
const fgMuted = rgbToRgba(c.foreground, 0.6);
const fgSubtle = rgbToRgba(c.foreground, 0.4);
const blue = rgbToHex(c.blue);
const blueBright = rgbToHex(c.blueBright);
const green = rgbToHex(c.green);
const yellow = rgbToHex(c.yellow);
const red = rgbToHex(c.red);
const purple = rgbToHex(c.purple);
const orange = rgbToHex(c.orange);
const surface = rgbToHex(c.surface);
const surfaceAlpha = rgbToRgba(c.surface, 0.3);
const surfaceBorder = rgbToRgba(c.surface, 0.5);
const surfaceHover = rgbToRgba(c.surface, 0.6);
const bgTransparent = "rgba(0, 0, 0, 0.5)";
const bgSubtle = "rgba(0, 0, 0, 0.3)";
const css = `
main {
--color-prettylights-syntax-comment: ${fgSubtle};
--color-prettylights-syntax-constant: ${blueBright};
--color-prettylights-syntax-entity: ${purple};
--color-prettylights-syntax-storage-modifier-import: ${fg};
--color-prettylights-syntax-entity-tag: ${green};
--color-prettylights-syntax-keyword: ${red};
--color-prettylights-syntax-string: ${blueBright};
--color-prettylights-syntax-variable: ${orange};
--color-prettylights-syntax-brackethighlighter-unmatched: ${red};
--color-prettylights-syntax-invalid-illegal-text: ${fg};
--color-prettylights-syntax-invalid-illegal-bg: ${red};
--color-prettylights-syntax-carriage-return-text: ${fg};
--color-prettylights-syntax-carriage-return-bg: ${red};
--color-prettylights-syntax-string-regexp: ${green};
--color-prettylights-syntax-markup-list: ${yellow};
--color-prettylights-syntax-markup-heading: ${blueBright};
--color-prettylights-syntax-markup-italic: ${fg};
--color-prettylights-syntax-markup-bold: ${orange};
--color-prettylights-syntax-markup-deleted-text: ${red};
--color-prettylights-syntax-markup-deleted-bg: transparent;
--color-prettylights-syntax-markup-inserted-text: ${green};
--color-prettylights-syntax-markup-inserted-bg: transparent;
--color-prettylights-syntax-markup-changed-text: ${yellow};
--color-prettylights-syntax-markup-changed-bg: transparent;
--color-prettylights-syntax-markup-ignored-text: ${fg};
--color-prettylights-syntax-markup-ignored-bg: transparent;
--color-prettylights-syntax-meta-diff-range: ${purple};
--color-prettylights-syntax-brackethighlighter-angle: ${fgSubtle};
--color-prettylights-syntax-sublimelinter-gutter-mark: ${fgSubtle};
--color-prettylights-syntax-constant-other-reference-link: ${blueBright};
--color-btn-text: ${fg};
--color-btn-bg: ${bgTransparent};
--color-btn-border: ${surfaceBorder};
--color-btn-shadow: 0 0 transparent;
--color-btn-inset-shadow: 0 0 transparent;
--color-btn-hover-bg: ${surfaceAlpha};
--color-btn-hover-border: ${surfaceHover};
--color-btn-active-bg: ${bgSubtle};
--color-btn-active-border: ${surfaceHover};
--color-btn-selected-bg: ${bgTransparent};
--color-btn-primary-text: ${fg};
--color-btn-primary-bg: ${blue};
--color-btn-primary-border: transparent;
--color-btn-primary-shadow: 0 0 transparent;
--color-btn-primary-inset-shadow: 0 0 transparent;
--color-btn-primary-hover-bg: ${blueBright};
--color-btn-primary-hover-border: transparent;
--color-btn-primary-selected-bg: ${blue};
--color-btn-primary-selected-shadow: 0 0 transparent;
--color-btn-primary-disabled-text: ${fgMuted};
--color-btn-primary-disabled-bg: ${rgbToRgba(c.blue, 0.6)};
--color-btn-primary-disabled-border: transparent;
--color-action-list-item-default-hover-bg: ${surfaceAlpha};
--color-segmented-control-bg: ${surfaceAlpha};
--color-segmented-control-button-bg: ${bgTransparent};
--color-segmented-control-button-selected-border: ${surfaceBorder};
--color-fg-default: ${fg};
--color-fg-muted: ${fgMuted};
--color-fg-subtle: ${fgSubtle};
--color-canvas-default: transparent;
--color-canvas-overlay: ${bgTransparent};
--color-canvas-inset: ${bgSubtle};
--color-canvas-subtle: ${bgSubtle};
--color-border-default: ${surfaceBorder};
--color-border-muted: ${surfaceAlpha};
--color-neutral-muted: ${rgbToRgba(c.surface, 0.25)};
--color-accent-fg: ${blueBright};
--color-accent-emphasis: ${blue};
--color-accent-muted: ${rgbToRgba(c.blue, 0.4)};
--color-accent-subtle: ${rgbToRgba(c.blue, 0.1)};
--color-success-fg: ${green};
--color-attention-fg: ${yellow};
--color-attention-muted: ${rgbToRgba(c.yellow, 0.4)};
--color-attention-subtle: ${rgbToRgba(c.yellow, 0.15)};
--color-danger-fg: ${red};
--color-danger-muted: ${rgbToRgba(c.red, 0.4)};
--color-danger-subtle: ${rgbToRgba(c.red, 0.1)};
--color-primer-shadow-inset: 0 0 transparent;
--color-scale-gray-7: ${surface};
--color-scale-blue-8: ${blue};
--color-social-reaction-bg-hover: ${surfaceAlpha};
--color-social-reaction-bg-reacted-hover: ${rgbToRgba(c.blue, 0.3)};
}
main .pagination-loader-container {
background-image: url(https://github.com/images/modules/pulls/progressive-disclosure-line-dark.svg);
}
.gsc-reactions-count { display: none; }
.gsc-timeline { flex-direction: column-reverse; }
.gsc-header {
padding-bottom: 1rem;
font-family: "Comic Code", monospace;
border-bottom: none;
}
.gsc-comments > .gsc-header { order: 1; }
.gsc-comments > .gsc-comment-box {
margin-bottom: 1rem;
order: 2;
font-family: "Comic Code", monospace;
background-color: ${bgTransparent};
border-radius: 0.5rem;
border: 1px solid ${surfaceBorder};
}
.gsc-comments > .gsc-timeline { order: 3; }
.gsc-homepage-bg { background-color: transparent; }
main .gsc-loading-image {
background-image: url(https://github.githubassets.com/images/mona-loading-dimmed.gif);
}
.gsc-comment {
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
margin-bottom: 1rem;
background-color: ${bgTransparent};
transition: border-color 0.2s ease;
}
.gsc-comment-header {
background-color: ${bgSubtle};
padding: 0.75rem;
border-bottom: 1px solid ${rgbToRgba(c.surface, 0.2)};
font-family: "Comic Code", monospace;
}
.gsc-comment-content {
padding: 1rem;
font-family: "Comic Code", monospace;
}
.gsc-comment-author { color: var(--color-fg-default); font-weight: 600; }
.gsc-comment-author-avatar img { border-radius: 50%; }
.gsc-comment-reactions { border-top: none; padding-top: 0.5rem; }
.gsc-reply-box {
background-color: ${bgTransparent};
border-radius: 0.5rem;
margin-top: 0.5rem;
margin-left: 1rem;
font-family: "Comic Code", monospace;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment-box-textarea {
background-color: ${bgSubtle};
border: 1px solid ${surfaceBorder};
border-radius: 0.5rem;
color: var(--color-fg-default);
font-family: "Comic Code", monospace;
padding: 0.75rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gsc-comment-box-textarea:focus {
border-color: ${blueBright};
box-shadow: 0 0 0 2px ${rgbToRgba(c.blue, 0.2)};
outline: none;
}
.gsc-comment-box-buttons button {
font-family: "Comic Code", monospace;
border-radius: 0.5rem;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.gsc-comment pre {
background-color: ${bgSubtle};
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
border: 1px solid ${rgbToRgba(c.surface, 0.25)};
}
.gsc-comment code {
font-family: "Comic Code", monospace;
background-color: ${bgSubtle};
color: ${purple};
padding: 0.2em 0.4em;
border-radius: 0.25rem;
}
.gsc-comment:hover { border-color: ${surfaceHover}; }
.gsc-social-reaction-summary-item:hover { background-color: ${surfaceAlpha}; }
.gsc-timeline::-webkit-scrollbar { width: 8px; height: 8px; }
.gsc-timeline::-webkit-scrollbar-track { background: transparent; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb { background: ${surfaceBorder}; border-radius: 4px; }
.gsc-timeline::-webkit-scrollbar-thumb:hover { background: ${surface}; }
.gsc-comment-footer, .gsc-comment-footer-separator,
.gsc-reactions-button, .gsc-social-reaction-summary-item:not(:hover) { border: none; }
.gsc-upvote svg { fill: ${blueBright}; }
.gsc-downvote svg { fill: ${red}; }
.gsc-comment-box, .gsc-comment, .gsc-comment-reactions, button, .gsc-reply-box {
transition: all 0.2s ease-in-out;
}
.gsc-main { border: none !important; }
.gsc-left-header { background-color: transparent !important; }
.gsc-right-header { background-color: transparent !important; }
.gsc-header-status { background-color: transparent !important; }
.gsc-comment-box, .gsc-comment-box-md-toolbar, .gsc-comment-box-buttons { border: none !important; }
.gsc-comment-box-md-toolbar-item { color: ${blueBright} !important; }
.gsc-comment-box-md-toolbar {
background-color: ${bgSubtle} !important;
padding: 0.5rem !important;
}
.gsc-comments .gsc-powered-by { display: none !important; }
.gsc-comments footer { display: none !important; }
.gsc-comments .gsc-powered-by a { visibility: hidden !important; }
`;
return new Response(css, {
headers: {
"Content-Type": "text/css",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
},
});
};

View File

@@ -5,6 +5,7 @@ import ContentLayout from "@/layouts/content.astro";
import { getArticleSchema } from "@/lib/structuredData";
import { blogWebsite } from "@/lib/structuredData";
import { Comments } from "@/components/blog/comments";
import { StreamContent } from "@/components/stream-content";
import { incrementViews, getViews } from "@/lib/views";
// This is a dynamic route in SSR mode
@@ -110,9 +111,11 @@ const jsonLd = {
</span>
))}
</div>
<StreamContent client:load>
<div class="prose prose-invert prose-lg max-w-none">
<Content />
</div>
</StreamContent>
</article>
<Comments client:idle />
</div>

View File

@@ -5,6 +5,7 @@ import { getCollection, render } from "astro:content";
import ContentLayout from "@/layouts/content.astro";
import { Comments } from "@/components/blog/comments";
import { StreamContent } from "@/components/stream-content";
export async function getStaticPaths() {
const projects = await getCollection("projects");
@@ -60,9 +61,11 @@ const { Content } = await render(project);
</div>
</header>
<StreamContent client:load>
<div class="prose prose-invert prose-lg max-w-none">
<Content />
</div>
</StreamContent>
</article>
<Comments client:idle />
</ContentLayout>

View File

@@ -22,6 +22,10 @@
@tailwind components;
@tailwind utilities;
.stream-hidden > .prose > * {
opacity: 0;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
display: none;
@@ -69,6 +73,14 @@
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Regular */
@font-face {
font-family: "ComicRegular";