added project pages, updated page for FAM.

This commit is contained in:
Hunter W.
2026-06-08 22:25:39 -04:00
parent 0b161f3e2d
commit 89e5c2364c
27 changed files with 1860 additions and 55 deletions

View File

@@ -0,0 +1,10 @@
'use client';
import { createContext, useContext } from 'react';
const ProjectContext = createContext<any>(null);
export function ProjectProvider({ children, value }: { children: React.ReactNode, value: any }) {
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
}
export const useSharedData = () => useContext(ProjectContext);

View File

@@ -149,6 +149,14 @@ body {
height: 28;
}
.expand-on-hover-button {
transition: all 0.2s ease;
}
.expand-on-hover-button:hover {
scale: 1.05;
}
@media (max-width: 640px) {
h1 {
font-size: 32px !important;

View File

@@ -5,6 +5,7 @@ import "./globals.css";
import { ThemeProvider } from 'next-themes'
import ThemeSwitch from "@/components/ThemeSwitch";
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--mono",

View File

@@ -4,9 +4,7 @@ import { useState } from "react";
import { useMountTransition } from "@/hooks/useAnimations";
import Nav from "@/components/Nav";
import Profile from "@/components/Profile";
import Projects from "@/components/Projects";
import Footer from "@/components/Footer";
export default function Home() {
const [section, setSection] = useState("profile");
const mounted = useMountTransition();
@@ -44,11 +42,9 @@ export default function Home() {
</header>
<Nav section={section} setSection={setSection} />
<Nav section={"profile"} />
<main>
{section === "profile" && <Profile />}
{section === "projects" && <Projects />}
<Profile />
</main>
</div>

54
app/profile/layout.tsx Normal file
View File

@@ -0,0 +1,54 @@
"use client";
import Nav from "@/components/Nav";
import Footer from "@/components/Footer";
export default function Layout({children}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
return (
<div style={{ minHeight: "100vh", paddingBottom: 36 }}>
<div
style={{
maxWidth: 880,
margin: "0 auto",
padding: "0 24px",
transition: "all 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<header
style={{
padding: "24px 0 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 14,
fontWeight: 700,
color: "var(--accent)",
letterSpacing: "-0.02em",
}}
>
</div>
</header>
<Nav section={"profile"} />
<main>
{children}
</main>
</div>
<Footer />
</div>
);
}

12
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
"use client";
import Profile from "@/components/Profile";
export default function ProfilePage() {
return (
<main>
<Profile />
</main>
);
}

View File

@@ -0,0 +1,18 @@
import { ProjectProvider } from "../../context/ProjectContext";
import { getProjectDetails } from "@/data/content";
export default async function SlugLayout({children, params}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const {slug} = await params;
const projectData = await getProjectDetails(slug)
return (
<ProjectProvider value={projectData}>
<main>
{children}
</main>
</ProjectProvider>
);
}

105
app/project/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
'use client'
import { use } from 'react'
import { redirect, RedirectType } from 'next/navigation'
import { useSharedData } from '@/app/context/ProjectContext';
import { useMountTransition } from '@/hooks/useAnimations';
import "../../../styles/markdown-container.css";
import Link from 'next/link';
import { PROJECTS } from '@/data/content';
export default function ProjectPage({
params,
}: {params: Promise<{ slug: string }>}) {
const mounted = useMountTransition(50)
const { slug } = use(params);
if (!slug) redirect('project',RedirectType.replace);
const hasValidProject = PROJECTS.find((v)=>v.slug == slug)
const hasValidDetails = hasValidProject && PROJECTS.find((v)=>v.slug == slug)?.details
const projectDetails = useSharedData();
return (
<div
id="project-markdown-container"
className={"markdown-container"}
style={{
opacity: mounted ? 1 : 0,
transition: "all 0.5s ease",
translate: mounted ? "0 2em" : "0 0"
}}
>
{
hasValidDetails ?
(
<main dangerouslySetInnerHTML={{ __html: projectDetails }}>
</main>
) : (
<p>This project has no details set up for it yet.</p>
)
}
<div style={{
display: "flex",
flexDirection: "row",
gap: "1em",
marginBottom: "2em"
}}>
<Link onClick={(e)=>{
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
}}
className="expand-on-hover-button"
style={{
fontFamily: "var(--mono)",
fontSize: 15,
color: "var(--fg)",
border: "1px solid var(--border)",
backgroundColor: "var(--bg)",
height: "3em",
display: "block",
alignContent: "center",
alignItems: "center",
textAlign: "center",
width: "10em",
borderRadius: "var(--radius-md)",
}} href={"#"}>Back To Top</Link>
<Link onClick={(e)=>{
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
});
}}
className="expand-on-hover-button"
style={{
fontFamily: "var(--mono)",
fontSize: 15,
color: "var(--fg)",
border: "1px solid var(--border)",
backgroundColor: "var(--bg)",
height: "3em",
display: "block",
alignContent: "center",
alignItems: "center",
textAlign: "center",
width: "10em",
borderRadius: "var(--radius-md)",
}} href={"/project"}>More Projects</Link>
</div>
</div>
);
}

55
app/project/layout.tsx Normal file
View File

@@ -0,0 +1,55 @@
import Nav from "@/components/Nav";
import Footer from "@/components/Footer";
export default async function Layout({children, params}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const {slug} = await params;
return (
<div style={{ minHeight: "100vh", paddingBottom: 36 }}>
<div
style={{
maxWidth: 880,
margin: "0 auto",
padding: "0 24px",
transition: "all 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<header
style={{
padding: "24px 0 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 14,
fontWeight: 700,
color: "var(--accent)",
letterSpacing: "-0.02em",
}}
>
</div>
</header>
<Nav section={"project"} subtitle={slug} />
<main>
{children}
</main>
</div>
<Footer />
</div>
);
}

14
app/project/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use client";
import ProjectsGrid from "@/components/ProjectsGrid";
export default function Project() {
return (
<main>
<ProjectsGrid />
</main>
);
}

View File

@@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
type Props = {
section: string;
setSection: (s: string) => void;
subtitle?: string;
};
const items = ["profile", "projects"];
const items = ["profile", "project"];
export default function Nav({ section, setSection }: Props) {
export default function Nav({ section, subtitle }: Props) {
return (
<nav
style={{
@@ -21,10 +23,9 @@ export default function Nav({ section, setSection }: Props) {
}}>
{items.map((item) => (
<Link key={item} href={"/" + item}>
<button
key={item}
onClick={() => setSection(item)}
style={{
background: section === item ? "var(--surface)" : "transparent",
color: section === item ? "var(--accent)" : "var(--muted)",
@@ -50,9 +51,11 @@ export default function Nav({ section, setSection }: Props) {
<span style={{ opacity: 0.4, marginRight: 6 }}></span>
{item}
</button>
</button></Link>
))}
<p>{subtitle}</p>
</nav>
);
}

View File

@@ -18,6 +18,22 @@ export default function ProfileMore() {
setMorePosition(morePosition + 1);
}
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"
@@ -63,22 +79,19 @@ export default function ProfileMore() {
})}
<div {...handlers} className={`drop-handle ${wiggling ? "wiggle" : ""}`}
onMouseEnter={() => {
if (delayTimer) return;
setHovered(true);
let timer = window.setTimeout(() => {
handleSectionExpand();
}, 500);
setDelayTimer(timer);
}}
onMouseLeave={() => {
onMouseEnter={
() => handleEnter()
}
onMouseLeave={() => handleLeave()}
onTouchStart={
() => handleEnter()
}
onTouchEnd={()=>handleLeave()}
onClick={() => {
if (delayTimer){
clearTimeout(delayTimer);
setDelayTimer(null);
}
setHovered(false);
handleSectionExpand();
}}
style={{
opacity: morePosition < PROFILE.moreSections.length ? 1.0 : 0.0,

View File

@@ -3,6 +3,8 @@
import { useEffect, useRef, useState } from "react";
import type { Project } from "@/data/content";
import SimpleGallery from "./SimpleGallery";
import Link from "next/link";
import { relative } from "path";
type Props = {
@@ -143,10 +145,27 @@ export default function ProjectCard({ project, visible, selected = false, setSel
</p>
{contentRef.current && project.images && project.images.length > 0 && (
<SimpleGallery images={project.images} videos={project.videos} title={project.title}
/>
<SimpleGallery images={project.images} videos={project.videos} title={project.title} />
)}
<Link
className="expand-on-hover-button"
style={{
fontFamily: "var(--mono)",
fontSize: 15,
color: "var(--fg)",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
backgroundColor: "var(--bg)",
height: "3em",
display: "block",
alignContent: "center",
alignItems: "center",
textAlign: "center",
width: "10em",
borderRadius: "var(--radius-md)",
position: "absolute",
right: "1em",
bottom: "1em"
}} href={"/project/"+project.slug} >See More</Link>
</div>
);
}

View File

@@ -93,20 +93,98 @@ export const PROJECTS: Project[] = [{
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.",
details: `The source for this project is private, but I am happy to
discuss the architecture and design decisions in detail during
an interview.
details: `# Flow-based Agent Management platform (FAM)
This application was deployed for a major convention in May 2026, where it achieved a pretty wild amount of success:
- Single day registration of over 10,000 guests with an average processing time of about 45 seconds.
- No single guest wait time greater than 10 minutes.
The Flow-based Agent Managemment (FAM) platform is designed to streamline high-volume in-person Agent to Guest interactions using the concept of workflows.
The previous year at the same convention with the same attendance levels experienced major wait times, and physical lines that extended around multiple floors of a large convention space. Some
attendees waited in line for hours.
<blockquote>A welcome screen using the default theme.
<a target="_blank" href="/img/projects/fam/example-welcome.png">
<img src="/img/projects/fam/example-welcome.png" alt="Alt text" width="600"/>
</a>
</blockquote>
The platform consists of a Vue 3 frontend and a configurable backend interface.
It features a dynamic UX emphasized flow-based interface that allows agents to navigate complex tasks efficiently, and with minimal training.
A real-world solution for event staff and volunteer management.
## Private Repository
This project was tailored to the needs of a large organization. As such, and naturally so — the source for this project is private.
I am happy to discuss the architecture and design decisions made for this project in detail during an interview.
<blockquote>A short video of the platform's operation.
<video width="640" height="360" controls>
<source src="/img/projects/fam/runthrough.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</blockquote>
## Summary
In the context of <b>FAM</b>, an \`interaction\` is the behavior that occurs between a guest and an agent.
An \`agent\` is a real-person (and most often a representative of some business) whom is interacting with a \`guest\` in order to exchange information or complete some task.
A front-desk employee at a hotel checking in a guest is an easy to reach example. The ticketing/admission agents at an event might be another.
These agent to guest interactions are what drive an organization on the ground.
These interactions might occur at high volumes and might be sustained throughout normal business operation. In such cases,
the amount of time spent completing tasks and exchanging information might have a very high impact on a business' operations.
FAM supports breaking these interactions down into streamlined, consistent, efficient, repeatable, and flexible
workflows.
<blockquote>Not every interaction will be the same. Some guests might have additional service needs.
<a target="_blank" href="/img/projects/fam/reference-1.png">
<img src="/img/projects/fam/reference-1.png" alt="A workflow, and a workload." width="800"/>
</a>
</blockquote>
There is a minimum amount of time that it takes an agent to complete a task in any software, and there is a reasonable maximum amount of time that might be spent on
unusual circumstances or perhaps lost to the 'growing pains' of inexperienced staff or imperfect systems.
FAM tries to shorten the duration of agent to guest interactions without reducing the quality of the interaction through purpose-built UX-first guided workflows. Task identification from business analysis are made into functional workflows that support reducing the time taken to complete tasks while also addressing the variability in
how much time that it takes to complete these tasks.
<blockquote>The FAM platform runs on a vue/nuxt stack. Here's a part of the app structure in a diagram, without exposing too much.
<a target="_blank" href="/img/projects/fam/reference-1.png">
<img src="/img/projects/fam/workflow.png" alt="Workflow architecture" width="800"/>
</a>
</blockquote>
A step can contain any type of work, and can be used in multiple workflows. An identification step for a check-in process using the default template might look like such:
<blockquote>The general look and feel of a step in a workflow.
<a target="_blank" href="/img/projects/fam/example-step.png">
<img src="/img/projects/fam/example-step.png" alt="Example Step" width="800"/>
</a>
</blockquote>
In this picture, a guest has had their ID card scanned and their profile information is displayed. The area with a marching border on the right side integrates an external barcode scanner in order to
read information on the back of US driver's license. This information is compared with that on the left, and discrepencies are highlighted in yellow. Checking the acknowledgement box at the bottom moves
the workflow into the next step.
## Implementation
This platform was implemented as a solution for in-person registration, VIP registration, pre-event mailouts, and entitlements fulfillment for an Atlanta-based organization's yearly convention for May 2026, where it achieved significant success:
The setup consists of a Vue 3 frontend and a configurable backend interface. This particular implementation utilized a Perl catalyst backend. Standard 2D barcode scanning devices and RFID tag readers were used
in the guest identification system and have out-of-the box modular support in flows created on the platform.
<pre>Some Stats:</pre>
- Single day registration of over 10,000 guests with an average processing time of about 45 seconds.
- Previous year: ~2 minutes.
- No single guest wait time greater than 10 minutes.
- Previous year had lines that wrapped around the entire building and there were stories of four hour waits.
<blockquote>A testament
<a target="_blank" href="/img/projects/fam/stats-2026-1.png">
<img src="/img/projects/fam/stats-2026-1.png" alt="Example Step" width="800"/>
</a>
</blockquote>
A real-world solution for event staff and volunteer management.
`,
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack"],
link: "private",
@@ -130,3 +208,22 @@ export const PROJECTS: Project[] = [{
link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev",
year: "2026",
}];
import { remark } from "remark";
import remarkParse from "remark-parse";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkRehype from "remark-rehype";
export async function getProjectDetails(slug: string) {
const p = PROJECTS.find((v)=>v.slug == slug);
if (!p || !p.details) return null;
const re = await remark()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify).process(p.details);
return re?.toString();
}

1334
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,14 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-grid-layout" : "^2.2.3",
"next-themes": "^0.4.6"
"next-themes": "^0.4.6",
"remark": "^15.0.1",
"remark-parse": "^11.0.0",
"remark-html": "^16.0.1",
"remark-rehype": "^11.1.2",
"rehype-stringify": "^10.0.1",
"rehype-raw": "^7.0.0",
"rehype-rewrite":"^4.0.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,6 +1,4 @@
@import '../app/globals.css';
.gallery-action-button {
position: absolute;
width: 28;

View File

@@ -0,0 +1,70 @@
.markdown-container{
p {
margin-bottom: 0.5em;
font-family: var(--sans);
font-size: 16;
line-height: 1.7;
color: var(--fg-secondary);
margin: 0 0 40px 0;
max-width: 560;
}
h1, h2, h3 {
font-family: var(--mono);
font-weight: 700;
color: var(--fg);
letter-spacing: -0.02em;
}
h1 {
margin-bottom: 1em;
margin-top: 0.5em;
}
h2 {
margin-bottom: 0.5em;
margin-top: 0.5em;
}
img {
margin: 0 0 40px 0;
}
blockquote {
width: 100%;
text-align: center;
font-family: var(--sans);
font-size: 12;
color: var(--muted);
}
ul {
font-family: var(--sans);
list-style: inside;
margin: 0 0 40px 0;
}
ul ul {
padding-left: 1em;
margin-bottom: 0;
color: var(--muted);
}
ul > li {
font-size: 14;
margin: 0 5px 5px 0;
}
ul ul > li {
font-size: 12;
margin: 0 5px 5px 0;
}
pre {
font-family: var(--mono);
margin-bottom: 0.1em;
}
}

View File

@@ -1,7 +1,4 @@
.more-section-container {
}
.drop-handle {
width: 100%;
height: 2em;

View File

@@ -29,6 +29,6 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
, "pages/test.jax" ],
"exclude": ["node_modules"]
}