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:
@@ -35,6 +35,9 @@
|
||||
--info: #2e7db5;
|
||||
--info-bg: rgba(46, 125, 181, 0.07);
|
||||
|
||||
/* Threshold band (profile "more" reveal) -- darkens in light mode */
|
||||
--threshold-tint: rgba(0, 0, 0, 0.035);
|
||||
|
||||
/* Overlay / depth */
|
||||
--overlay: rgba(0, 0, 0, 0.2);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
@@ -117,6 +120,9 @@
|
||||
--info: #60a5d6;
|
||||
--info-bg: rgba(96, 165, 214, 0.08);
|
||||
|
||||
/* Threshold band (profile "more" reveal) -- lightens in dark mode */
|
||||
--threshold-tint: rgba(255, 255, 255, 0.035);
|
||||
|
||||
/* Overlay / depth */
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
@@ -132,8 +138,27 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The page below the profile "more" threshold is tinted. Painting it as the
|
||||
root background rather than as an element means it covers the whole document
|
||||
for free, and -- unlike an oversized absolutely-positioned box -- it cannot
|
||||
extend the scrollable area. `--threshold-y` is the line's distance from the
|
||||
top of the document, set from ProfileMore; with no threshold on the page the
|
||||
100% fallback puts the stop at the bottom, so nothing is tinted. */
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent var(--threshold-y, 100%),
|
||||
var(--threshold-tint) var(--threshold-y, 100%),
|
||||
var(--threshold-tint) 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
/* Transparent, so the root gradient above shows through. */
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -190,22 +215,48 @@ nav {
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
scrollbar-width: 10px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
15% { transform: rotate(-1deg); }
|
||||
30% { transform: rotate(1deg); }
|
||||
45% { transform: rotate(-1deg); }
|
||||
60% { transform: rotate(1deg); }
|
||||
75% { transform: rotate(-1deg); }
|
||||
/* ---- project card selection ---- *
|
||||
*
|
||||
* Browsers with the View Transitions API morph the card between its grid slot
|
||||
* and the full-width slot; `data-vt="off"` marks the fallback path, where the
|
||||
* newly-selected card plays this enter animation instead.
|
||||
*/
|
||||
|
||||
@keyframes card-select-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wiggle {
|
||||
animation: wiggle 0.4s ease-in-out infinite;
|
||||
[data-vt="off"] .project-card-selected {
|
||||
animation: card-select-in 350ms var(--ease-out) both;
|
||||
}
|
||||
|
||||
::view-transition-group(*) {
|
||||
animation-duration: 380ms;
|
||||
animation-timing-function: var(--ease-out);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-vt="off"] .project-card-selected {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
const { wiggling, progress, handlers } = useWiggle(500, () => {});
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
function handleSectionExpand(){
|
||||
setMorePosition(morePosition + 1);
|
||||
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 style={{ width: "100%" }}>
|
||||
{above.map((item, i) => (
|
||||
<Section key={i} item={item} />
|
||||
))}
|
||||
|
||||
{/* 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
|
||||
className="more-section-container"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
width: "100%",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
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}
|
||||
>
|
||||
{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",
|
||||
}}>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,13 +11,10 @@ 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 }}>
|
||||
@@ -43,7 +39,6 @@ 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)}
|
||||
@@ -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}
|
||||
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 && (
|
||||
{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 {
|
||||
|
||||
@@ -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>>({});
|
||||
useEffect(() => {
|
||||
setSupportsViewTransition(typeof document.startViewTransition === "function");
|
||||
}, []);
|
||||
|
||||
const updateHeight = useCallback((projectId: number, heightPx: number) => {
|
||||
const key = projectId.toString();
|
||||
const rowH = Math.max(1, Math.ceil(heightPx / ROW_HEIGHT));
|
||||
const useTransitions = supportsViewTransition && !reducedMotion;
|
||||
|
||||
if (measuredHeights.current[key] === rowH) return;
|
||||
measuredHeights.current[key] = rowH;
|
||||
const selectProject = useCallback(
|
||||
(id: number) => {
|
||||
if (!useTransitions) {
|
||||
setSelectedProject(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
|
||||
}, []);
|
||||
// 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]
|
||||
);
|
||||
|
||||
function setSelectedHandler(projectId: number) {
|
||||
setSelectedProject(projectId);
|
||||
measuredHeights.current = {};
|
||||
setLayouts(buildLayoutsFromHeights(projectId, {}));
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -31,39 +31,35 @@ export type Project = {
|
||||
|
||||
|
||||
export const PROFILE = {
|
||||
name: "Hunter W",
|
||||
name: "Hunter",
|
||||
title: "Software Engineer",
|
||||
email: "contact@hwilliams.dev",
|
||||
bio: `I build user experiences before I write code.
|
||||
Experience with full-stack development across a variety of frameworks and languages including Vue,
|
||||
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more.`,
|
||||
portrait: "img/portrait.jpg",
|
||||
bio: `Hi there. I consider myself a problem solver type and the adventurous sort. Lets take it apart, see how it works — then we have some options:
|
||||
build something completely different out of it, take it to it's genuine limits, or just change a few things about it.
|
||||
|
||||
Looking for work. See below.`,
|
||||
moreSections: [
|
||||
{
|
||||
title:"A little more about me",
|
||||
text: `I graduated with a B.S. in Computer Science from Georgia Southern University in May 2025.
|
||||
I have been working with AWS building analytics and monitoring dashboards with a sprinkle of prompt
|
||||
engineering on major production projects.`
|
||||
text: `
|
||||
I'm an associate level (~1yr) software engineer with full-stack development experience across a variety of frameworks and languages including Vue,
|
||||
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more. Graduated with a B.S. in May of 2025. Last seen at USAN building AWS deployed apps. `
|
||||
},
|
||||
{
|
||||
text: "I make mods for video games such as Space Engineers and Rimworld in my free time."
|
||||
title:"What do I do right now?",
|
||||
text: "I'm building Flowdesk! Check it out on my projects page. I also build mods for video games such as Palworld, Space Engineers and Rimworld, and run webservers for various modding tools — like the U4ESS Object Explorer. I don't post my projects on github as much anymore to avoid being free training for AI, but my old stuff is still there and you can find any new open source projects of mine at git.hwilliams.dev/hwilliams."
|
||||
},
|
||||
{
|
||||
title:"My goals",
|
||||
text: `User experience. \n \n Nothing makes me happier than seeing someone excited to use the software I make, and the way that happens is by practicing
|
||||
development techniques that make a good user experience. I especially enjoy peeling back the layers of old systems to explore how an existing
|
||||
user experience can be improved. I love the words 'Wow, it used to be difficult to do this!'.
|
||||
text: `Land the perfect user experience every time. \n \n Nothing makes me happier than seeing someone enjoy the experience they have using the things I make, and the way that happens is by practicing
|
||||
development techniques that make a good user experience. I love to hear things like 'Your app makes it easy!', 'Your app feels good to use!', and 'Wow, it used to be difficult to do this!'.
|
||||
`
|
||||
},
|
||||
{
|
||||
title:"My dreams",
|
||||
text: `Stop me if you've heard this one before: I left college with a burning passion for machine learning and artificial intelligence. It's still there, and despite
|
||||
the trouble going on in the world right now I see a bright future for how these incredibly advanced statistical models can still be applied in new ways.
|
||||
\n
|
||||
title:"Footnote",
|
||||
text: `If you've found this page trying to answer a question about my mods, please feel free to reach out.
|
||||
`
|
||||
},
|
||||
{
|
||||
title:"Wow, you're still here?",
|
||||
text: `I appreciate you, but I am totally out of things to talk about.`
|
||||
}
|
||||
] satisfies MoreSection[],
|
||||
moreSectionsStart: 1,
|
||||
@@ -305,8 +301,17 @@ A real-world solution for event staff and volunteer management.
|
||||
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
|
||||
link: "https://github.com/FerrenF/CleanSpace",
|
||||
year: "2025",
|
||||
}, {
|
||||
},{
|
||||
id: 3,
|
||||
slug: "ue4ss-explorer",
|
||||
title: "Unreal: UE4SS Object Explorer",
|
||||
description: "The UE4SS Object explorer implements a byte-level parser to organize unreal engine object dumps into visual data structures.",
|
||||
tags: ["Nuxt", "Vue", "TypeScript", "Node"],
|
||||
link: "https://ue4ss-explorer.gimme.pet",
|
||||
year: "2026",
|
||||
images: [ "img/projects/uoe/example.png"],
|
||||
},{
|
||||
id: 4,
|
||||
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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, type CSSProperties } from "react";
|
||||
|
||||
export function useStaggerReveal(count: number, baseDelay = 80) {
|
||||
const [visible, setVisible] = useState<Set<number>>(new Set());
|
||||
@@ -21,6 +21,101 @@ export function useStaggerReveal(count: number, baseDelay = 80) {
|
||||
return visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the rendered width of an element. `mounted` stays false until the
|
||||
* first measurement lands, so callers can avoid rendering a width-dependent
|
||||
* layout against a zero/SSR width.
|
||||
*/
|
||||
export function useElementWidth<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(w);
|
||||
setMounted(true);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { width, ref, mounted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a nested element break out to the full width of the viewport.
|
||||
*
|
||||
* The pure-CSS `left:50%; margin-left:-50vw` trick only works when every
|
||||
* ancestor is horizontally centered, which is not true here -- Profile's
|
||||
* content column is left-aligned inside the centered page wrapper. Measuring
|
||||
* the real offset is exact from any nesting depth, and using
|
||||
* `documentElement.clientWidth` instead of `100vw` excludes the scrollbar,
|
||||
* so this can't induce a horizontal scroll.
|
||||
*
|
||||
* Returns a style that is a no-op until the first measurement lands.
|
||||
*/
|
||||
export function useFullBleed<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [bleed, setBleed] = useState<{ width: number; offsetLeft: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
// Undo any margin this element is already carrying, so re-measuring is idempotent.
|
||||
const current = parseFloat(el.style.marginLeft || "0") || 0;
|
||||
const offsetLeft = el.getBoundingClientRect().left - current;
|
||||
|
||||
setBleed((prev) =>
|
||||
prev && prev.width === width && Math.abs(prev.offsetLeft - offsetLeft) < 0.5
|
||||
? prev
|
||||
: { width, offsetLeft }
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(document.documentElement);
|
||||
window.addEventListener("resize", measure);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const style: CSSProperties = bleed
|
||||
? { width: bleed.width, marginLeft: -bleed.offsetLeft }
|
||||
: { width: "100%", marginLeft: 0 };
|
||||
|
||||
return { ref, style, measured: bleed !== null };
|
||||
}
|
||||
|
||||
/** Mirrors the user's `prefers-reduced-motion` setting, and follows changes to it. */
|
||||
export function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => setReduced(query.matches);
|
||||
|
||||
sync();
|
||||
query.addEventListener("change", sync);
|
||||
return () => query.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
export function useMountTransition(delay = 50) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -31,63 +126,3 @@ export function useMountTransition(delay = 50) {
|
||||
|
||||
return mounted;
|
||||
}
|
||||
|
||||
|
||||
export function useWiggle(durationMs = 1500, onComplete: () => void) {
|
||||
const [wiggling, setWiggling] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const startTime = useRef<number>(0);
|
||||
const rafId = useRef<number>(0);
|
||||
const completeTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const completedRef = useRef(false);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setWiggling(false);
|
||||
setProgress(0);
|
||||
cancelAnimationFrame(rafId.current);
|
||||
if (completeTimer.current) clearTimeout(completeTimer.current);
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(() => {
|
||||
const elapsed = Date.now() - startTime.current;
|
||||
const p = Math.min(1, elapsed / durationMs);
|
||||
setProgress(p);
|
||||
if (p < 1) {
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [durationMs]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
completedRef.current = false;
|
||||
startTime.current = Date.now();
|
||||
setWiggling(true);
|
||||
setProgress(0);
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
|
||||
completeTimer.current = setTimeout(() => {
|
||||
completedRef.current = true;
|
||||
stop();
|
||||
onComplete();
|
||||
}, durationMs);
|
||||
}, [durationMs, onComplete, tick, stop]);
|
||||
|
||||
const release = useCallback(() => {
|
||||
if (!completedRef.current) stop();
|
||||
}, [stop]);
|
||||
|
||||
// cleanup on unmount
|
||||
useEffect(() => () => {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
if (completeTimer.current) clearTimeout(completeTimer.current);
|
||||
}, []);
|
||||
|
||||
const handlers = {
|
||||
onPointerDown: start,
|
||||
onPointerEnter: start,
|
||||
onPointerUp: release,
|
||||
onPointerLeave: release,
|
||||
};
|
||||
|
||||
return { wiggling, progress, handlers };
|
||||
}
|
||||
201
package-lock.json
generated
201
package-lock.json
generated
@@ -12,9 +12,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-rewrite": "^4.0.4",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
@@ -2583,22 +2581,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47-match": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
|
||||
"integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
@@ -2800,15 +2782,6 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2868,22 +2841,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-selector-parser": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz",
|
||||
"integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -3057,19 +3014,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/direction": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz",
|
||||
"integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"direction": "cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -3757,12 +3701,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
|
||||
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -4205,19 +4143,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-has-property": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz",
|
||||
"integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
@@ -4271,33 +4196,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-select": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz",
|
||||
"integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"bcp-47-match": "^2.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"css-selector-parser": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"direction": "^2.0.0",
|
||||
"hast-util-has-property": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"hast-util-whitespace": "^3.0.0",
|
||||
"nth-check": "^2.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-html": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
|
||||
@@ -4340,19 +4238,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-string": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -4935,6 +4820,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -5365,6 +5251,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -6152,22 +6039,11 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6478,6 +6354,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -6547,58 +6424,13 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
||||
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-grid-layout": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz",
|
||||
"integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"fast-equals": "^4.0.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-resizable": "^3.1.3",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
|
||||
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "15.x",
|
||||
"react-draggable": "^4.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3",
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -6658,23 +6490,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-rewrite": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/rehype-rewrite/-/rehype-rewrite-4.0.4.tgz",
|
||||
"integrity": "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hast-util-select": "^6.0.0",
|
||||
"unified": "^11.0.3",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-stringify": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
|
||||
@@ -6771,12 +6586,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout" : "^2.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"remark": "^15.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
|
||||
BIN
public/img/portrait.jpg
Normal file
BIN
public/img/portrait.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
BIN
public/img/projects/uoe/example.png
Normal file
BIN
public/img/projects/uoe/example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -1,18 +1,191 @@
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Profile "more" threshold
|
||||
*
|
||||
* A full-bleed rule sits at the vertical centre of the handle. Everything
|
||||
* below it is tinted -- darker in light mode, lighter in dark mode -- so the
|
||||
* page reads as having a boundary you can cross. Crossing it reveals the
|
||||
* remaining sections in one motion.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
.drop-handle {
|
||||
width: 100%;
|
||||
height: 2em;
|
||||
display: flex;
|
||||
gap: 12;
|
||||
margin: 0 0 40px 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
bottom: 0px;
|
||||
background-color: var(--bg-raised);
|
||||
.threshold {
|
||||
position: relative;
|
||||
/* The rule is the vertical centre of this box, so the box is the hit area. */
|
||||
width: 100%;
|
||||
height: 4em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 8px 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.threshold:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The tint itself is painted as the root background (see globals.css) -- an
|
||||
element tall enough to cover the rest of the page would extend the
|
||||
document's scrollable area. ProfileMore only publishes the line's position
|
||||
as `--threshold-y`. */
|
||||
|
||||
/* The rule itself. */
|
||||
.threshold-rule {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
transform: translateY(-0.5px);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
var(--border) 12%,
|
||||
var(--border-strong) 50%,
|
||||
var(--border) 88%,
|
||||
transparent 100%
|
||||
);
|
||||
transition: background var(--duration-slow) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.threshold[data-armed="true"] .threshold-rule {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
var(--accent-bg) 6%,
|
||||
var(--accent) 50%,
|
||||
var(--accent-bg) 94%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* A soft glow that blooms out of the centre when the threshold is armed. */
|
||||
.threshold-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
height: 120px;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%) scaleX(0.3);
|
||||
opacity: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
var(--accent-bg) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
transition:
|
||||
opacity var(--duration-slow) var(--ease-out),
|
||||
transform var(--duration-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.threshold[data-armed="true"] .threshold-glow {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scaleX(1);
|
||||
}
|
||||
|
||||
/* The label pill. The rule passes behind it. */
|
||||
.threshold-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color var(--duration-normal) var(--ease-in-out),
|
||||
border-color var(--duration-normal) var(--ease-in-out),
|
||||
background var(--duration-normal) var(--ease-in-out),
|
||||
transform var(--duration-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
/* Move the focus ring onto the pill -- the button itself is a full-width band. */
|
||||
.threshold:focus-visible .threshold-label {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.threshold[data-armed="true"] .threshold-label {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-raised);
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
.threshold-chevron {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--duration-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.threshold[data-armed="true"] .threshold-chevron {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.threshold[data-open="true"] .threshold-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---- the reveal ---- */
|
||||
|
||||
/* 0fr -> 1fr is the animatable stand-in for height:auto, so the sections can
|
||||
size themselves to their real content without any JS measurement. */
|
||||
.more-reveal {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 700ms var(--ease-out);
|
||||
}
|
||||
|
||||
.more-reveal[data-open="true"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.more-reveal > .more-reveal-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.more-section {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition:
|
||||
opacity 500ms var(--ease-out),
|
||||
transform 500ms var(--ease-out);
|
||||
}
|
||||
|
||||
.more-reveal[data-open="true"] .more-section {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.more-reveal,
|
||||
.more-section,
|
||||
.threshold-glow,
|
||||
.threshold-label,
|
||||
.threshold-chevron,
|
||||
.threshold-rule {
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
|
||||
.more-section {
|
||||
transition-delay: 0ms !important;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user