added deploy watch, pull script. minor refactors
3
.gitignore
vendored
@@ -39,3 +39,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# deploy watcher trigger flags
|
||||
deploy-trigger/*.flag
|
||||
|
||||
47
app/api/deploy/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* POST /api/deploy
|
||||
*
|
||||
* Drops the redeploy flag that scripts/deploy-watch.sh polls for. Requires
|
||||
* `Authorization: Bearer <DEPLOY_TRIGGER_SECRET>`.
|
||||
*
|
||||
* DEPLOY_TRIGGER_DIR must point at the same directory the watcher reads
|
||||
* (mounted into the container by docker-compose.yml).
|
||||
*/
|
||||
|
||||
const TRIGGER_DIR = process.env.DEPLOY_TRIGGER_DIR ?? "/deploy-trigger";
|
||||
const TRIGGER_FILE = path.join(TRIGGER_DIR, "redeploy.flag");
|
||||
|
||||
// Hashing both sides gives equal-length buffers, so the comparison is
|
||||
// constant-time regardless of the supplied token's length.
|
||||
function tokenMatches(supplied: string, expected: string): boolean {
|
||||
const a = createHash("sha256").update(supplied).digest();
|
||||
const b = createHash("sha256").update(expected).digest();
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.DEPLOY_TRIGGER_SECRET;
|
||||
if (!secret) {
|
||||
// Not configured: behave as if the route doesn't exist.
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
if (!match || !tokenMatches(match[1].trim(), secret)) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(TRIGGER_FILE, new Date().toISOString() + "\n");
|
||||
} catch (err) {
|
||||
console.error("[deploy] failed to write trigger file", TRIGGER_FILE, err);
|
||||
return Response.json({ error: "failed to queue redeploy" }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ status: "queued" }, { status: 202 });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useMountTransition } from '@/hooks/useAnimations';
|
||||
import "../../../styles/markdown-container.css";
|
||||
import Link from 'next/link';
|
||||
import { PROJECTS } from '@/data/content';
|
||||
import SimpleGallery from '@/components/SimpleGallery';
|
||||
|
||||
export default function ProjectPage({params}: { params: Promise<{ slug: string }> })
|
||||
{
|
||||
@@ -41,6 +42,12 @@ export default function ProjectPage({params}: { params: Promise<{ slug: string }
|
||||
)
|
||||
}
|
||||
|
||||
{hasValidProject?.images && hasValidProject.images.length > 0 && (
|
||||
<section className="project-details-gallery">
|
||||
<h2>Gallery</h2>
|
||||
<SimpleGallery images={hasValidProject.images} videos={hasValidProject.videos} title={hasValidProject.title} gallerySize="large"/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function ModCard({ mod, visible = true }: Props) {
|
||||
href={mod.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={`Open ${mod.linkLabel ?? "repository"} in a new tab`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Project } from "@/data/content";
|
||||
import { type Project, imageSrc, imageDescription } from "@/data/content";
|
||||
import SimpleGallery from "./SimpleGallery";
|
||||
import Link from "next/link";
|
||||
import "../styles/project-card.css";
|
||||
@@ -144,7 +144,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
||||
textAlign: "center",
|
||||
width: "10em",
|
||||
borderRadius: "var(--radius-md)",
|
||||
}} href={"/project/"+project.slug} >See More</Link>
|
||||
}} href={"/project/"+project.slug} title={`See more details about ${project.title}`}>See More</Link>
|
||||
|
||||
<Link
|
||||
className="expand-on-hover-button"
|
||||
@@ -160,7 +160,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
||||
textAlign: "center",
|
||||
width: "10em",
|
||||
borderRadius: "var(--radius-md)",
|
||||
}} target="_blank" href={project.link} >Link</Link>
|
||||
}} target="_blank" href={project.link} title={`Open ${project.link} in a new tab`}>Link</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,8 +168,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
|
||||
}
|
||||
|
||||
function imgComponent(){
|
||||
const image = project.images![0];
|
||||
return (
|
||||
<img src={`/${project.images?.[0]}`} alt={`${project.title} screenshot`} width={800} height={180} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", height: 180}} />
|
||||
<img src={`/${imageSrc(image)}`} alt={imageDescription(image) ?? `${project.title} screenshot`} width={800} height={180} style={{width: "100%", borderRadius: 4, marginBottom: 16, objectFit: "cover", height: 180}} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,21 @@ import Image from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import '@/styles/gallery.css';
|
||||
import { type GalleryImage, imageSrc, imageDescription } from "@/data/content";
|
||||
|
||||
type GallerySize = "small" | "medium" | "large";
|
||||
type Props = {
|
||||
images: string[];
|
||||
images: GalleryImage[];
|
||||
videos?: string[];
|
||||
title: string;
|
||||
gallerySize?: GallerySize;
|
||||
};
|
||||
|
||||
export default function SimpleGallery({
|
||||
images,
|
||||
videos = [],
|
||||
title
|
||||
title,
|
||||
gallerySize = "medium"
|
||||
}: Props) {
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -28,10 +32,24 @@ export default function SimpleGallery({
|
||||
const totalMedia = images.length + videos.length;
|
||||
const isVideo = active >= images.length;
|
||||
const videoIndex = active - images.length;
|
||||
const currentSrc = isVideo ? videos[videoIndex] : images[active];
|
||||
const currentSrc = isVideo ? videos[videoIndex] : imageSrc(images[active]);
|
||||
const currentDescription = isVideo ? undefined : imageDescription(images[active]);
|
||||
|
||||
if (totalMedia === 0) return null;
|
||||
|
||||
function getGallerySize() {
|
||||
switch (gallerySize) {
|
||||
case "small":
|
||||
return { maxHeight: 150 };
|
||||
case "medium":
|
||||
return { maxHeight: 250 };
|
||||
case "large":
|
||||
return { maxHeight: 500 };
|
||||
default:
|
||||
return { maxHeight: 250 };
|
||||
}
|
||||
}
|
||||
|
||||
function handleFullscreenClose() {
|
||||
setIsEnlarged(false);
|
||||
}
|
||||
@@ -94,8 +112,8 @@ export default function SimpleGallery({
|
||||
const imageStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: 4,
|
||||
objectFit: "cover",
|
||||
maxHeight: 250,
|
||||
objectFit: "contain",
|
||||
maxHeight: getGallerySize().maxHeight,
|
||||
display: "block",
|
||||
transition: "all 0.8s ease",
|
||||
zIndex: 1,
|
||||
@@ -104,7 +122,7 @@ export default function SimpleGallery({
|
||||
const videoStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: 4,
|
||||
maxHeight: 250,
|
||||
maxHeight: getGallerySize().maxHeight,
|
||||
display: "block",
|
||||
transition: "all 0.8s ease",
|
||||
};
|
||||
@@ -126,7 +144,8 @@ export default function SimpleGallery({
|
||||
<Image
|
||||
id="gallery-main-image"
|
||||
src={`/${currentSrc}`}
|
||||
alt={`${title} screenshot ${active + 1}`}
|
||||
alt={currentDescription ?? `${title} screenshot ${active + 1}`}
|
||||
title={currentDescription}
|
||||
width={800}
|
||||
height={600}
|
||||
onClick={() => setIsEnlarged(true)}
|
||||
@@ -216,11 +235,17 @@ export default function SimpleGallery({
|
||||
) : (
|
||||
<Image
|
||||
src={`/${currentSrc}`}
|
||||
alt={`${title} fullscreen ${active + 1}`}
|
||||
alt={currentDescription ?? `${title} fullscreen ${active + 1}`}
|
||||
title={currentDescription}
|
||||
width={1600}
|
||||
height={1200}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
// Auto sizing keeps the element's box tight to the visible image;
|
||||
// otherwise the letterboxed area inside the box swallows taps
|
||||
// meant for the darkened background.
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "90vh",
|
||||
borderRadius: 8,
|
||||
@@ -245,27 +270,40 @@ export default function SimpleGallery({
|
||||
marginTop: 8,
|
||||
flexWrap: "wrap" }}>
|
||||
|
||||
{images.map((src, i) => (
|
||||
<Image
|
||||
{images.map((image, i) => {
|
||||
const description = imageDescription(image);
|
||||
const isActive = i === active;
|
||||
return (
|
||||
<button
|
||||
key={`thumb-img-${i}`}
|
||||
src={`/${src}`}
|
||||
alt={`${title} thumbnail ${i + 1}`}
|
||||
className={`gallery-thumb${isActive ? " is-active" : ""}`}
|
||||
onClick={() => handleThumbnailClick(i)}
|
||||
title={description ?? `Show ${title} screenshot ${i + 1}`}
|
||||
>
|
||||
<Image
|
||||
src={`/${imageSrc(image)}`}
|
||||
alt={description ?? `${title} thumbnail ${i + 1}`}
|
||||
width={48}
|
||||
height={36}
|
||||
onClick={() => handleThumbnailClick(i)}
|
||||
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,
|
||||
border: `2px solid ${isActive ? "var(--accent)" : "var(--border)"}`,
|
||||
opacity: isActive ? 1 : 0.6,
|
||||
transition: "all 0.15s ease",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="gallery-thumb-magnifier" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="7" cy="7" r="4.5" strokeWidth="1.75" />
|
||||
<path d="M10.5 10.5L14 14" strokeWidth="1.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{videos.map((_, i) => {
|
||||
const mediaIndex = images.length + i;
|
||||
@@ -332,6 +370,8 @@ export default function SimpleGallery({
|
||||
borderRadius: 7,
|
||||
border: `1px solid ${mediaHovered ? "var(--border-strong)" : "transparent"}`,
|
||||
transition: "border-color 0.15s ease",
|
||||
backgroundColor: "var(--overlay, #1a1a1a)",
|
||||
boxShadow: "var(--shadow-sm)"
|
||||
}}
|
||||
>
|
||||
{inlineMedia()}
|
||||
|
||||
@@ -15,16 +15,15 @@ const ThemeSwitch = () => {
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const next = () => {
|
||||
const i = themes.indexOf(theme as (typeof themes)[number])
|
||||
setTheme(themes[(i + 1) % themes.length])
|
||||
}
|
||||
const nextTheme = themes[(themes.indexOf(theme as (typeof themes)[number]) + 1) % themes.length]
|
||||
const next = () => setTheme(nextTheme)
|
||||
const label = `Theme: ${theme}. Click to switch to ${nextTheme}.`
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={next}
|
||||
aria-label={`Theme: ${theme}`}
|
||||
title={`Theme: ${theme}`}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'var(--space-4)',
|
||||
|
||||
211
data/content.ts
@@ -16,6 +16,21 @@ export type ContactMethod = {
|
||||
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;
|
||||
@@ -23,7 +38,7 @@ export type Project = {
|
||||
description: string;
|
||||
details?: string;
|
||||
tags: string[];
|
||||
images?: string[];
|
||||
images?: GalleryImage[];
|
||||
videos?: string[];
|
||||
link: string;
|
||||
year: string;
|
||||
@@ -106,7 +121,7 @@ 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 workflows with a focus on collapsing the gap between a novice agent and an expert one.",
|
||||
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/>
|
||||
@@ -121,195 +136,45 @@ export const PROJECTS: Project[] = [{
|
||||
|
||||
## App Overview
|
||||
|
||||
Flowdesk is a microservices platform for breaking down complex business processes into guided workflows. The focus here is on collapsing the gap in time it takes to perform tasks between a novice agent and an expert one.
|
||||
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 even shorter version: 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.
|
||||
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). **The metric is time-to-task and its variance - not just the average.**
|
||||
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>
|
||||
|
||||
|
||||
|
||||
<blockquote>State driven navigation and visual consistency between controls make complex tasks feel easier.
|
||||
<a target="_blank" href="/img/projects/fam/example-step-2.png">
|
||||
<img src="/img/projects/fam/example-step-2.png" alt="Alt text" width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
<blockquote>A step demonstrating input components.
|
||||
<a target="_blank" href="/img/projects/fam/example-step.png">
|
||||
<img src="/img/projects/fam/example-step.png" alt="Alt text" width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
<blockquote>A short video of the platform's operation. This is a custom implemntation of the architecture for a specific event.
|
||||
The platform is highly configurable and can be used for many different business processes.
|
||||
|
||||
<video width="1000" controls>
|
||||
<source src="/img/projects/fam/runthrough.mp4" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</blockquote>
|
||||
|
||||
## Details
|
||||
|
||||
In Flowdesk, an \`interaction\` is the behavior that occurs between a guest and an agent.
|
||||
An \`agent\` is a real-person who is interacting with this \`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.
|
||||
|
||||
<blockquote>A standard conversation flow
|
||||
<a target="_blank" href="/img/projects/fam/standard-conversation-flow.png">
|
||||
<img src="/img/projects/fam/standard-conversation-flow.png" alt="Alt text" />
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
A normal business day might see these interactions repeated hundreds or thousands of times, and the time taken to complete these interactions
|
||||
might vary significantly from one agent to another. Each agent can also generally only handle one interaction at a time.
|
||||
|
||||
<blockquote>A line of workflows waiting to happen
|
||||
<a target="_blank" href="/img/projects/fam/flow-lineup.png">
|
||||
<img src="/img/projects/fam/flow-lineup.png" alt="Alt text" />
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
Scale this up.
|
||||
|
||||
Hundreds of guests, or thousands of guests arriving at a high rate. A once a year event, if you will.
|
||||
|
||||
|
||||
<blockquote>An surge of workflows happening all at once
|
||||
<a target="_blank" href="/img/projects/fam/flow-lineup-at-scale.png">
|
||||
<img src="/img/projects/fam/flow-lineup-at-scale.png" alt="Alt text" />
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
Okay, most organizations aren't going to have a single agent out there handling such scale, but even with numerous agents the variance in handling time
|
||||
is a real bottleneck to operations. Little variances in the amount of time spent might have a very high cumulative impact on a business' ability to scale
|
||||
operation to bursts in demand.
|
||||
|
||||
Flowdesk envisions breaking these interactions down into streamlined, consistent, efficient, repeatable, and flexible
|
||||
workflows that can be completed by agents of any skill level with minimal training.
|
||||
|
||||
The images below are more technical documentation, but they do look neat. Bonus points if you get it.
|
||||
|
||||
<blockquote>
|
||||
<a target="_blank" href="/img/projects/fam/workflow-steps.png">
|
||||
<img src="/img/projects/fam/workflow-steps.png" alt="A workflow divides some business process into a sequence of steps." width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
<blockquote>
|
||||
<a target="_blank" href="/img/projects/fam/workflow-abandon.png">
|
||||
<img src="/img/projects/fam/workflow-abandon.png" alt="A workflow is easy to repeat or abandon." width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
<blockquote>
|
||||
<a target="_blank" href="/img/projects/fam/workflow-sequence-trav.png">
|
||||
<img src="/img/projects/fam/workflow-sequence-trav.png" alt="A workflow may allow traversal between steps." width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
There is a minimum amount of time that it takes an agent to complete a task in a UI, on average, and a reasonable maximum amount of time that might
|
||||
be spent on the same task in the same UI, on average. Flowdesk compresses the space between the minimum amount of time taken to complete a task and the maximum aount of time taken.
|
||||
|
||||
<blockquote>
|
||||
<a target="_blank" href="/img/projects/fam/variables.png">
|
||||
<img src="/img/projects/fam/variables.png" alt="Attempt to demonstrate variance in workflows." width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
# Flowdesk Process
|
||||
|
||||
The prerequisite to a good set of Flowdesk is to identify the tasks that are being performed by agents and the time taken to complete these tasks.
|
||||
Most tasks can be broken down into sequences of steps, and these steps can be made into workflows that are repeatable and consistent.
|
||||
|
||||
You divide these more modular tasks into stages, and then create consistent UX/UI experiences for each of these stages.
|
||||
Some tasks share stages, and some stages are unique to a task. Some stages are simple, and some stages are complex.
|
||||
Some tasks may even involve one another.
|
||||
|
||||
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.
|
||||
|
||||
Flowdesk is not a one-size-fits-all solution, but it is a platform that can be used to create solutions for many different business processes.
|
||||
Just 50% coverage of tasks can have a significant impact on organizations that have a high volume of interactions with their customers, clients, or guests.
|
||||
See the testimonial below for a real-world example of how this platform was used to improve the guest experience at a large convention in Atlanta, Georgia.
|
||||
|
||||
<blockquote>Technical document. Partial app structure.
|
||||
<a target="_blank" href="/img/projects/fam/reference-1.png">
|
||||
<img src="/img/projects/fam/workflow.png" alt="Workflow architecture" width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
## Steps
|
||||
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 feel of a workflow step.
|
||||
<a target="_blank" href="/img/projects/fam/flow-look-1.png">
|
||||
<img src="/img/projects/fam/flow-look-1.png" alt="Example Step" width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
In this picture, a guest has had their member ID scanned on the first and their profile information is displayed. The first step is now locked and the agent must reset to go back.
|
||||
|
||||
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 the displayed profile on the left, and discrepencies are highlighted in yellow.
|
||||
|
||||
Checking the acknowledgement box at the bottom moves the workflow into the next step by fulfulling the step advancement conditions.
|
||||
|
||||
<blockquote>The major components of this UI.
|
||||
<a target="_blank" href="/img/projects/fam/flow-look-2.png">
|
||||
<img src="/img/projects/fam/flow-look-2.png" alt="Example Step" width="800"/>
|
||||
</a>
|
||||
</blockquote>
|
||||
|
||||
|
||||
## 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"],
|
||||
tags: ["Vue", "Node.JS", "Event-Management", "Real-Time", "Full-Stack", "Microservices", "SSO", "Rest API"],
|
||||
link: "https://flowdesk.hwilliams.dev",
|
||||
year: "2026",
|
||||
images: [ "img/projects/fam/example-welcome.png", "img/projects/fam/example-step-2.png", "img/projects/fam/example-step.png"],
|
||||
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,
|
||||
|
||||
0
deploy-trigger/.gitkeep
Normal file
@@ -8,6 +8,13 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
# Bearer token for POST /api/deploy. Set it in a .env file next to this
|
||||
# compose file (gitignored); the route returns 404 when it is unset.
|
||||
- DEPLOY_TRIGGER_SECRET=${DEPLOY_TRIGGER_SECRET:-}
|
||||
- DEPLOY_TRIGGER_DIR=/deploy-trigger
|
||||
volumes:
|
||||
# Shared with scripts/deploy-watch.sh on the host.
|
||||
- ./deploy-trigger:/deploy-trigger
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3050"]
|
||||
|
||||
@@ -6,3 +6,6 @@ node_modules
|
||||
.env*.local
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
deploy-trigger
|
||||
scripts
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/projects/flowdesk/demo-screenshot-editor-1.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/img/projects/flowdesk/demo-screenshot-loggedin-flow.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/img/projects/flowdesk/demo-screenshot-loggedout-flow.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 62 KiB |
BIN
public/img/projects/flowdesk/demo-screenshot-org-manage.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/img/projects/flowdesk/flowdesk-preview-summary-1.png
Normal file
|
After Width: | Height: | Size: 967 KiB |
BIN
public/img/projects/flowdesk/prototype-feedback-screenshot.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
public/img/projects/flowdesk/prototype-live-screenshot.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
18
scripts/deploy-watch.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=hwilliams dev root watcher
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__DEPLOY_USER__
|
||||
WorkingDirectory=__REPO_DIR__
|
||||
Environment=REPO_DIR=__REPO_DIR__
|
||||
Environment=SERVICE=__SERVICE__
|
||||
ExecStart=__REPO_DIR__/scripts/deploy-watch.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
30
scripts/deploy-watch.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="${REPO_DIR:-$(dirname "$SCRIPT_DIR")}"
|
||||
TRIGGER_FILE="${TRIGGER_FILE:-$REPO_DIR/deploy-trigger/redeploy.flag}"
|
||||
POLL_INTERVAL="${POLL_INTERVAL:-10}"
|
||||
# Compose service name from docker-compose.yml (not the generated image name).
|
||||
SERVICE="${SERVICE:-portfolio}"
|
||||
log() { echo "[deploy-watch] $(date -Iseconds) $*"; }
|
||||
|
||||
log "watching for $TRIGGER_FILE (repo: $REPO_DIR, service: $SERVICE, interval: ${POLL_INTERVAL}s)"
|
||||
|
||||
while true; do
|
||||
if [ -f "$TRIGGER_FILE" ]; then
|
||||
log "trigger detected, redeploying"
|
||||
rm -f "$TRIGGER_FILE"
|
||||
|
||||
# set -e is ignored inside a subshell that is part of an && / || list, so
|
||||
# each step is chained explicitly to stop at the first failure.
|
||||
(
|
||||
cd "$REPO_DIR" &&
|
||||
git pull --ff-only &&
|
||||
docker compose build "$SERVICE" &&
|
||||
docker compose up -d "$SERVICE" &&
|
||||
docker image prune -f
|
||||
) && log "redeploy complete" || log "redeploy FAILED, see output above"
|
||||
fi
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
62
scripts/install-deploy-watch.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs deploy-watch.sh as a systemd service so it survives reboots and
|
||||
# restarts on failure. Run on the host that runs `docker compose` for this
|
||||
# stack (i.e. where ./deploy-trigger and the repo checkout actually live).
|
||||
#
|
||||
# Usage: sudo ./scripts/install-deploy-watch.sh [user]
|
||||
# user account to run the watcher as (must be in the `docker` group).
|
||||
# Defaults to $SUDO_USER, or the current user if not run via sudo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Run this with sudo (writes to /etc/systemd/system)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DEPLOY_USER="${1:-${SUDO_USER:-$(id -un)}}"
|
||||
UNIT_NAME="deploy-watch-hwilliams-dev.service"
|
||||
UNIT_DEST="/etc/systemd/system/$UNIT_NAME"
|
||||
# Compose service name from docker-compose.yml.
|
||||
SERVICE="portfolio"
|
||||
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||
echo "User '$DEPLOY_USER' does not exist." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! id -nG "$DEPLOY_USER" | grep -qw docker; then
|
||||
echo "Warning: '$DEPLOY_USER' is not in the 'docker' group;" \
|
||||
"docker compose calls in deploy-watch.sh will fail until you add it" \
|
||||
"(sudo usermod -aG docker $DEPLOY_USER) and re-login." >&2
|
||||
fi
|
||||
|
||||
TRIGGER_DIR="$REPO_DIR/deploy-trigger"
|
||||
# The web container (uid/gid 1001, see Dockerfile) writes the flag through a
|
||||
# bind mount and the watcher deletes it, so both need write access.
|
||||
CONTAINER_GID=1001
|
||||
mkdir -p "$TRIGGER_DIR"
|
||||
chown "$DEPLOY_USER:$CONTAINER_GID" "$TRIGGER_DIR"
|
||||
chmod 2775 "$TRIGGER_DIR"
|
||||
if ! sudo -u "$DEPLOY_USER" test -w "$TRIGGER_DIR"; then
|
||||
echo "Warning: '$DEPLOY_USER' cannot write to $TRIGGER_DIR;" \
|
||||
"the watcher will fail to clear the trigger file and restart-loop." >&2
|
||||
fi
|
||||
|
||||
chmod +x "$SCRIPT_DIR/deploy-watch.sh"
|
||||
|
||||
sed \
|
||||
-e "s|__REPO_DIR__|$REPO_DIR|g" \
|
||||
-e "s|__SERVICE__|$SERVICE|g" \
|
||||
-e "s|__DEPLOY_USER__|$DEPLOY_USER|g" \
|
||||
"$SCRIPT_DIR/deploy-watch.service" > "$UNIT_DEST"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$UNIT_NAME"
|
||||
# restart (not start) so a reinstall picks up changes to the unit or script.
|
||||
systemctl restart "$UNIT_NAME"
|
||||
|
||||
echo "Installed and started $UNIT_NAME (repo: $REPO_DIR, user: $DEPLOY_USER)."
|
||||
echo "Check status with: systemctl status $UNIT_NAME"
|
||||
echo "Follow logs with: journalctl -u $UNIT_NAME -f"
|
||||
29
scripts/uninstall-deploy-watch.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stops and removes the deploy-watch systemd service installed by
|
||||
# install-deploy-watch.sh.
|
||||
#
|
||||
# Usage: sudo ./scripts/uninstall-deploy-watch.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Run this with sudo (modifies /etc/systemd/system)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UNIT_NAME="deploy-watch-hwilliams-dev.service"
|
||||
UNIT_DEST="/etc/systemd/system/$UNIT_NAME"
|
||||
|
||||
if systemctl list-unit-files | grep -q "^$UNIT_NAME"; then
|
||||
systemctl disable --now "$UNIT_NAME"
|
||||
else
|
||||
echo "$UNIT_NAME is not installed; nothing to disable."
|
||||
fi
|
||||
|
||||
if [ -f "$UNIT_DEST" ]; then
|
||||
rm "$UNIT_DEST"
|
||||
systemctl daemon-reload
|
||||
echo "Removed $UNIT_DEST."
|
||||
else
|
||||
echo "$UNIT_DEST not found; nothing to remove."
|
||||
fi
|
||||
@@ -33,6 +33,41 @@
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.gallery-thumb img {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Hover hint that clicking a thumbnail shows it in the main display. The
|
||||
active thumbnail is already there, so it gets no hint. */
|
||||
.gallery-thumb-magnifier {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.gallery-thumb:not(.is-active):hover .gallery-thumb-magnifier,
|
||||
.gallery-thumb:not(.is-active):focus-visible .gallery-thumb-magnifier {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-button-svg {
|
||||
stroke: var(--fg-secondary);
|
||||
}
|
||||
|
||||
@@ -114,12 +114,22 @@
|
||||
video {
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
/* The gallery sizes its own media; keep the article image rules off it. */
|
||||
.project-details-gallery {
|
||||
max-width: 720px;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.project-details-gallery img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.markdown-container {
|
||||
div:last-of-type {
|
||||
> div:last-of-type {
|
||||
margin-bottom: 4em !important;
|
||||
}
|
||||
}
|
||||
|
||||