diff --git a/src/App.tsx b/src/App.tsx index 7e86400b..abf8e6c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,10 +51,23 @@ function Home() { const isTabsEnabled = import.meta.env.VITE_TABS === "1"; const navigate = useNavigate(); const [asciiArt, setAsciiArt] = useState(""); - const [activeTab, setActiveTab] = useState("work"); - const [activeProject, setActiveProject] = useState(null); - const [activeLocation, setActiveLocation] = useState(null); - const [activePhoto, setActivePhoto] = useState(null); + const [activeHomeTab, setActiveHomeTab] = useState("work"); + const [activeProjectIndex, setActiveProjectIndex] = useState( + null, + ); + const [activeLocationIndex, setActiveLocationIndex] = useState( + null, + ); + const [expandedLocationIndex, setExpandedLocationIndex] = useState< + number | null + >(null); + const [previewPhotoPath, setPreviewPhotoPath] = useState(null); + const [travelFocusLevel, setTravelFocusLevel] = useState< + "location" | "photo" + >("location"); + const [activePhotoIndexByLocation, setActivePhotoIndexByLocation] = useState< + Record + >({}); const [asciiFile] = useState( () => asciiFiles[Math.floor(Math.random() * asciiFiles.length)], ); @@ -80,7 +93,7 @@ function Home() { }, [asciiFile]); useEffect(() => { - setActiveProject((prev) => { + setActiveProjectIndex((prev) => { if (visibleProjects.length === 0) return prev; if (prev === null) return null; return Math.min(prev, visibleProjects.length - 1); @@ -88,15 +101,31 @@ function Home() { }, [visibleProjects.length]); useEffect(() => { - if (visibleProjects.length === 0) return; + if (activeHomeTab !== "travel") { + setTravelFocusLevel("location"); + return; + } + setActiveLocationIndex((prev) => { + if (locations.length === 0) return null; + if (prev === null) return 0; + return clampIndex(prev, locations.length); + }); + + setExpandedLocationIndex((prev) => { + if (prev === null || locations.length === 0) return null; + return clampIndex(prev, locations.length); + }); + }, [activeHomeTab]); + + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (isTabsEnabled && event.key === "Tab") { event.preventDefault(); - setActiveTab((prev) => { + setActiveHomeTab((prev) => { const currentIndex = homeTabs.indexOf(prev); const safeIndex = currentIndex === -1 ? 0 : currentIndex; const nextIndex = (safeIndex + 1) % homeTabs.length; @@ -108,34 +137,202 @@ function Home() { if (isInteractiveTarget(event.target)) return; const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; - const isDesktop = window.matchMedia("(min-width: 768px)").matches; - const columns = isDesktop ? 2 : 1; - let delta = 0; - if (key === "ArrowLeft" || key === "h") delta = -1; - if (key === "ArrowRight" || key === "l") delta = 1; - if (key === "ArrowUp" || key === "k") delta = -columns; - if (key === "ArrowDown" || key === "j") delta = columns; + if (activeHomeTab === "work") { + if (visibleProjects.length === 0) return; - if (delta !== 0) { - event.preventDefault(); - setActiveProject((prev) => { - if (prev === null) return 0; - const next = Math.max( - 0, - Math.min(visibleProjects.length - 1, prev + delta), + const isDesktop = window.matchMedia("(min-width: 768px)").matches; + const columns = isDesktop ? 2 : 1; + + let delta = 0; + if (key === "ArrowLeft" || key === "h") delta = -1; + if (key === "ArrowRight" || key === "l") delta = 1; + if (key === "ArrowUp" || key === "k") delta = -columns; + if (key === "ArrowDown" || key === "j") delta = columns; + + if (delta !== 0) { + event.preventDefault(); + setActiveProjectIndex((prev) => { + if (prev === null) return 0; + return clampIndex(prev + delta, visibleProjects.length); + }); + return; + } + + if (key === "Enter") { + if (activeProjectIndex === null) return; + event.preventDefault(); + const target = visibleProjects[activeProjectIndex]; + if (!target) return; + navigate(`/projects/${target.metadata.slug}`); + } + + return; + } + + if (locations.length === 0) return; + + const expandedLocation = + expandedLocationIndex === null + ? null + : locations[expandedLocationIndex]; + const expandedLocationPhotoList = expandedLocation + ? (locationPhotos[expandedLocation.id] ?? []) + : []; + const isAtPhotoLevel = + travelFocusLevel === "photo" && + expandedLocationIndex !== null && + expandedLocationPhotoList.length > 0; + + const locationDelta = getLinearDelta(key); + if (isAtPhotoLevel) { + if (expandedLocationIndex === null || !expandedLocation) return; + + const locationId = expandedLocation.id; + const currentPhotoIndex = activePhotoIndexByLocation[locationId] ?? 0; + + if ((key === "ArrowUp" || key === "k") && currentPhotoIndex === 0) { + event.preventDefault(); + setTravelFocusLevel("location"); + return; + } + + if ( + (key === "ArrowDown" || key === "j") && + currentPhotoIndex === expandedLocationPhotoList.length - 1 + ) { + event.preventDefault(); + setTravelFocusLevel("location"); + setActiveLocationIndex( + clampIndex(expandedLocationIndex + 1, locations.length), ); - return next; + return; + } + + if (locationDelta !== 0) { + event.preventDefault(); + const nextPhotoIndex = clampIndex( + currentPhotoIndex + locationDelta, + expandedLocationPhotoList.length, + ); + + setActivePhotoIndexByLocation((prev) => ({ + ...prev, + [locationId]: nextPhotoIndex, + })); + + const nextPhotoName = expandedLocationPhotoList[nextPhotoIndex]; + if (nextPhotoName) { + setPreviewPhotoPath( + getTravelPhotoPath(expandedLocation, nextPhotoName), + ); + } + return; + } + + return; + } + + if (locationDelta !== 0) { + if ( + (key === "ArrowUp" || key === "k") && + activeLocationIndex !== null + ) { + const nextLocationIndex = clampIndex( + activeLocationIndex - 1, + locations.length, + ); + const nextLocation = locations[nextLocationIndex]; + const nextLocationPhotos = nextLocation + ? (locationPhotos[nextLocation.id] ?? []) + : []; + const lastPhotoIndex = nextLocationPhotos.length - 1; + const lastPhotoName = nextLocationPhotos[lastPhotoIndex]; + + if ( + expandedLocationIndex === nextLocationIndex && + nextLocation && + lastPhotoName + ) { + event.preventDefault(); + setActiveLocationIndex(nextLocationIndex); + setActivePhotoIndexByLocation((prev) => ({ + ...prev, + [nextLocation.id]: lastPhotoIndex, + })); + setPreviewPhotoPath( + getTravelPhotoPath(nextLocation, lastPhotoName), + ); + setTravelFocusLevel("photo"); + return; + } + } + + if ( + activeLocationIndex !== null && + expandedLocationIndex === activeLocationIndex && + (key === "ArrowDown" || key === "j") + ) { + const location = locations[activeLocationIndex]; + const photoList = location ? (locationPhotos[location.id] ?? []) : []; + const firstPhotoName = photoList[0]; + + if (location && firstPhotoName) { + event.preventDefault(); + setActivePhotoIndexByLocation((prev) => ({ + ...prev, + [location.id]: 0, + })); + setPreviewPhotoPath(getTravelPhotoPath(location, firstPhotoName)); + setTravelFocusLevel("photo"); + return; + } + } + + event.preventDefault(); + setActiveLocationIndex((prev) => { + if (prev === null) return 0; + return clampIndex(prev + locationDelta, locations.length); }); return; } if (key === "Enter") { - if (activeProject === null) return; + if (activeLocationIndex === null) return; event.preventDefault(); - const target = visibleProjects[activeProject]; - if (!target) return; - navigate(`/projects/${target.metadata.slug}`); + + if (expandedLocationIndex === activeLocationIndex) { + setExpandedLocationIndex(null); + setPreviewPhotoPath(null); + setTravelFocusLevel("location"); + return; + } + + const location = locations[activeLocationIndex]; + if (!location) return; + + const photoList = locationPhotos[location.id] ?? []; + setExpandedLocationIndex(activeLocationIndex); + + if (photoList.length === 0) { + setPreviewPhotoPath(null); + setTravelFocusLevel("location"); + return; + } + + const nextPhotoIndex = clampIndex( + activePhotoIndexByLocation[location.id] ?? 0, + photoList.length, + ); + const nextPhotoName = photoList[nextPhotoIndex]; + if (!nextPhotoName) return; + + setActivePhotoIndexByLocation((prev) => ({ + ...prev, + [location.id]: nextPhotoIndex, + })); + setPreviewPhotoPath(getTravelPhotoPath(location, nextPhotoName)); + setTravelFocusLevel("photo"); } }; @@ -143,7 +340,16 @@ function Home() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [activeProject, navigate, visibleProjects]); + }, [ + activeHomeTab, + activeLocationIndex, + activePhotoIndexByLocation, + activeProjectIndex, + expandedLocationIndex, + navigate, + travelFocusLevel, + visibleProjects, + ]); return (
@@ -188,8 +394,8 @@ function Home() {
{isTabsEnabled ? ( setActiveTab(value as HomeTab)} + value={activeHomeTab} + onValueChange={(value) => setActiveHomeTab(value as HomeTab)} className="w-full max-w-5xl gap-0" > ))} @@ -224,23 +432,62 @@ function Home() {
{locations.map((location, index) => ( - <> +
- {activeLocation === index && + {expandedLocationIndex === index && (locationPhotos[location.id].length === 0 ? (
@@ -259,30 +506,45 @@ function Home() { ) : (
- {locationPhotos[location.id].map((photo) => ( - - ))} + {locationPhotos[location.id].map( + (photo, photoIndex) => ( + + ), + )}
- {activePhoto ? ( + {previewPhotoPath ? ( {"active-photo"} ) : ( @@ -297,7 +559,7 @@ function Home() { )}
))} - +
))}
@@ -309,7 +571,9 @@ function Home() { key={project.metadata.slug} metadata={project.metadata} isDevMode={isDevMode} - isActive={activeProject !== null && index === activeProject} + isActive={ + activeProjectIndex !== null && index === activeProjectIndex + } /> ))}
@@ -329,6 +593,32 @@ function Home() { ); } +function clampIndex(index: number, itemCount: number): number { + return Math.max(0, Math.min(itemCount - 1, index)); +} + +function getLinearDelta(key: string): number { + if (key === "ArrowLeft" || key === "h" || key === "ArrowUp" || key === "k") { + return -1; + } + if ( + key === "ArrowRight" || + key === "l" || + key === "ArrowDown" || + key === "j" + ) { + return 1; + } + return 0; +} + +function getTravelPhotoPath( + location: { city: string; country: string; date: string }, + photo: string, +): string { + return `/travel/${location.city} ${location.country} ${location.date}/${photo}`; +} + function isInteractiveTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable) return true;