updated projects display; simplified for mobile
This commit is contained in:
@@ -143,10 +143,12 @@ body {
|
|||||||
background: rgba(110, 231, 183, 0.25);
|
background: rgba(110, 231, 183, 0.25);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 28;
|
height: 28;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 32px !important;
|
font-size: 32px !important;
|
||||||
@@ -156,3 +158,4 @@ body {
|
|||||||
height: 36;
|
height: 36;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
setSelected: (selected: any) => void;
|
setSelected: (selected: any) => void;
|
||||||
updateHeight: (activeId: number, contentRef: HTMLElement | null) => 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, updateHeight}: Props) {
|
||||||
@@ -45,9 +45,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onClick={() => setSelected(project.id)}
|
onClick={() => setSelected(project.id)}
|
||||||
|
className="project-card"
|
||||||
style={{
|
style={{
|
||||||
// height: "100%",
|
width: "100%",
|
||||||
minWidth: "10em",
|
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
padding: 28,
|
padding: 28,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
@@ -89,9 +89,11 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contentRef.current) return;
|
if (!contentRef.current) return;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
if (contentRef.current) {
|
const height = entries[0]?.borderBoxSize?.[0]?.blockSize
|
||||||
updateHeight(project.id, contentRef.current);
|
?? entries[0]?.contentRect.height;
|
||||||
|
if (height > 0) {
|
||||||
|
updateHeight(project.id, height);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
className="project-card"
|
||||||
style={{
|
style={{
|
||||||
minWidth: "10em",
|
minWidth: "10em",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { PROJECTS } from "@/data/content";
|
import { PROJECTS } from "@/data/content";
|
||||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||||
import React, { useState, useCallback, useRef } from "react";
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
|
||||||
import ProjectCard from "@/components/ProjectCard";
|
import ProjectCard from "@/components/ProjectCard";
|
||||||
|
|
||||||
@@ -10,121 +10,94 @@ import {
|
|||||||
Layout,
|
Layout,
|
||||||
Responsive,
|
Responsive,
|
||||||
useContainerWidth,
|
useContainerWidth,
|
||||||
horizontalCompactor,
|
|
||||||
DefaultBreakpoints,
|
DefaultBreakpoints,
|
||||||
|
verticalCompactor,
|
||||||
} from "react-grid-layout";
|
} from "react-grid-layout";
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
export default function Projects() {
|
const ROW_HEIGHT = 100;
|
||||||
const rowHeight = 100;
|
const SMALL_BREAKPOINT = 768;
|
||||||
const visible = useStaggerReveal(PROJECTS.length, 100);
|
|
||||||
const [selectedProject, setSelectedProject] = useState<number>(1);
|
|
||||||
const { width, containerRef, mounted } = useContainerWidth();
|
|
||||||
|
|
||||||
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(
|
function buildLayoutsFromHeights(activeId: number, heights: Record<string, number>): Record<DefaultBreakpoints, Layout> {
|
||||||
() => buildLayouts(1, {})
|
const selected = PROJECTS.find((p) => p.id === activeId);
|
||||||
);
|
const rest = PROJECTS.filter((p) => p.id !== activeId);
|
||||||
|
|
||||||
const heightCache = useRef<Record<string, number>>({});
|
const h = (id: number, fallback: number) =>
|
||||||
|
heights[id.toString()] ?? fallback;
|
||||||
|
|
||||||
function buildLayouts(
|
function twoCols(): Layout {
|
||||||
activeId: number,
|
let items: Layout = [];
|
||||||
heightOverrides: Record<string, number>
|
const selH = selected ? h(selected.id, 2) : 0;
|
||||||
): Record<DefaultBreakpoints, Layout> {
|
|
||||||
const isSelected = (id: number) => id === activeId;
|
|
||||||
|
|
||||||
function h(project: (typeof PROJECTS)[number]) {
|
|
||||||
const override = heightOverrides[project.id.toString()];
|
|
||||||
if (isSelected(project.id) && override != null) return override;
|
|
||||||
if(!isSelected(project.id)) return 1;
|
|
||||||
return project.images?.length ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedProject = PROJECTS.find((p) => isSelected(p.id));
|
if (selected) {
|
||||||
const selectedH = selectedProject ? h(selectedProject) : 1;
|
items = items.concat({
|
||||||
const nonSelected = PROJECTS.filter((p) => !isSelected(p.id));
|
i: selected.id.toString(),
|
||||||
|
|
||||||
function twoCol(project: (typeof PROJECTS)[number]) {
|
|
||||||
if (isSelected(project.id)) {
|
|
||||||
return { i: project.id.toString(), x: 0, y: 0, w: 2, h: h(project) };
|
|
||||||
}
|
|
||||||
const ni = nonSelected.indexOf(project);
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: ni % 2,
|
|
||||||
y: Math.floor(ni / 2) + selectedH,
|
|
||||||
w: 1,
|
|
||||||
h: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function singleCol(project: (typeof PROJECTS)[number], colSpan: number) {
|
|
||||||
if (isSelected(project.id)) {
|
|
||||||
return { i: project.id.toString(), x: 0, y: 0, w: colSpan, h: h(project) };
|
|
||||||
}
|
|
||||||
const ni = nonSelected.indexOf(project);
|
|
||||||
return {
|
|
||||||
i: project.id.toString(),
|
|
||||||
x: 0,
|
x: 0,
|
||||||
y: ni + selectedH,
|
y: 0,
|
||||||
w: colSpan,
|
w: 2,
|
||||||
h: project.images?.length ? 4 : 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lg: PROJECTS.map((p) => twoCol(p)),
|
lg: twoCols(),
|
||||||
md: PROJECTS.map((p) => twoCol(p)),
|
md: twoCols(),
|
||||||
sm: PROJECTS.map((p) => twoCol(p)),
|
sm: twoCols(),
|
||||||
xs: PROJECTS.map((p) => singleCol(p, 4)),
|
|
||||||
xxs: PROJECTS.map((p) => singleCol(p, 4)),
|
// small screens get the horizontal scroll row
|
||||||
|
xs: twoCols(),
|
||||||
|
xxs: twoCols(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function Projects() {
|
||||||
|
const visible = useStaggerReveal(PROJECTS.length, 100);
|
||||||
|
const { width, containerRef, mounted } = useContainerWidth();
|
||||||
|
const isSmall = mounted && width > 0 && width < SMALL_BREAKPOINT;
|
||||||
|
|
||||||
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
||||||
const selectedRef = useRef(selectedProject);
|
const selectedRef = useRef(selectedProject);
|
||||||
selectedRef.current = selectedProject;
|
selectedRef.current = selectedProject;
|
||||||
|
|
||||||
const pendingUpdate = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(() => buildLayoutsFromHeights(1, {}));
|
||||||
|
const measuredHeights = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
const updateHeight = useCallback(
|
const updateHeight = useCallback((projectId: number, heightPx: number) => {
|
||||||
(sourceId: number, contentRef: HTMLElement | null) => {
|
const key = projectId.toString();
|
||||||
if (sourceId !== selectedRef.current) return;
|
const rowH = Math.max(1, Math.ceil(heightPx / ROW_HEIGHT));
|
||||||
|
|
||||||
if (pendingUpdate.current) clearTimeout(pendingUpdate.current);
|
if (measuredHeights.current[key] === rowH) return;
|
||||||
pendingUpdate.current = setTimeout(() => {
|
measuredHeights.current[key] = rowH;
|
||||||
const newH = contentRef
|
|
||||||
? Math.ceil(contentRef.getBoundingClientRect().height / rowHeight)
|
|
||||||
: 2;
|
|
||||||
|
|
||||||
const key = sourceId.toString();
|
setLayouts(buildLayoutsFromHeights(selectedRef.current, measuredHeights.current));
|
||||||
if (heightCache.current[key] === newH) return;
|
}, []);
|
||||||
heightCache.current[key] = newH;
|
|
||||||
setLayouts((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const bp in next) {
|
|
||||||
next[bp as DefaultBreakpoints] = next[bp as DefaultBreakpoints].map(
|
|
||||||
(layout) =>
|
|
||||||
layout.i === key ? { ...layout, h: newH } : layout
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
},
|
|
||||||
[rowHeight]
|
|
||||||
);
|
|
||||||
|
|
||||||
function setSelectedHandler(projectId: number) {
|
function setSelectedHandler(projectId: number) {
|
||||||
const id = Number(projectId);
|
setSelectedProject(projectId);
|
||||||
setSelectedProject(id);
|
measuredHeights.current = {};
|
||||||
heightCache.current = {};
|
setLayouts(buildLayoutsFromHeights(projectId, {}));
|
||||||
setLayouts(buildLayouts(id, {}));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<div style={{ padding: "48px 0", minWidth: 400 }}>
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -149,21 +122,70 @@ export default function Projects() {
|
|||||||
>
|
>
|
||||||
{"Things I've built"}
|
{"Things I've built"}
|
||||||
</h2>
|
</h2>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- small screen: horizontal scroll row ---- */
|
||||||
|
if (isSmall) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "48px 0", minWidth: 0 }}>
|
||||||
|
{header}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
overflowX: "auto",
|
||||||
|
gap: 16,
|
||||||
|
paddingBottom: 12,
|
||||||
|
scrollSnapType: "x mandatory",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PROJECTS.map((project, i) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
style={{
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: selectedProject === project.id ? 500 : 280,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- normal: grid layout ---- */
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "48px 0", minWidth: 400 }}>
|
||||||
|
{header}
|
||||||
|
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<Responsive
|
<Responsive
|
||||||
|
|
||||||
layouts={layouts}
|
layouts={layouts}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
|
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
|
||||||
width={width}
|
width={width}
|
||||||
compactor={horizontalCompactor}
|
compactor={verticalCompactor}
|
||||||
rowHeight={rowHeight}
|
rowHeight={ROW_HEIGHT}
|
||||||
dragConfig={{ enabled: false }}
|
dragConfig={{ enabled: false }}
|
||||||
|
resizeConfig={{ enabled: false }}
|
||||||
>
|
>
|
||||||
{PROJECTS.map((project, i) => (
|
{PROJECTS.map((project, i) => (
|
||||||
<div key={project.id} style={{ pointerEvents: "none" }}>
|
<div
|
||||||
|
id={`project-${project.id}`}
|
||||||
|
key={project.id}>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
project={project}
|
project={project}
|
||||||
visible={visible.has(i)}
|
visible={visible.has(i)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import '@/styles/gallery.css';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
images: string[];
|
images: string[];
|
||||||
@@ -32,7 +34,6 @@ export default function SimpleGallery({
|
|||||||
function handleEnlarge() {
|
function handleEnlarge() {
|
||||||
if (enlargeLevel === 0) {
|
if (enlargeLevel === 0) {
|
||||||
setEnlargeLevel(1);
|
setEnlargeLevel(1);
|
||||||
|
|
||||||
} else if (enlargeLevel === 1) {
|
} else if (enlargeLevel === 1) {
|
||||||
setEnlargeLevel(2);
|
setEnlargeLevel(2);
|
||||||
}
|
}
|
||||||
@@ -57,26 +58,13 @@ export default function SimpleGallery({
|
|||||||
function expandButton() {
|
function expandButton() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
className="gallery-action-button"
|
||||||
|
style={{
|
||||||
|
top: 8,
|
||||||
|
right: 8
|
||||||
|
}}
|
||||||
onClick={handleEnlarge}
|
onClick={handleEnlarge}
|
||||||
title="Enlarge image"
|
title="Enlarge image"
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "none",
|
|
||||||
background: "rgba(0,0,0,0.55)",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 0,
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
transition: "background 0.15s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) =>
|
onMouseEnter={(e) =>
|
||||||
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
|
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
|
||||||
}
|
}
|
||||||
@@ -97,27 +85,13 @@ export default function SimpleGallery({
|
|||||||
function shrinkButton() {
|
function shrinkButton() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
className="gallery-action-button"
|
||||||
|
style={{
|
||||||
|
bottom: 8,
|
||||||
|
right: 8
|
||||||
|
}}
|
||||||
onClick={handleShrink}
|
onClick={handleShrink}
|
||||||
title="Shrink image"
|
title="Shrink image"
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "none",
|
|
||||||
background: "rgba(0,0,0,0.55)",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: 0,
|
|
||||||
backdropFilter: "blur(4px)",
|
|
||||||
zIndex: 1001,
|
|
||||||
transition: "background 0.15s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) =>
|
onMouseEnter={(e) =>
|
||||||
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
|
(e.currentTarget.style.background = "rgba(0,0,0,0.8)")
|
||||||
}
|
}
|
||||||
|
|||||||
26
styles/gallery.css
Normal file
26
styles/gallery.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
@import '../app/globals.css';
|
||||||
|
|
||||||
|
.gallery-action-button {
|
||||||
|
position: absolute;
|
||||||
|
width: 28;
|
||||||
|
height: 28;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1001;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user