added project gallery behavior
This commit is contained in:
@@ -1,103 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Project } from "@/data/content";
|
||||
import SimpleGallery from "./SimpleGallery";
|
||||
|
||||
type Props = {
|
||||
project: Project;
|
||||
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);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
padding: 28,
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
const [expandedImage, setExpandedImage] = useState(false);
|
||||
|
||||
function projectTagsComponent(project: Project){
|
||||
return (<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
{project.tags.map((tag) => (
|
||||
<span key={tag}
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "var(--accent)",
|
||||
padding: "3px 10px",
|
||||
background: "var(--accent-bg)",
|
||||
borderRadius: 4,
|
||||
letterSpacing: "0.02em",
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>);
|
||||
}
|
||||
|
||||
function notSelectedComponent(){
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => setSelected(project.id)}
|
||||
style={{
|
||||
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
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: hovered ? "var(--accent)" : "var(--fg)",
|
||||
transition: "color 0.2s",
|
||||
}}
|
||||
>
|
||||
{project.title}
|
||||
</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}
|
||||
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12,
|
||||
}}>
|
||||
<div
|
||||
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
|
||||
{project.title}
|
||||
</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>
|
||||
<p
|
||||
);
|
||||
}
|
||||
|
||||
function selectedComponent(){
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => setSelected(project.id)}
|
||||
style={{
|
||||
fontFamily: "var(--sans)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
color: "var(--fg-secondary)",
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "var(--accent)",
|
||||
padding: "3px 10px",
|
||||
background: "var(--accent-bg)",
|
||||
borderRadius: 4,
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
height: "100%",
|
||||
minWidth: "10em",
|
||||
overflow: "hidden",
|
||||
textDecoration: "none",
|
||||
padding: 28,
|
||||
background: hovered ? "var(--surface-hover)" : "var(--surface)",
|
||||
border: `2px solid var(--accent)`,
|
||||
borderRadius: 8,
|
||||
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
transform: visible
|
||||
? "translateY(-3px)"
|
||||
: "translateY(0)",
|
||||
opacity: visible ? 1 : 0,
|
||||
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer"
|
||||
}}>
|
||||
<div
|
||||
style={{display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 12
|
||||
}}>
|
||||
<div
|
||||
style={{fontFamily: "var(--mono)", fontSize: 18, fontWeight: 700, color: hovered ? "var(--accent)" : "var(--fg)", transition: "color 0.2s"}}>
|
||||
{project.title}
|
||||
</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 && (
|
||||
<SimpleGallery images={project.images} title={project.title} expansionHandler={(height: number) => {
|
||||
setExpandedHeight(height);
|
||||
setExpandedImage(height > 0);
|
||||
|
||||
}} />
|
||||
)}
|
||||
|
||||
</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 { 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() {
|
||||
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 (
|
||||
<div style={{ padding: "48px 0" }}>
|
||||
<div style={{ padding: "48px 0"}}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
@@ -33,21 +114,33 @@ export default function Projects() {
|
||||
>
|
||||
{"Things I've built"}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
|
||||
<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={horizontalCompactor}
|
||||
rowHeight={rowHeight}
|
||||
dragConfig={{enabled:false}}
|
||||
>
|
||||
{PROJECTS.map((project, i) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
<div key={project.id} style={{pointerEvents: "none"}}>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
visible={visible.has(i)}
|
||||
selected={selectedProject === project.id}
|
||||
setSelected={setSelectedHandler}
|
||||
setExpandedHeight={setExpandedHeight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
))}
|
||||
</Responsive>)}
|
||||
|
||||
</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;
|
||||
description: string;
|
||||
tags: string[];
|
||||
images?: string[];
|
||||
link: string;
|
||||
year: string;
|
||||
};
|
||||
@@ -25,7 +26,7 @@ export const PROFILE = {
|
||||
name: "Hunter W",
|
||||
title: "Software Engineer",
|
||||
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: [
|
||||
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
|
||||
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
|
||||
@@ -35,7 +36,7 @@ export const PROFILE = {
|
||||
label: "478-331-2258",
|
||||
icon: "tel"
|
||||
}, {
|
||||
masked: false,
|
||||
masked: true,
|
||||
label: "contact@hwilliams.dev",
|
||||
icon: "email"
|
||||
}
|
||||
@@ -53,6 +54,15 @@ export const SITE = {
|
||||
|
||||
export const PROJECTS: Project[] = [{
|
||||
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",
|
||||
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 .",
|
||||
|
||||
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",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout" : "^2.2.3",
|
||||
"next-themes": "^0.4.6"
|
||||
},
|
||||
"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