Changed the behavior of the 'more' bar into something less annoying, and re-did the entire project grid component chain.

This commit is contained in:
Hunter
2026-08-11 20:22:54 -04:00
parent 9840ecfcf2
commit 65bd3ec705
11 changed files with 709 additions and 580 deletions

View File

@@ -1,127 +1,183 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState, type CSSProperties } from "react";
import { MoreSection, PROFILE } from "@/data/content";
import { useStaggerReveal, useWiggle } from "@/hooks/useAnimations";
import "@/styles/more-section.css"
import { useFullBleed, usePrefersReducedMotion } from "@/hooks/useAnimations";
import "@/styles/more-section.css";
/**
* Publishes the threshold rule's distance from the top of the document as
* `--threshold-y`, which the root background gradient reads to tint everything
* below it. Keeping this on the background instead of in an element means it
* covers the rest of the page without adding to the scrollable area.
*/
function useThresholdTint(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const root = document.documentElement;
const publish = () => {
const rect = el.getBoundingClientRect();
// The rule sits at the element's vertical centre.
const y = rect.top + window.scrollY + rect.height / 2;
root.style.setProperty("--threshold-y", `${Math.round(y)}px`);
};
publish();
const observer = new ResizeObserver(publish);
observer.observe(root);
observer.observe(el);
return () => {
observer.disconnect();
root.style.removeProperty("--threshold-y");
};
}, [ref]);
}
/** Per-section stagger once the threshold opens. */
const STAGGER_MS = 90;
export default function ProfileMore() {
const [delayTimer, setDelayTimer] = useState<number | null>(null);
const [hovered, setHovered] = useState<boolean | null>(null);
const [morePosition, setMorePosition] = useState<number>(PROFILE.moreSectionsStart ?? 0);
const [open, setOpen] = useState(false);
const [armed, setArmed] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const { wiggling, progress, handlers } = useWiggle(500, () => {});
function handleSectionExpand(){
setMorePosition(morePosition + 1);
const { ref: bleedRef, style: bleedStyle } = useFullBleed<HTMLDivElement>();
useThresholdTint(bleedRef);
// Sections before `moreSectionsStart` are always visible; the rest are the
// payload that lives below the threshold.
const start = PROFILE.moreSectionsStart ?? 0;
const above = PROFILE.moreSections.slice(0, start);
const below = PROFILE.moreSections.slice(start);
// Crossing the line opens it. The reveal then latches -- crossing back up
// leaves it open, so a stray hover never yanks content out from under someone
// mid-read. Collapsing is deliberate only: a click on the label.
const cross = useCallback(() => {
setArmed(true);
setOpen(true);
}, []);
const toggle = useCallback(() => {
setArmed(true);
setOpen((prev) => !prev);
}, []);
if (below.length === 0) {
return (
<>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
</>
);
}
function handleEnter(){
if (delayTimer) return;
setHovered(true);
let timer = window.setTimeout(() => {
handleSectionExpand();
}, 500);
setDelayTimer(timer);
}
function handleLeave() {
if (delayTimer){
clearTimeout(delayTimer);
setDelayTimer(null);
}
setHovered(false);
}
return (
<div
className="more-section-container"
style={{
minHeight: "100px",
width: "100%",
overflow: "hidden"
}}
>
{PROFILE.moreSections.map((item: MoreSection, i)=>{
return (
<div
key={i}
style={{
// display: i < morePosition ? "block" : "none",
opacity: i < morePosition ? 1.0 : 0.0,
transition: "all 0.3s ease",
overflow: "hidden",
minHeight: i < morePosition ? "5em" : "0em",
height: i < morePosition ? "auto" : "0",
}}>
{item.title && (<h3
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}>
{item.title}
</h3>)}
<p style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}>{item.text}</p>
</div>
);
})}
<div {...handlers} className={`drop-handle ${wiggling ? "wiggle" : ""}`}
onMouseEnter={
() => handleEnter()
}
onMouseLeave={() => handleLeave()}
onTouchStart={
() => handleEnter()
}
onTouchEnd={()=>handleLeave()}
onClick={() => {
if (delayTimer){
clearTimeout(delayTimer);
}
handleSectionExpand();
}}
style={{
opacity: morePosition < PROFILE.moreSections.length ? 1.0 : 0.0,
backgroundColor: hovered ? "var(--surface)" : "var(--bg-raised",
width: "100%",
height: "2em",
display: "flex",
gap: 12,
margin: "0 0 40px 0",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
bottom: hovered ? "-10px" : "0px",
position: "relative",
}}>
<div style={{ width: "100%" }}>
{above.map((item, i) => (
<Section key={i} item={item} />
))}
<svg height={"30px"} width={"30px"} fill="var(--fg-secondary)" version="1.1" id="Capa_1" style={{scale:"0.5"}} >
<g>
<path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0
l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z"/>
</g>
</svg>
<span style={{
fontFamily: "var(--sans)",
fontSize: 12,
lineHeight: 0.5,
color: "var(--fg-secondary)",
alignSelf: "center",
textTransform: "uppercase"
}}>more?</span>
{/* The threshold. The rule sits at this box's vertical centre, and the
rule and glow bleed to the viewport edges via the measured offset.
Entering the band at all is what counts as crossing it. */}
<div
ref={bleedRef}
style={{ ...bleedStyle, position: "relative" }}
onMouseEnter={cross}
>
<button
type="button"
className="threshold"
data-armed={armed || open}
data-open={open}
aria-expanded={open}
aria-controls="profile-more-reveal"
onFocus={() => setArmed(true)}
onBlur={() => setArmed(open)}
onClick={toggle}
>
<span className="threshold-glow" aria-hidden="true" />
<span className="threshold-rule" aria-hidden="true" />
<span className="threshold-label">
<svg
className="threshold-chevron"
viewBox="0 0 31 31"
fill="currentColor"
aria-hidden="true"
>
<path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z" />
</svg>
{open ? "less" : "more?"}
</span>
</button>
</div>
{/* Everything below the line, revealed in one motion. */}
<div id="profile-more-reveal" className="more-reveal" data-open={open}>
<div className="more-reveal-inner">
<div style={{ paddingTop: 32 }}>
{below.map((item, i) => (
<Section
key={i}
item={item}
revealed
style={{
transitionDelay: reducedMotion ? "0ms" : `${120 + i * STAGGER_MS}ms`,
}}
/>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function Section({
item,
revealed = false,
style,
}: {
item: MoreSection;
revealed?: boolean;
style?: CSSProperties;
}) {
return (
<div className={revealed ? "more-section" : undefined} style={style}>
{item.title && (
<h3
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{item.title}
</h3>
)}
<p
style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}
>
{item.text}
</p>
</div>
);
}

View File

@@ -1,10 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import type { Project } from "@/data/content";
import SimpleGallery from "./SimpleGallery";
import Link from "next/link";
import { relative } from "path";
import "../styles/project-card.css";
type Props = {
@@ -12,14 +11,11 @@ type Props = {
visible: boolean;
selected: boolean;
setSelected: (selected: any) => void;
updateHeight: (projectId: number, heightPx: number) => void;
};
export default function ProjectCard({ project, visible, selected = false, setSelected, updateHeight}: Props) {
export default function ProjectCard({ project, visible, selected = false, setSelected }: Props) {
const [hovered, setHovered] = useState(false);
const [expandedImage, setExpandedImage] = useState(false);
const contentRef = useRef(null);
function projectTagsComponent(project: Project){
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
{project.tags.map((tag) => (
@@ -42,8 +38,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
function notSelectedComponent(){
return (
<div
ref={contentRef}
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setSelected(project.id)}
@@ -88,29 +83,13 @@ export default function ProjectCard({ project, visible, selected = false, setSel
);
}
useEffect(() => {
if (!contentRef.current) return;
const observer = new ResizeObserver((entries) => {
const height = entries[0]?.borderBoxSize?.[0]?.blockSize
?? entries[0]?.contentRect.height;
if (height > 0) {
updateHeight(project.id, height);
}
});
observer.observe(contentRef.current);
return () => observer.disconnect();
}, [updateHeight, project.id]);
function selectedComponent(){
return (
<div
ref={contentRef}
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className="project-card"
className="project-card project-card-selected"
key={project.id}
style={{
minWidth: "10em",
maxWidth: "90vw",
@@ -145,11 +124,12 @@ export default function ProjectCard({ project, visible, selected = false, setSel
{project.description}
</p>
{contentRef.current && project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title} />
{project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title} />
)}
<div v-if={project.link} style={{display: "flex", gap: 12, flexWrap: "wrap", marginTop: 16}}>
{project.link && (
<div style={{display: "flex", gap: 12, flexWrap: "wrap", marginTop: 16}}>
<Link
className="expand-on-hover-button"
style={{
@@ -182,24 +162,17 @@ export default function ProjectCard({ project, visible, selected = false, setSel
borderRadius: "var(--radius-md)",
}} target="_blank" href={project.link} >Link</Link>
</div>
)}
</div>
);
}
function imgComponent(){
return (
<img src={project.images?.[0]} alt={`${project.title} screenshot`} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", maxHeight: 180}} />
<img src={`/${project.images?.[0]}`} alt={`${project.title} screenshot`} width={800} height={180} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", height: 180}} />
);
}
useEffect(() => {
if (!selected) {
if (expandedImage && contentRef.current) {
setExpandedImage(false);
}
}
});
if (selected) {
return selectedComponent();
} else {

View File

@@ -1,100 +1,114 @@
"use client";
import { PROJECTS } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
import React, { useState, useCallback, useRef, useEffect } from "react";
import { PROJECTS, type Project } from "@/data/content";
import {
useStaggerReveal,
useElementWidth,
usePrefersReducedMotion,
} from "@/hooks/useAnimations";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import ProjectCard from "@/components/ProjectCard";
import {
Layout,
Responsive,
useContainerWidth,
DefaultBreakpoints,
verticalCompactor,
} from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ROW_HEIGHT = 100;
const SMALL_BREAKPOINT = 768;
const GAP = 16;
function buildLayoutsFromHeights(activeId: number, heights: Record<string, number>): Record<DefaultBreakpoints, Layout> {
const selected = PROJECTS.find((p) => p.id === activeId);
const rest = PROJECTS.filter((p) => p.id !== activeId);
/** Used for a card whose real height hasn't been measured yet. Only affects
* column balance -- cards are in normal flow, so a bad guess can never overlap. */
const ESTIMATED_HEIGHT = 280;
const h = (id: number, fallback: number) =>
heights[id.toString()] ?? fallback;
/** Greedy shortest-column packing. Columns are equal width, so a card's height
* doesn't depend on which column holds it -- this is stable, not a feedback loop. */
function packColumns(projects: Project[], heights: Record<number, number>): Project[][] {
const columns: Project[][] = [[], []];
const columnHeights = [0, 0];
function twoCols(): Layout {
let items: Layout = [];
const selH = selected ? h(selected.id, 2) : 0;
if (selected) {
items = items.concat({
i: selected.id.toString(),
x: 0,
y: 0,
w: 2,
h: selH,
});
}
const colY = [selH, selH];
for (const p of rest) {
const pH = h(p.id, 3);
const col = colY[0] <= colY[1] ? 0 : 1;
items = items.concat({ i: p.id.toString(), x: col, y: colY[col], w: 1, h: pH });
colY[col] += pH;
}
return items;
for (const project of projects) {
const col = columnHeights[0] <= columnHeights[1] ? 0 : 1;
columns[col].push(project);
columnHeights[col] += heights[project.id] ?? ESTIMATED_HEIGHT;
}
return {
lg: twoCols(),
md: twoCols(),
sm: twoCols(),
// small screens get the horizontal scroll row
xs: twoCols(),
xxs: twoCols(),
};
return columns;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function Projects() {
const visible = useStaggerReveal(PROJECTS.length, 100);
const { width, containerRef, mounted } = useContainerWidth();
const { width, ref: containerRef, mounted } = useElementWidth<HTMLDivElement>();
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
const [selectedProject, setSelectedProject] = useState<number>(1);
const selectedRef = useRef(selectedProject);
selectedRef.current = selectedProject;
const [heights, setHeights] = useState<Record<number, number>>({});
const reducedMotion = usePrefersReducedMotion();
const [supportsViewTransition, setSupportsViewTransition] = useState(false);
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(() => buildLayoutsFromHeights(1, {}));
const measuredHeights = useRef<Record<string, number>>({});
const updateHeight = useCallback((projectId: number, heightPx: number) => {
const key = projectId.toString();
const rowH = Math.max(1, Math.ceil(heightPx / ROW_HEIGHT));
useEffect(() => {
setSupportsViewTransition(typeof document.startViewTransition === "function");
}, []);
if (measuredHeights.current[key] === rowH) return;
measuredHeights.current[key] = rowH;
const useTransitions = supportsViewTransition && !reducedMotion;
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
}, []);
const selectProject = useCallback(
(id: number) => {
if (!useTransitions) {
setSelectedProject(id);
return;
}
function setSelectedHandler(projectId: number) {
setSelectedProject(projectId);
measuredHeights.current = {};
setLayouts(buildLayoutsFromHeights(projectId, {}));
}
// flushSync is required: startViewTransition snapshots the DOM when the callback returns, so the state change has to be committed by then.
document.startViewTransition(() => flushSync(() => setSelectedProject(id)));
},
[useTransitions]
);
const selected = PROJECTS.find((p) => p.id === selectedProject);
const rest = PROJECTS.filter((p) => p.id !== selectedProject);
const columns = packColumns(rest, heights);
const indexOf = (project: Project) => PROJECTS.indexOf(project);
/* ---- height measurement (cosmetic: it only decides column balance) ---- */
const observers = useRef(new Map<number, ResizeObserver>());
const refCallbacks = useRef(new Map<number, (node: HTMLDivElement | null) => void>());
// Cached so each project keeps a stable ref identity across renders; ref re-fires when the node itself changes, which is what makes this survive the
// selected/unselected subtree swap inside ProjectCard.
const getCardRef = useCallback((id: number) => {
let cb = refCallbacks.current.get(id);
if (cb) return cb;
cb = (node: HTMLDivElement | null) => {
observers.current.get(id)?.disconnect();
observers.current.delete(id);
if (!node) return;
const observer = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? entries[0]?.contentRect.height ?? 0;
if (height <= 0) return;
setHeights((prev) =>
Math.abs((prev[id] ?? 0) - height) < 1 ? prev : { ...prev, [id]: height }
);
});
observer.observe(node);
observers.current.set(id, observer);
};
refCallbacks.current.set(id, cb);
return cb;
}, []);
useEffect(() => {
const active = observers.current;
return () => {
active.forEach((observer) => observer.disconnect());
active.clear();
};
}, []);
const header = (
<>
@@ -125,18 +139,22 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
</>
);
/* ---- small screen: horizontal scroll row ---- */
if (isSmall) {
return (
<div style={{ padding: "48px 0", minWidth: 0 }}>
{header}
return (
<div
ref={containerRef}
data-vt={useTransitions ? "on" : "off"}
style={{ padding: "48px 0", minWidth: 0 }}
>
{header}
{isSmall ? (
/* ---- small screen: horizontal scroll row ---- */
<div
className={"project-grid"}
ref={containerRef}
className="project-grid"
style={{
display: "flex",
overflowX: "auto",
gap: 16,
gap: GAP,
paddingBottom: 12,
scrollSnapType: "x mandatory",
WebkitOverflowScrolling: "touch",
@@ -151,54 +169,64 @@ const updateHeight = useCallback((projectId: number, heightPx: number) => {
flexShrink: 0,
scrollSnapAlign: "start",
transition: "max-width 0.3s ease",
}}>
}}
>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
setSelected={selectProject}
/>
</div>
))}
</div>
</div>
);
}
) : (
/* ---- wide: selected card full width, the rest packed into two columns ---- */
<div style={{ display: "flex", flexDirection: "column", gap: GAP }}>
{selected && (
<div style={{ viewTransitionName: `project-${selected.id}` }}>
<ProjectCard
project={selected}
visible={visible.has(indexOf(selected))}
selected
setSelected={selectProject}
/>
</div>
)}
/* ---- normal: grid layout ---- */
return (
<div style={{ padding: "48px 0", minWidth: 400 }}>
{header}
<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={verticalCompactor}
rowHeight={ROW_HEIGHT}
dragConfig={{ enabled: false }}
resizeConfig={{ enabled: false }}
>
{PROJECTS.map((project, i) => (
<div style={{ display: "flex", gap: GAP, alignItems: "flex-start" }}>
{columns.map((column, colIndex) => (
<div
id={`project-${project.id}`}
key={project.id}>
<ProjectCard
project={project}
visible={visible.has(i)}
selected={selectedProject === project.id}
setSelected={setSelectedHandler}
updateHeight={updateHeight}
/>
key={colIndex}
style={{
// min-width:0 is required -- flex items default to min-width:auto
// and would refuse to shrink below their content.
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: GAP,
}}
>
{column.map((project) => (
<div
key={project.id}
ref={getCardRef(project.id)}
style={{ viewTransitionName: `project-${project.id}` }}
>
<ProjectCard
project={project}
visible={visible.has(indexOf(project))}
selected={false}
setSelected={selectProject}
/>
</div>
))}
</div>
))}
</Responsive>
)}
</div>
</div>
</div>
)}
</div>
);
}
}