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",
+ }];