Mobile optimizations

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