added site as project. Improved mobile display

This commit is contained in:
Hunter W.
2026-06-01 23:04:00 -04:00
parent d979ac7f9b
commit 3f17cf9094
5 changed files with 170 additions and 144 deletions

View File

@@ -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({

View File

@@ -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 (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{project.tags.map((tag) => (
@@ -40,11 +41,12 @@ export default function ProjectCard({ project, visible, selected = false, setSel
function notSelectedComponent(){
return (
<div
ref={contentRef}
onMouseEnter={() => 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
</div>
);
}
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 (
<div
ref={contentRef}
onMouseEnter={() => 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}
</p>
{project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title} expansionHandler={(height: number) => {
setExpandedHeight(height);
setExpandedImage(height > 0);
}} />
{contentRef.current && project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title}
/>
)}
</div>
@@ -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);
}
}

View File

@@ -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<number>(1);
const { width, containerRef, mounted } = useContainerWidth();
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(
() => buildLayouts(1, {})
);
const heightCache = useRef<Record<string, number>>({});
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<string, number>
): Record<DefaultBreakpoints, Layout> {
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<ReturnType<typeof setTimeout> | 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<DefaultBreakpoints, Layout> {
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 (
<div style={{ padding: "48px 0"}}>
<div style={{ padding: "48px 0", minWidth: 400 }}>
<div
style={{
marginBottom: 8,
@@ -117,33 +149,33 @@ export default function Projects() {
>
{"Things I've built"}
</h2>
<div ref={containerRef}>
{mounted && (<Responsive
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
width={width}
compactor={horizontalCompactor}
rowHeight={rowHeight}
dragConfig={{enabled:false}}
>
{PROJECTS.map((project, i) => (
<div key={project.id} style={{pointerEvents: "none"}}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
setExpandedHeight={setExpandedHeight}
/>
</div>
))}
</Responsive>)}
</div>
<div ref={containerRef}>
{mounted && (
<Responsive
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
width={width}
compactor={horizontalCompactor}
rowHeight={rowHeight}
dragConfig={{ enabled: false }}
>
{PROJECTS.map((project, i) => (
<div key={project.id} style={{ pointerEvents: "none" }}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
/>
</div>
))}
</Responsive>
)}
</div>
</div>
);
}
}

View File

@@ -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 (
<>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
{inlineMedia()}
</div>

View File

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