Files
hwilliams-dev/data/content.ts

343 lines
13 KiB
TypeScript

import blogPosts from "./blog-posts.json";
export type MoreSection = {
title?: string;
text: string;
}
export type SocialLink = {
label: string;
url: string;
icon: "gh" | "li" | "x" | "dev";
};
export type ContactMethod = {
masked: boolean;
label: string;
icon: string;
}
/** A gallery image: either a bare url, or a url with metadata. */
export type GalleryImage = string | {
src: string;
/** Used as the image's alt text and tooltip. */
description?: string;
};
export function imageSrc(image: GalleryImage): string {
return typeof image === "string" ? image : image.src;
}
export function imageDescription(image: GalleryImage): string | undefined {
return typeof image === "string" ? undefined : image.description;
}
export type Project = {
id: number;
slug: string;
title: string;
description: string;
details?: string;
tags: string[];
images?: GalleryImage[];
videos?: string[];
link: string;
year: string;
};
export type BlogPost = {
id: number;
slug: string; // kebab-case, URL-safe
title: string;
description: string; // 1-2 sentence excerpt shown on cards
date: string; // display date, e.g. "2026-09-14"
category: string; // slug, e.g. "meta" — one category per post
tags: string[];
images?: GalleryImage[]; // optional, rendered via SimpleGallery
details: string; // markdown body
};
export type Mod = {
id: number;
title: string;
description: string;
link: string;
linkLabel?: string;
version?: string;
tags?: string[];
};
export type ModdedGame = {
id: number;
slug: string;
title: string;
platform?: string;
description?: string;
mods: Mod[];
};
export const PROFILE = {
name: "Hunter",
title: "Software Engineer",
email: "contact@hwilliams.dev",
portrait: "img/portrait.jpg",
bio: `Hi there. I consider myself a problem solver type and the adventurous sort. Lets take it apart, see how it works — then we have some options:
build something completely different out of it, take it to it's genuine limits, or just change a few things about it.
Looking for work. See below.`,
moreSections: [
{
title:"A little more about me",
text: `
I'm an associate level (~1yr) software engineer with full-stack development experience across a variety of frameworks and languages including Vue,
React, Next.JS, Nuxt, Node.JS, Express, Flask, FastAPI, Gunicorn, and more. Graduated with a B.S. in May of 2025. Last seen at USAN building AWS deployed apps. `
},
{
title:"What do I do right now?",
text: "I'm building Flowdesk! Check it out on my projects page. I also build mods for video games such as Palworld, Space Engineers and Rimworld, and run webservers for various modding tools — like the U4ESS Object Explorer. I don't post my projects on github as much anymore to avoid being free training for AI, but my old stuff is still there and you can find any new open source projects of mine at git.hwilliams.dev/hwilliams."
},
{
title:"My goals",
text: `Land the perfect user experience every time. \n \n Nothing makes me happier than seeing someone enjoy the experience they have using the things I make, and the way that happens is by practicing
development techniques that make a good user experience. I love to hear things like 'Your app makes it easy!', 'Your app feels good to use!', and 'Wow, it used to be difficult to do this!'.
`
},
{
title:"Footnote",
text: `If you've found this page trying to answer a question about my mods, please feel free to reach out.
`
}
] satisfies MoreSection[],
moreSectionsStart: 1,
links: [
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
] satisfies SocialLink[],
contactMethods: [{
masked: true,
label: "contact@hwilliams.dev",
icon: "email"
}
] satisfies ContactMethod[]
};
export const SITE = {
title: "Hunter W. - Software Engineer",
description: "SWE Looking for work",
source: {
label: "Git",
link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev"
}
};
export const PROJECTS: Project[] = [{
id: 1,
slug: "workflows-app",
title: "Flowdesk: Hit the ground running",
description: "Flowdesk is a microservices platform for breaking down complex business processes into guided visual workflows. Includes an SSO system, administration panel, database and API, and front-end.",
details: `# Flowdesk: A Flow-based support platform designed for event-based organizations.
<br/>
<blockquote>It's not just "make the UI simpler" but "make a day-one hire perform like a veteran on this task, every time."
<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>
<br/>
## App Overview
Flowdesk is a set of microservices that acts as a platform for breaking down complex business processes into guided workflows. It's designed for event-based organizations, such as those that run
conferences, festivals, or other large scale events. Guest check-in/out, agent management, membership tracking, and many other critical tasks are given a consistent and streamlined workflow that
just feels good to use.
The approach that Flowdesk takes is trying to collapse the gap in time it takes to perform tasks between a novice agent and an expert one. Two agents doing the same check-in shouldn't take 90 seconds and 6 minutes. Flowdesk as a product seeks to compress that spread in variance.
As an objective it is measurable, demonstrable, and directly tied to money (labor cost, queue length, guest satisfaction, throughput per desk). **It's all about time-to-task and its variance - not just the average.**
It's not just "easy software. It's not just "make the UI simpler". It's "make a day-one hire perform like a veteran on this task, every time."
<a target="_blank" href="https://flowdesk.hwilliams.dev">Preview: Flowdesk Development Site</a>
## Private Repository
This is a real product that is in development. The source code is not public, but the platform may soon be available as a service or for licensing.
Please contact me for more information or exceptions to this.
A real-world solution for event staff and volunteer management.
`,
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack", "Microservices", "SSO", "Rest API"],
link: "https://flowdesk.hwilliams.dev",
year: "2026",
images: [
{ src: "img/projects/flowdesk/flowdesk-preview-summary-1.png", description: "Flowdesk overview: your organization, your people, your process" },
{ src: "img/projects/flowdesk/prototype-live-screenshot.png", description: "Live registration flow: guest information and ID verification step" },
{ src: "img/projects/flowdesk/demo-screenshot-loggedout-flow.png", description: "Guided workflow as seen by a logged-out user" },
{ src: "img/projects/flowdesk/demo-screenshot-loggedin-flow.png", description: "Guided workflow as seen by a logged-in agent" },
{ src: "img/projects/flowdesk/demo-screenshot-editor-1.png", description: "Workflow editor" },
{ src: "img/projects/flowdesk/demo-screenshot-org-manage.png", description: "Organization management" },
{ src: "img/projects/flowdesk/demo-screenshot-org-details-sponsor.png", description: "Organization details: sponsorship" },
{ src: "img/projects/flowdesk/demo-screenshot-org-details-invite.png", description: "Organization details: member invites" },
{ src: "img/projects/flowdesk/demo-screenshot-message-inbox-empty.png", description: "Message inbox" },
{ src: "img/projects/flowdesk/demo-screenshot-admin-org-manage.png", description: "Admin panel: organization management" },
{ src: "img/projects/flowdesk/demo-screenshot-admin-tickets-manage.png", description: "Admin panel: ticket management" },
{ src: "img/projects/flowdesk/demo-screenshot-admin-message-manage.png", description: "Admin panel: message management" },
{ src: "img/projects/flowdesk/prototype-feedback-screenshot.png", description: "Event staff feedback after the prototype's first convention deployment" },
],
//videos: [ "img/projects/fam/runthrough.mp4" ]
},{
id: 2,
slug: "clean-space",
title: "Clean Space",
description: "Clean Space is a proof of concept and in-development tool for (Space Engineers) Torch server owners featuring client and server side plugin hashing, identification, and filtering controls.",
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
link: "https://github.com/FerrenF/CleanSpace",
year: "2025",
images: [
"img/projects/clean/cleanspace.png",
"img/projects/clean/ss1.png",
"img/projects/clean/ss2.png",
"img/projects/clean/ss3.png"],
},{
id: 3,
slug: "ue4ss-explorer",
title: "Unreal: UE4SS Object Explorer",
description: "The UE4SS Object explorer implements a byte-level parser to organize unreal engine object dumps into visual data structures.",
tags: ["Nuxt", "Vue", "TypeScript", "Node"],
link: "https://ue4ss-explorer.gimme.pet",
year: "2026",
images: [ "img/projects/uoe/example.png"],
},{
id: 4,
slug: "personal-portfolio",
title: "Personal Portfolio Website",
description: "This very website! Built with Next.JS, React, and TypeScript. Featuring a custom CMS and a lot of custom-built components and hooks.",
tags: ["Next.JS", "React", "TypeScript", "Vercel"],
link: "https://git.hwilliams.dev/hwilliams/hwilliams-dev",
year: "2026",
}];
/* Blog posts live in blog-posts.json so tooling (blog-manager/) can edit them
* without rewriting TypeScript. The shape must match the BlogPost type above. */
export const BLOG_POSTS: BlogPost[] = blogPosts as BlogPost[];
/* ---- blog directory helpers ----
* Pagination and category filtering for the /blog list. Everything derives
* from BLOG_POSTS (data/blog-posts.json), the single source of truth. */
/** Small enough that pagination is visible as soon as a few posts exist. */
export const POSTS_PER_PAGE = 3;
/** Deduplicated, alphabetically sorted categories currently in use. */
export function blogCategories(): string[] {
return [...new Set(BLOG_POSTS.map((p) => p.category))].sort();
}
/** Slug -> display string: "development" -> "Development", "game-mods" -> "Game Mods". */
export function categoryLabel(slug: string): string {
return slug
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/** All posts when omitted, otherwise the posts in the given category. */
export function postsForCategory(slug?: string): BlogPost[] {
if (!slug) return BLOG_POSTS;
return BLOG_POSTS.filter((p) => p.category === slug);
}
/**
* Pure pagination over an already-sorted array. Clamps `page` into
* [1, totalPages], so out-of-range values (?page=99) land on the last page
* instead of producing an empty or error state.
*/
export function paginate<T>(
items: T[],
page: number,
perPage: number
): {
items: T[];
page: number;
totalPages: number;
total: number;
startIndex: number;
endIndex: number;
} {
const total = items.length;
const safePerPage = Math.max(1, Math.floor(perPage));
const totalPages = Math.max(1, Math.ceil(total / safePerPage));
const requested = Number.isFinite(page) ? Math.floor(page) : NaN;
const clamped = Math.min(Math.max(1, requested || 1), totalPages);
const startIndex = total === 0 ? 0 : (clamped - 1) * safePerPage + 1;
const endIndex = Math.min(total, clamped * safePerPage);
return {
items: items.slice((clamped - 1) * safePerPage, clamped * safePerPage),
page: clamped,
totalPages,
total,
startIndex,
endIndex,
};
}
export const MOD_GAMES: ModdedGame[] = [{
id: 1,
slug: "palworld",
title: "Palworld",
platform: "UE4SS / Dedicated server",
description: "Server-side tooling and quality-of-life changes for dedicated Palworld servers.",
mods: [{
id: 1,
title: "PalUI Extension",
description: "A collection of UI enhancements for Palworld, focused on manipulating the existing UI instead of adding complex new elements.",
link: "https://plz.gimme.pet/ferren/PalUIExtension",
linkLabel: "Git",
version: "0.0.3",
tags: ["Client", "Unreal Engine", "Palworld", "UE4SS", "C++", "UI", "Injection"],
}]
}
];
import { remark } from "remark";
import remarkParse from "remark-parse";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkRehype from "remark-rehype";
/**
* Shared remark -> rehype pipeline. Content is authored in-repo and reviewed
* via git, and allowDangerousHtml is intentional: post/project markdown may
* embed raw HTML blocks (see the blockquote-with-image idiom).
*/
async function renderMarkdown(source: string): Promise<string> {
const re = await remark()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify)
.process(source);
return re?.toString() ?? "";
}
export async function getProjectDetails(slug: string) {
const p = PROJECTS.find((v)=>v.slug == slug);
if (!p || !p.details) return null;
return renderMarkdown(p.details);
}
export async function getBlogDetails(slug: string) {
const p = BLOG_POSTS.find((v) => v.slug == slug);
if (!p || !p.details) return null;
return renderMarkdown(p.details);
}