diff --git a/astro.config.mjs b/astro.config.mjs index fddc72d..387da65 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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", diff --git a/src/components/about/current-focus.tsx b/src/components/about/current-focus.tsx index cf8e6ef..df751f1 100644 --- a/src/components/about/current-focus.tsx +++ b/src/components/about/current-focus.tsx @@ -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(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 ( -
- {children} -
- ); -} +import { AnimateIn } from "@/components/animate-in"; export default function CurrentFocus() { const recentProjects = [ @@ -98,7 +46,7 @@ export default function CurrentFocus() {

{project.title} diff --git a/src/components/about/intro.tsx b/src/components/about/intro.tsx index 9f373dc..432581d 100644 --- a/src/components/about/intro.tsx +++ b/src/components/about/intro.tsx @@ -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 (
-
+
Timothy Pidashev
-

+

Timothy Pidashev

-
-

+

+

Software Systems Engineer

-

+

Open Source Enthusiast

-

+

Coffee Connoisseur

diff --git a/src/components/about/outside-coding.tsx b/src/components/about/outside-coding.tsx index f341964..bc76da4 100644 --- a/src/components/about/outside-coding.tsx +++ b/src/components/about/outside-coding.tsx @@ -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(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 ( -
- {children} -
- ); -} +import { AnimateIn } from "@/components/animate-in"; const interests = [ { @@ -86,12 +34,12 @@ export default function OutsideCoding() {

-
+
{interests.map((interest, i) => (
{interest.icon}

{interest.title}

diff --git a/src/components/about/stats-activity.tsx b/src/components/about/stats-activity.tsx index 165a3a1..b2bead6 100644 --- a/src/components/about/stats-activity.tsx +++ b/src/components/about/stats-activity.tsx @@ -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(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 ( -
+
Activity
@@ -69,12 +71,13 @@ export const ActivityGrid = ({ data }: ActivityGridProps) => {
setTapped(tapped === day.date ? null : day.date)} > -
+
{hours.toFixed(1)} hours on {day.date}
diff --git a/src/components/about/stats-alltime.tsx b/src/components/about/stats-alltime.tsx index f5e92be..d78c47a 100644 --- a/src/components/about/stats-alltime.tsx +++ b/src/components/about/stats-alltime.tsx @@ -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 (
-
+
I've spent
-
+
{formattedHours} - + hours
-
+
writing code & building apps
diff --git a/src/components/about/stats-detailed.tsx b/src/components/about/stats-detailed.tsx index 05c484b..484d0e5 100644 --- a/src/components/about/stats-detailed.tsx +++ b/src/components/about/stats-detailed.tsx @@ -131,7 +131,7 @@ const DetailedStats = () => { <> {/* Header */}

{ return (
{ {/* Languages */}
{
@@ -212,7 +214,7 @@ const DetailedStats = () => { {/* Activity Grid */} {activity && (
@@ -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() {
diff --git a/src/components/animate-in.tsx b/src/components/animate-in.tsx index 3e9f01f..1c703fb 100644 --- a/src/components/animate-in.tsx +++ b/src/components/animate-in.tsx @@ -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(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 (
{children} diff --git a/src/components/animation-switcher/index.tsx b/src/components/animation-switcher/index.tsx index 3d14d25..935883b 100644 --- a/src/components/animation-switcher/index.tsx +++ b/src/components/animation-switcher/index.tsx @@ -41,7 +41,7 @@ export default function AnimationSwitcher() { return (
setHovering(true)} onMouseLeave={() => setHovering(false)} onClick={handleClick} diff --git a/src/components/blog/comments.tsx b/src/components/blog/comments.tsx index 433b691..558cd9a 100644 --- a/src/components/blog/comments.tsx +++ b/src/components/blog/comments.tsx @@ -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("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 ( -
- {mounted ? ( +
+ {mounted && themeUrl ? ( { return ( -
- -

- Latest Thoughts
- & Writings -

-
+
+
+ +
{ href={`/blog/${post.id}`} className="block" > -
+
{/* Image container with fixed aspect ratio */}
{ const TaggedPosts = ({ tag, posts }: TaggedPostsProps) => { return (
-
- -

- #{tag} -

-
+
+
+ +
{
  • -
    +
    -
    +
    {footerLinks}
    diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 7851da9..ca1c6d6 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -92,7 +92,7 @@ export default function Header({ transparent = false }: { transparent?: boolean `} >
  • {children} @@ -128,11 +70,12 @@ function SkillTags({ skills, trigger }: { skills: string[]; trigger: boolean }) {skills.map((skill, i) => ( {skill} @@ -212,19 +155,19 @@ const Resume = () => { }; return ( -
    +
    {/* Header */}
    -

    {resumeData.name}

    +

    {resumeData.name}

    -

    {resumeData.title}

    +

    {resumeData.title}

    -
    - +
    + {resumeData.contact.email} @@ -236,7 +179,7 @@ const Resume = () => {
    -
    +
    { {/* Summary */} -

    {resumeData.summary}

    +

    {resumeData.summary}

    {/* Experience */} @@ -275,14 +218,14 @@ const Resume = () => {
    -

    {exp.title}

    -
    {exp.company} - {exp.location}
    +

    {exp.title}

    +
    {exp.company} - {exp.location}
    -
    {exp.period}
    +
    {exp.period}
      {exp.achievements.map((a, i) => ( -
    • {a}
    • +
    • {a}
    • ))}
    @@ -300,7 +243,7 @@ const Resume = () => {
    -
    Since {project.startDate}
    +
    Since {project.startDate}
    {project.responsibilities && (
    -
    Responsibilities
    +
    Responsibilities
      {project.responsibilities.map((r, i) => ( -
    • {r}
    • +
    • {r}
    • ))}
    )} {project.achievements && (
    -
    Key Achievements
    +
    Key Achievements
      {project.achievements.map((a, i) => ( -
    • {a}
    • +
    • {a}
    • ))}
    @@ -344,32 +287,6 @@ const Resume = () => {
    - {/* Education */} - -
    - {resumeData.education.map((edu, index) => ( -
    -
    -
    -
    -

    {edu.degree}

    -
    {edu.school} - {edu.location}
    -
    -
    {edu.period}
    -
    - {edu.achievements.length > 0 && ( -
      - {edu.achievements.map((a, i) => ( -
    • {a}
    • - ))} -
    - )} -
    -
    - ))} -
    -
    - {/* Skills */}
    @@ -385,24 +302,25 @@ function SkillsSection() { return (
    -

    +

    {visible ? displayed : "\u00A0"} {visible && !done && |}

    -

    Technical Skills

    +

    Technical Skills

    -

    Soft Skills

    +

    Soft Skills

    diff --git a/src/components/stream-content.tsx b/src/components/stream-content.tsx new file mode 100644 index 0000000..a34b1e8 --- /dev/null +++ b/src/components/stream-content.tsx @@ -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 { + 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(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 ( +
    + {children} +
    + ); +} diff --git a/src/components/theme-switcher/index.tsx b/src/components/theme-switcher/index.tsx index 31b17fe..6d7c55c 100644 --- a/src/components/theme-switcher/index.tsx +++ b/src/components/theme-switcher/index.tsx @@ -74,7 +74,7 @@ export default function ThemeSwitcher() { return ( <>
    setHovering(true)} onMouseLeave={() => setHovering(false)} onClick={handleClick} diff --git a/src/components/typed-text.tsx b/src/components/typed-text.tsx new file mode 100644 index 0000000..c184a04 --- /dev/null +++ b/src/components/typed-text.tsx @@ -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(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 ( +
    + + {visible ? displayed : "\u00A0"} + {cursor && visible && !done && ( + | + )} + +
    + ); +} diff --git a/src/layouts/content.astro b/src/layouts/content.astro index 9ab81e7..cff340e 100644 --- a/src/layouts/content.astro +++ b/src/layouts/content.astro @@ -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";
    + +
    + +
    -
    +
    +
    @@ -70,6 +76,7 @@ const ogImage = "https://timmypidashev.dev/og-image.jpg"; +