mirror of
https://github.com/timmypidashev/web.git
synced 2026-04-14 02:53:51 +00:00
Mobile optimizations
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''}
|
||||
`}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
101
src/components/mobile-nav/index.tsx
Normal file
101
src/components/mobile-nav/index.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
src/components/mobile-nav/settings-sheet.tsx
Normal file
138
src/components/mobile-nav/settings-sheet.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
src/components/stream-content.tsx
Normal file
96
src/components/stream-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
95
src/components/typed-text.tsx
Normal file
95
src/components/typed-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
src/lib/reduced-motion.ts
Normal file
4
src/lib/reduced-motion.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
275
src/pages/api/giscus-theme.ts
Normal file
275
src/pages/api/giscus-theme.ts
Normal 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": "*",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user