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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user