From 3f17cf9094784748d5b85b3c1bd3627454fa7810 Mon Sep 17 00:00:00 2001 From: "Hunter W." Date: Mon, 1 Jun 2026 23:04:00 -0400 Subject: [PATCH] added site as project. Improved mobile display --- app/layout.tsx | 2 +- components/ProjectCard.tsx | 40 ++++--- components/Projects.tsx | 224 ++++++++++++++++++++--------------- components/SimpleGallery.tsx | 38 +----- data/content.ts | 10 +- 5 files changed, 170 insertions(+), 144 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 6bc3fbd..b406fd5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,7 +19,7 @@ const sans = DM_Sans({ export const metadata: Metadata = { title: SITE.title, - description: SITE.description, + description: SITE.description }; export default function RootLayout({ diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index dd160eb..39850e1 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Project } from "@/data/content"; import SimpleGallery from "./SimpleGallery"; @@ -10,13 +10,14 @@ type Props = { visible: boolean; selected: boolean; setSelected: (selected: any) => void; - setExpandedHeight: (activeId: number) => void; + updateHeight: (activeId: number, contentRef: HTMLElement | null) => void; }; -export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) { +export default function ProjectCard({ project, visible, selected = false, setSelected, updateHeight}: Props) { const [hovered, setHovered] = useState(false); const [expandedImage, setExpandedImage] = useState(false); - const [currentExpandedHeight, setCurrentExpandedHeight] = useState(0); + const contentRef = useRef(null); + function projectTagsComponent(project: Project){ return (
{project.tags.map((tag) => ( @@ -40,11 +41,12 @@ export default function ProjectCard({ project, visible, selected = false, setSel function notSelectedComponent(){ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={() => setSelected(project.id)} style={{ - height: "100%", + // height: "100%", minWidth: "10em", textDecoration: "none", padding: 28, @@ -83,15 +85,28 @@ export default function ProjectCard({ project, visible, selected = false, setSel
); } + + useEffect(() => { + if (!contentRef.current) return; + + const observer = new ResizeObserver(() => { + if (contentRef.current) { + updateHeight(project.id, contentRef.current); + } + }); + + observer.observe(contentRef.current); + return () => observer.disconnect(); +}, [updateHeight, project.id]); + function selectedComponent(){ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} - onClick={() => setSelected(project.id)} style={{ - height: "100%", minWidth: "10em", overflow: "hidden", textDecoration: "none", @@ -124,11 +139,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel {project.description}

- {project.images && project.images.length > 0 && ( - { - setExpandedHeight(height); - setExpandedImage(height > 0); - }} /> + {contentRef.current && project.images && project.images.length > 0 && ( + )}
@@ -143,8 +156,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel useEffect(() => { if (!selected) { - if (expandedImage) { - setExpandedHeight(0); + if (expandedImage && contentRef.current) { setExpandedImage(false); } } diff --git a/components/Projects.tsx b/components/Projects.tsx index 4f1f207..f06756b 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -2,97 +2,129 @@ import { PROJECTS } from "@/data/content"; import { useStaggerReveal } from "@/hooks/useAnimations"; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useRef } from "react"; import ProjectCard from "@/components/ProjectCard"; -import { Layout, Responsive, useContainerWidth, horizontalCompactor, verticalCompactor, DefaultBreakpoints } from "react-grid-layout"; +import { + Layout, + Responsive, + useContainerWidth, + horizontalCompactor, + DefaultBreakpoints, +} from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; export default function Projects() { - - const rowHeight = 250; - const defaultExpandedHeightUnits = 2; + const rowHeight = 100; const visible = useStaggerReveal(PROJECTS.length, 100); const [selectedProject, setSelectedProject] = useState(1); - const { width, containerRef, mounted } = useContainerWidth(); - const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits); - const [layouts, setLayouts] = useState>(updateLayout(1)); + const [layouts, setLayouts] = useState>( + () => buildLayouts(1, {}) + ); + const heightCache = useRef>({}); - function setExpandedHeight(height: number) { - const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight); - setTopCellHeight(gridUnits); - setLayouts(updateLayout(selectedProject, gridUnits)); // pass both + function buildLayouts( + activeId: number, + heightOverrides: Record +): Record { + const isSelected = (id: number) => id === activeId; + + function h(project: (typeof PROJECTS)[number]) { + const override = heightOverrides[project.id.toString()]; + if (isSelected(project.id) && override != null) return override; + if(!isSelected(project.id)) return 1; + return project.images?.length ? 2 : 1; } + const selectedProject = PROJECTS.find((p) => isSelected(p.id)); + const selectedH = selectedProject ? h(selectedProject) : 1; + const nonSelected = PROJECTS.filter((p) => !isSelected(p.id)); + + function twoCol(project: (typeof PROJECTS)[number]) { + if (isSelected(project.id)) { + return { i: project.id.toString(), x: 0, y: 0, w: 2, h: h(project) }; + } + const ni = nonSelected.indexOf(project); + return { + i: project.id.toString(), + x: ni % 2, + y: Math.floor(ni / 2) + selectedH, + w: 1, + h: 3, + }; + } + + function singleCol(project: (typeof PROJECTS)[number], colSpan: number) { + if (isSelected(project.id)) { + return { i: project.id.toString(), x: 0, y: 0, w: colSpan, h: h(project) }; + } + const ni = nonSelected.indexOf(project); + return { + i: project.id.toString(), + x: 0, + y: ni + selectedH, + w: colSpan, + h: project.images?.length ? 4 : 2, + }; + } + + return { + lg: PROJECTS.map((p) => twoCol(p)), + md: PROJECTS.map((p) => twoCol(p)), + sm: PROJECTS.map((p) => twoCol(p)), + xs: PROJECTS.map((p) => singleCol(p, 4)), + xxs: PROJECTS.map((p) => singleCol(p, 4)), + }; +} + + const selectedRef = useRef(selectedProject); + selectedRef.current = selectedProject; + + const pendingUpdate = useRef | null>(null); + + const updateHeight = useCallback( + (sourceId: number, contentRef: HTMLElement | null) => { + if (sourceId !== selectedRef.current) return; + + if (pendingUpdate.current) clearTimeout(pendingUpdate.current); + pendingUpdate.current = setTimeout(() => { + const newH = contentRef + ? Math.ceil(contentRef.getBoundingClientRect().height / rowHeight) + : 2; + + const key = sourceId.toString(); + if (heightCache.current[key] === newH) return; + heightCache.current[key] = newH; + setLayouts((prev) => { + const next = { ...prev }; + for (const bp in next) { + next[bp as DefaultBreakpoints] = next[bp as DefaultBreakpoints].map( + (layout) => + layout.i === key ? { ...layout, h: newH } : layout + ); + } + return next; + }); + }, 10); + }, + [rowHeight] +); + function setSelectedHandler(projectId: number) { const id = Number(projectId); setSelectedProject(id); - setLayouts(updateLayout(id, topCellHeight)); // pass both + heightCache.current = {}; + setLayouts(buildLayouts(id, {})); } - - function updateLayout(activeId: number, cellHeight?: number) : Record { - const currentSelected = activeId ?? selectedProject; - const currentCellHeight = cellHeight ?? topCellHeight; - - let isSelected = (projectId: number) => projectId === currentSelected; - return { - "sm": PROJECTS.map((project, i) => { - return { - i: project.id.toString(), - x: isSelected(project.id) ? 0 : (i % 2) + 1, - y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0), - w: isSelected(project.id) ? 2 : 1, - h: project.images && project.images.length > 0 ? currentCellHeight : 1 - }; - }), - "xs": PROJECTS.map((project, i) => { - return { - i: project.id.toString(), - x: 0, - y: i, - w: 2, - h: project.images && project.images.length > 0 ? currentCellHeight : 1 - }; - }), - "xxs": PROJECTS.map((project, i) => { - return { - i: project.id.toString(), - x: 0, - y: i, - w: 2, - h: project.images && project.images.length > 0 ? currentCellHeight : 1 - }; - }), - "md": PROJECTS.map((project, i) => { - return { - i: project.id.toString(), - x: isSelected(project.id) ? 0 : (i % 2) + 1, - y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0), - w: isSelected(project.id) ? 2 : 1, - h: project.images && project.images.length > 0 ? currentCellHeight : 1 - }; - }), - "lg": PROJECTS.map((project, i) => { - return { - i: project.id.toString(), - x: isSelected(project.id) ? 0 : (i % 2) + 1, - y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0), - w: isSelected(project.id) ? 2 : 1, - h: project.images && project.images.length > 0 ? currentCellHeight : 1 - }; - }), - }; - }; - return ( -
+
{"Things I've built"} - -
- {mounted && ( - {PROJECTS.map((project, i) => ( -
- -
- - ))} -
)} - -
+
+ {mounted && ( + + {PROJECTS.map((project, i) => ( +
+ +
+ ))} +
+ )} +
); -} +} \ No newline at end of file diff --git a/components/SimpleGallery.tsx b/components/SimpleGallery.tsx index dc962bb..a1ac78b 100644 --- a/components/SimpleGallery.tsx +++ b/components/SimpleGallery.tsx @@ -5,8 +5,7 @@ import { createPortal } from "react-dom"; type Props = { images: string[]; videos?: string[]; - title: string; - expansionHandler: (height: number) => void; + title: string; }; // 0 = inline default, 1 = inline enlarged, 2 = fullscreen overlay @@ -15,8 +14,7 @@ type EnlargeLevel = 0 | 1 | 2; export default function SimpleGallery({ images, videos = [], - title, - expansionHandler + title }: Props) { const [active, setActive] = useState(0); @@ -31,28 +29,10 @@ export default function SimpleGallery({ if (totalMedia === 0) return null; - function scheduleHeightUpdate(enlarged: boolean) { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - if (!enlarged) { - expansionHandler(0); - return; - } - const el = - document.getElementById("gallery-main-image") ?? - document.getElementById("gallery-main-video"); - if (el){ - let h = el.getBoundingClientRect().height; - let adjAmount = h + 150; - expansionHandler(adjAmount); - } - }, 200); - } - function handleEnlarge() { if (enlargeLevel === 0) { setEnlargeLevel(1); - scheduleHeightUpdate(true); + } else if (enlargeLevel === 1) { setEnlargeLevel(2); } @@ -62,8 +42,7 @@ export default function SimpleGallery({ if (enlargeLevel === 2) { setEnlargeLevel(1); } else if (enlargeLevel === 1) { - setEnlargeLevel(0); - scheduleHeightUpdate(false); + setEnlargeLevel(0); } } @@ -72,12 +51,7 @@ export default function SimpleGallery({ } function handleThumbnailClick(index: number) { - setActive(index); - if (enlargeLevel === 1) { - scheduleHeightUpdate(true); - } else if (enlargeLevel === 2) { - // stay fullscreen with new media - } + setActive(index); } function expandButton() { @@ -398,7 +372,7 @@ export default function SimpleGallery({ return ( <> -
+
{inlineMedia()}
diff --git a/data/content.ts b/data/content.ts index 1cfaa2c..9330ea8 100644 --- a/data/content.ts +++ b/data/content.ts @@ -83,4 +83,12 @@ export const PROJECTS: Project[] = [{ tags: ["C#", ".NET", "WPF", "Netcode", "Security"], link: "https://github.com/FerrenF/CleanSpace", year: "2025", -}]; +}, { + id: 3, + slug: "personal-portfolio", + title: "Personal Portfolio Website", + description: "This very website! Built with Next.JS, React, and TypeScript. Featuring a custom CMS and a lot of custom-built components and hooks.", + tags: ["Next.JS", "React", "TypeScript", "Vercel"], + link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev", + year: "2026", + }];