added project gallery behavior
This commit is contained in:
@@ -1,103 +1,161 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Project } from "@/data/content";
|
import type { Project } from "@/data/content";
|
||||||
|
import SimpleGallery from "./SimpleGallery";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
project: Project;
|
project: Project;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
setSelected: (selected: any) => void;
|
||||||
|
setExpandedHeight: (activeId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectCard({ project, visible }: Props) {
|
export default function ProjectCard({ project, visible, selected = false, setSelected, setExpandedHeight}: Props) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
const [expandedImage, setExpandedImage] = useState(false);
|
||||||
<a
|
|
||||||
href={project.link}
|
function projectTagsComponent(project: Project){
|
||||||
target="_blank"
|
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
||||||
rel="noopener noreferrer"
|
{project.tags.map((tag) => (
|
||||||
onMouseEnter={() => setHovered(true)}
|
<span key={tag}
|
||||||
onMouseLeave={() => setHovered(false)}
|
style={{
|
||||||
style={{
|
fontFamily: "var(--mono)",
|
||||||
display: "block",
|
fontSize: 11,
|
||||||
textDecoration: "none",
|
fontWeight: 500,
|
||||||
padding: 28,
|
color: "var(--accent)",
|
||||||
background: hovered ? "var(--surface-hover)" : "var(--surface)",
|
padding: "3px 10px",
|
||||||
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
|
background: "var(--accent-bg)",
|
||||||
borderRadius: 8,
|
borderRadius: 4,
|
||||||
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
letterSpacing: "0.02em",
|
||||||
transform: visible
|
}}>
|
||||||
? hovered
|
{tag}
|
||||||
? "translateY(-3px)"
|
</span>
|
||||||
: "translateY(0)"
|
))}
|
||||||
: "translateY(12px)",
|
</div>);
|
||||||
opacity: visible ? 1 : 0,
|
}
|
||||||
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
|
|
||||||
}}
|
function notSelectedComponent(){
|
||||||
>
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
onMouseEnter={() => setHovered(true)}
|
||||||
display: "flex",
|
onMouseLeave={() => setHovered(false)}
|
||||||
justifyContent: "space-between",
|
onClick={() => setSelected(project.id)}
|
||||||
alignItems: "flex-start",
|
style={{
|
||||||
marginBottom: 12,
|
height: "100%",
|
||||||
}}
|
minWidth: "10em",
|
||||||
>
|
textDecoration: "none",
|
||||||
|
padding: 28,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: hovered ? "var(--surface-hover)" : "var(--surface)",
|
||||||
|
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
transform: visible
|
||||||
|
? hovered
|
||||||
|
? "translateY(-3px)"
|
||||||
|
: "translateY(0)"
|
||||||
|
: "translateY(12px)",
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
cursor: "pointer"
|
||||||
|
}}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12,
|
||||||
fontFamily: "var(--mono)",
|
}}>
|
||||||
fontSize: 18,
|
<div
|
||||||
fontWeight: 700,
|
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
|
||||||
color: hovered ? "var(--accent)" : "var(--fg)",
|
{project.title}
|
||||||
transition: "color 0.2s",
|
</div>
|
||||||
}}
|
<div style={{fontFamily: "var(--mono)", fontSize: 11, color: "var(--muted)", padding: "2px 8px", background: "var(--bg)", borderRadius: 4, border: "1px solid var(--border)"}}>
|
||||||
>
|
{project.year}
|
||||||
{project.title}
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--muted)",
|
|
||||||
padding: "2px 8px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
borderRadius: 4,
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.year}
|
|
||||||
</div>
|
</div>
|
||||||
|
{projectTagsComponent(project)}
|
||||||
|
<p style={{fontFamily: "var(--sans)", fontSize: 14, lineHeight: 1.6, color: "var(--fg-secondary)", margin: "0 0 16px 0"}}>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
{project.images && project.images.length > 0 && imgComponent()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedComponent(){
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onClick={() => setSelected(project.id)}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--sans)",
|
height: "100%",
|
||||||
fontSize: 14,
|
minWidth: "10em",
|
||||||
lineHeight: 1.6,
|
overflow: "hidden",
|
||||||
color: "var(--fg-secondary)",
|
textDecoration: "none",
|
||||||
margin: "0 0 16px 0",
|
padding: 28,
|
||||||
}}
|
background: hovered ? "var(--surface-hover)" : "var(--surface)",
|
||||||
>
|
border: `2px solid var(--accent)`,
|
||||||
{project.description}
|
borderRadius: 8,
|
||||||
</p>
|
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
transform: visible
|
||||||
{project.tags.map((tag) => (
|
? "translateY(-3px)"
|
||||||
<span
|
: "translateY(0)",
|
||||||
key={tag}
|
opacity: visible ? 1 : 0,
|
||||||
style={{
|
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
|
||||||
fontFamily: "var(--mono)",
|
pointerEvents: "auto",
|
||||||
fontSize: 11,
|
cursor: "pointer"
|
||||||
fontWeight: 500,
|
}}>
|
||||||
color: "var(--accent)",
|
<div
|
||||||
padding: "3px 10px",
|
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12
|
||||||
background: "var(--accent-bg)",
|
}}>
|
||||||
borderRadius: 4,
|
<div
|
||||||
letterSpacing: "0.02em",
|
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
|
||||||
}}
|
{project.title}
|
||||||
>
|
</div>
|
||||||
{tag}
|
<div style={{fontFamily: "var(--mono)", fontSize: 11, color: "var(--muted)", padding: "2px 8px", background: "var(--bg)", borderRadius: 4, border: "1px solid var(--border)"}}>
|
||||||
</span>
|
{project.year}
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectTagsComponent(project)}
|
||||||
|
<p style={{fontFamily: "var(--sans)", fontSize: 14, lineHeight: 1.6, color: "var(--fg-secondary)", margin: "0 0 16px 0"}}>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.images && project.images.length > 0 && (
|
||||||
|
<SimpleGallery images={project.images} title={project.title} expansionHandler={(height: number) => {
|
||||||
|
setExpandedHeight(height);
|
||||||
|
setExpandedImage(height > 0);
|
||||||
|
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function imgComponent(){
|
||||||
|
return (
|
||||||
|
<img src={project.images?.[0]} alt={`${project.title} screenshot`} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", maxHeight: 180}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
if (expandedImage) {
|
||||||
|
setExpandedHeight(0);
|
||||||
|
setExpandedImage(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
return selectedComponent();
|
||||||
|
} else {
|
||||||
|
|
||||||
|
return notSelectedComponent();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,94 @@
|
|||||||
|
|
||||||
import { PROJECTS } from "@/data/content";
|
import { PROJECTS } from "@/data/content";
|
||||||
import { useStaggerReveal } from "@/hooks/useAnimations";
|
import { useStaggerReveal } from "@/hooks/useAnimations";
|
||||||
import ProjectCard from "./ProjectCard";
|
import React, { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import ProjectCard from "@/components/ProjectCard";
|
||||||
|
|
||||||
|
import { Layout, Responsive, useContainerWidth, horizontalCompactor, verticalCompactor, DefaultBreakpoints } from "react-grid-layout";
|
||||||
|
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
const visible = useStaggerReveal(PROJECTS.length, 100);
|
|
||||||
|
|
||||||
|
const rowHeight = 250;
|
||||||
|
const defaultExpandedHeightUnits = 2;
|
||||||
|
const visible = useStaggerReveal(PROJECTS.length, 100);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<number>(1);
|
||||||
|
|
||||||
|
const { width, containerRef, mounted } = useContainerWidth();
|
||||||
|
|
||||||
|
const [topCellHeight, setTopCellHeight] = useState(defaultExpandedHeightUnits);
|
||||||
|
const [layouts, setLayouts] = useState<Record<DefaultBreakpoints, Layout>>(updateLayout(1));
|
||||||
|
|
||||||
|
function setExpandedHeight(height: number) {
|
||||||
|
const gridUnits = height === 0 ? defaultExpandedHeightUnits : Math.ceil(height / rowHeight);
|
||||||
|
setTopCellHeight(gridUnits);
|
||||||
|
setLayouts(updateLayout(selectedProject, gridUnits)); // pass both
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedHandler(projectId: number) {
|
||||||
|
const id = Number(projectId);
|
||||||
|
setSelectedProject(id);
|
||||||
|
setLayouts(updateLayout(id, topCellHeight)); // pass both
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayout(activeId: number, cellHeight?: number) : Record<DefaultBreakpoints, Layout> {
|
||||||
|
const currentSelected = activeId ?? selectedProject;
|
||||||
|
const currentCellHeight = cellHeight ?? topCellHeight;
|
||||||
|
let isSelected = (projectId: number) => projectId === currentSelected;
|
||||||
|
return {
|
||||||
|
"sm": PROJECTS.map((project, i) => {
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
||||||
|
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
||||||
|
w: isSelected(project.id) ? 2 : 1,
|
||||||
|
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
"xs": PROJECTS.map((project, i) => {
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: 0,
|
||||||
|
y: i,
|
||||||
|
w: 2,
|
||||||
|
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
"xxs": PROJECTS.map((project, i) => {
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: 0,
|
||||||
|
y: i,
|
||||||
|
w: 2,
|
||||||
|
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
"md": PROJECTS.map((project, i) => {
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
||||||
|
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
||||||
|
w: isSelected(project.id) ? 2 : 1,
|
||||||
|
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
"lg": PROJECTS.map((project, i) => {
|
||||||
|
return {
|
||||||
|
i: project.id.toString(),
|
||||||
|
x: isSelected(project.id) ? 0 : (i % 2) + 1,
|
||||||
|
y: isSelected(project.id) ? 0 : Math.floor(i / 2) + (!isSelected(project.id) ? 1 : 0),
|
||||||
|
w: isSelected(project.id) ? 2 : 1,
|
||||||
|
h: project.images && project.images.length > 0 ? currentCellHeight : 1
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "48px 0" }}>
|
<div style={{ padding: "48px 0"}}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -33,21 +114,33 @@ export default function Projects() {
|
|||||||
>
|
>
|
||||||
{"Things I've built"}
|
{"Things I've built"}
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
|
||||||
style={{
|
<div ref={containerRef}>
|
||||||
display: "grid",
|
{mounted && (<Responsive
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
layouts={layouts}
|
||||||
gap: 16,
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
}}
|
cols={{ lg: 2, md: 2, sm: 2, xs: 2, xxs: 2 }}
|
||||||
>
|
width={width}
|
||||||
|
compactor={horizontalCompactor}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
dragConfig={{enabled:false}}
|
||||||
|
>
|
||||||
{PROJECTS.map((project, i) => (
|
{PROJECTS.map((project, i) => (
|
||||||
<ProjectCard
|
<div key={project.id} style={{pointerEvents: "none"}}>
|
||||||
key={project.id}
|
<ProjectCard
|
||||||
project={project}
|
project={project}
|
||||||
visible={visible.has(i)}
|
visible={visible.has(i)}
|
||||||
|
selected={selectedProject === project.id}
|
||||||
|
setSelected={setSelectedHandler}
|
||||||
|
setExpandedHeight={setExpandedHeight}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
</Responsive>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
155
components/SimpleGallery.tsx
Normal file
155
components/SimpleGallery.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
images: string[];
|
||||||
|
title: string;
|
||||||
|
expansionHandler: (height: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SimpleGallery({ images, title, expansionHandler }: Props) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [enlarged, setEnlarged] = useState(false);
|
||||||
|
|
||||||
|
let timerRef: any | null = null;
|
||||||
|
if (images.length === 0) return null;
|
||||||
|
|
||||||
|
function galleryImage(src: string, alt: string) {
|
||||||
|
return (
|
||||||
|
<div><img
|
||||||
|
id="gallery-image"
|
||||||
|
src={images[active]}
|
||||||
|
alt={`${title} screenshot ${active + 1}`}
|
||||||
|
style={enlarged ? {
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
borderRadius: 6,
|
||||||
|
objectFit: "contain",
|
||||||
|
boxShadow: "0 8px 40px rgba(0,0,0,0.6)",
|
||||||
|
transition: "all 0.3s ease",} : {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: 4,
|
||||||
|
objectFit: "cover",
|
||||||
|
maxHeight: 250,
|
||||||
|
display: "block",
|
||||||
|
transition: "all 0.8s ease",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEnlarged(!enlarged);
|
||||||
|
clearTimeout(timerRef);
|
||||||
|
timerRef = setTimeout(() => {
|
||||||
|
const imgElement = document.getElementById("gallery-image") as HTMLImageElement | null;
|
||||||
|
imgElement?.height && expansionHandler( !enlarged ? imgElement.height + 70 : 0);
|
||||||
|
}, 200); // delay to allow for CSS transition
|
||||||
|
}}
|
||||||
|
title={enlarged ? "Shrink image" : "Enlarge image"}
|
||||||
|
style={
|
||||||
|
enlarged ? {
|
||||||
|
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",
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
zIndex: 1001,
|
||||||
|
transition: "background 0.15s ease",
|
||||||
|
} : {
|
||||||
|
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 => (e.currentTarget.style.background = "rgba(0,0,0,0.8)")}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = "rgba(0,0,0,0.55)")}>
|
||||||
|
|
||||||
|
{!enlarged && <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 5V1H5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M13 9V13H9" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M1 1L5.5 5.5" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<path d="M13 13L8.5 8.5" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>}
|
||||||
|
{enlarged && <svg viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg" width="60" height="60">
|
||||||
|
<line x1="15" y1="15" x2="45" y2="45" stroke="currentColor" strokeWidth="6" strokeLinecap="round"/>
|
||||||
|
<line x1="45" y1="15" x2="15" y2="45" stroke="currentColor" strokeWidth="6" strokeLinecap="round"/>
|
||||||
|
</svg>}
|
||||||
|
</button></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef) {
|
||||||
|
clearTimeout(timerRef);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
|
||||||
|
<div style={{ position: "relative", display: "inline-block", width: "100%" }}>
|
||||||
|
{galleryImage(images[active], `${title} screenshot ${active + 1}`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={src}
|
||||||
|
alt={`${title} thumbnail ${i + 1}`}
|
||||||
|
onClick={() =>{
|
||||||
|
setActive(i)
|
||||||
|
clearTimeout(timerRef);
|
||||||
|
timerRef = setTimeout(() => {
|
||||||
|
const imgElement = document.getElementById("gallery-image") as HTMLImageElement | null;
|
||||||
|
imgElement?.height && expansionHandler(imgElement.height + 70);
|
||||||
|
}, 200); // delay to allow for CSS transition
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 36,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: `2px solid ${i === active ? "var(--accent)" : "var(--border)"}`,
|
||||||
|
opacity: i === active ? 1 : 0.6,
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type Project = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
images?: string[];
|
||||||
link: string;
|
link: string;
|
||||||
year: string;
|
year: string;
|
||||||
};
|
};
|
||||||
@@ -25,7 +26,7 @@ export const PROFILE = {
|
|||||||
name: "Hunter W",
|
name: "Hunter W",
|
||||||
title: "Software Engineer",
|
title: "Software Engineer",
|
||||||
email: "contact@hwilliams.dev",
|
email: "contact@hwilliams.dev",
|
||||||
bio: "I build user experiences before I ever write code. Experience with full-stack development across a variety of frameworks and languages including JS [Vue, React, Next.JS, Nuxt, Node.JS, Express], Python [Flask, FastAPI, Gunicorn], and more.",
|
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.",
|
||||||
links: [
|
links: [
|
||||||
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
|
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
|
||||||
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
|
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
|
||||||
@@ -35,7 +36,7 @@ export const PROFILE = {
|
|||||||
label: "478-331-2258",
|
label: "478-331-2258",
|
||||||
icon: "tel"
|
icon: "tel"
|
||||||
}, {
|
}, {
|
||||||
masked: false,
|
masked: true,
|
||||||
label: "contact@hwilliams.dev",
|
label: "contact@hwilliams.dev",
|
||||||
icon: "email"
|
icon: "email"
|
||||||
}
|
}
|
||||||
@@ -53,6 +54,15 @@ export const SITE = {
|
|||||||
|
|
||||||
export const PROJECTS: Project[] = [{
|
export const PROJECTS: Project[] = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
slug: "flowbased-agent-management",
|
||||||
|
title: "Flow-Based Agent Management platform",
|
||||||
|
description: "The Flow-based Agent Managemment (FAM) platform is a real-world example of an Agent support tool designed to streamline high-volume in-person Agent to Guest interactions in various scenarios.",
|
||||||
|
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack"],
|
||||||
|
link: "private",
|
||||||
|
year: "2026",
|
||||||
|
images: [ "img/projects/fam/example-welcome.png", "img/projects/fam/example-step-in-flow.png"],
|
||||||
|
},{
|
||||||
|
id: 2,
|
||||||
slug: "clean-space",
|
slug: "clean-space",
|
||||||
title: "Clean Space",
|
title: "Clean Space",
|
||||||
description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .",
|
description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .",
|
||||||
|
|||||||
1176
package-lock.json
generated
1176
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-grid-layout" : "^2.2.3",
|
||||||
"next-themes": "^0.4.6"
|
"next-themes": "^0.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
public/img/projects/fam/example-step-in-flow.png
Normal file
BIN
public/img/projects/fam/example-step-in-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
BIN
public/img/projects/fam/example-welcome.png
Normal file
BIN
public/img/projects/fam/example-welcome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Reference in New Issue
Block a user