added deploy watch, pull script. minor refactors

This commit is contained in:
Hunter
2026-10-03 14:31:43 -04:00
parent 6dbaa647dc
commit 30c8ad9293
30 changed files with 370 additions and 213 deletions

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# deploy watcher trigger flags
deploy-trigger/*.flag

47
app/api/deploy/route.ts Normal file
View 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 });
}

View File

@@ -7,6 +7,7 @@ import { useMountTransition } from '@/hooks/useAnimations';
import "../../../styles/markdown-container.css"; import "../../../styles/markdown-container.css";
import Link from 'next/link'; import Link from 'next/link';
import { PROJECTS } from '@/data/content'; import { PROJECTS } from '@/data/content';
import SimpleGallery from '@/components/SimpleGallery';
export default function ProjectPage({params}: { params: Promise<{ slug: string }> }) 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={{ <div style={{

View File

@@ -19,6 +19,7 @@ export default function ModCard({ mod, visible = true }: Props) {
href={mod.link} href={mod.link}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
title={`Open ${mod.linkLabel ?? "repository"} in a new tab`}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
style={{ style={{

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { Project } from "@/data/content"; import { type Project, imageSrc, imageDescription } from "@/data/content";
import SimpleGallery from "./SimpleGallery"; import SimpleGallery from "./SimpleGallery";
import Link from "next/link"; import Link from "next/link";
import "../styles/project-card.css"; import "../styles/project-card.css";
@@ -144,7 +144,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
textAlign: "center", textAlign: "center",
width: "10em", width: "10em",
borderRadius: "var(--radius-md)", 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 <Link
className="expand-on-hover-button" className="expand-on-hover-button"
@@ -160,7 +160,7 @@ export default function ProjectCard({ project, visible, selected = false, setSel
textAlign: "center", textAlign: "center",
width: "10em", width: "10em",
borderRadius: "var(--radius-md)", 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>
)} )}
</div> </div>
@@ -168,8 +168,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel
} }
function imgComponent(){ function imgComponent(){
const image = project.images![0];
return ( 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}} />
); );
} }

View File

@@ -3,17 +3,21 @@ import Image from "next/image";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import '@/styles/gallery.css'; import '@/styles/gallery.css';
import { type GalleryImage, imageSrc, imageDescription } from "@/data/content";
type GallerySize = "small" | "medium" | "large";
type Props = { type Props = {
images: string[]; images: GalleryImage[];
videos?: string[]; videos?: string[];
title: string; title: string;
gallerySize?: GallerySize;
}; };
export default function SimpleGallery({ export default function SimpleGallery({
images, images,
videos = [], videos = [],
title title,
gallerySize = "medium"
}: Props) { }: Props) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
@@ -28,10 +32,24 @@ export default function SimpleGallery({
const totalMedia = images.length + videos.length; const totalMedia = images.length + videos.length;
const isVideo = active >= images.length; const isVideo = active >= images.length;
const videoIndex = 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; 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() { function handleFullscreenClose() {
setIsEnlarged(false); setIsEnlarged(false);
} }
@@ -94,8 +112,8 @@ export default function SimpleGallery({
const imageStyle: React.CSSProperties = { const imageStyle: React.CSSProperties = {
width: "100%", width: "100%",
borderRadius: 4, borderRadius: 4,
objectFit: "cover", objectFit: "contain",
maxHeight: 250, maxHeight: getGallerySize().maxHeight,
display: "block", display: "block",
transition: "all 0.8s ease", transition: "all 0.8s ease",
zIndex: 1, zIndex: 1,
@@ -104,7 +122,7 @@ export default function SimpleGallery({
const videoStyle: React.CSSProperties = { const videoStyle: React.CSSProperties = {
width: "100%", width: "100%",
borderRadius: 4, borderRadius: 4,
maxHeight: 250, maxHeight: getGallerySize().maxHeight,
display: "block", display: "block",
transition: "all 0.8s ease", transition: "all 0.8s ease",
}; };
@@ -126,7 +144,8 @@ export default function SimpleGallery({
<Image <Image
id="gallery-main-image" id="gallery-main-image"
src={`/${currentSrc}`} src={`/${currentSrc}`}
alt={`${title} screenshot ${active + 1}`} alt={currentDescription ?? `${title} screenshot ${active + 1}`}
title={currentDescription}
width={800} width={800}
height={600} height={600}
onClick={() => setIsEnlarged(true)} onClick={() => setIsEnlarged(true)}
@@ -216,11 +235,17 @@ export default function SimpleGallery({
) : ( ) : (
<Image <Image
src={`/${currentSrc}`} src={`/${currentSrc}`}
alt={`${title} fullscreen ${active + 1}`} alt={currentDescription ?? `${title} fullscreen ${active + 1}`}
title={currentDescription}
width={1600} width={1600}
height={1200} height={1200}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ 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", maxWidth: "95vw",
maxHeight: "90vh", maxHeight: "90vh",
borderRadius: 8, borderRadius: 8,
@@ -245,27 +270,40 @@ export default function SimpleGallery({
marginTop: 8, marginTop: 8,
flexWrap: "wrap" }}> flexWrap: "wrap" }}>
{images.map((src, i) => ( {images.map((image, i) => {
<Image const description = imageDescription(image);
key={`thumb-img-${i}`} const isActive = i === active;
src={`/${src}`} return (
alt={`${title} thumbnail ${i + 1}`} <button
width={48} key={`thumb-img-${i}`}
height={36} className={`gallery-thumb${isActive ? " is-active" : ""}`}
onClick={() => handleThumbnailClick(i)} onClick={() => handleThumbnailClick(i)}
style={{ title={description ?? `Show ${title} screenshot ${i + 1}`}
width: 48, >
height: 36, <Image
objectFit: "cover", src={`/${imageSrc(image)}`}
borderRadius: 3, alt={description ?? `${title} thumbnail ${i + 1}`}
cursor: "pointer", width={48}
border: `2px solid ${i === active ? "var(--accent)" : "var(--border)"}`, height={36}
opacity: i === active ? 1 : 0.6, style={{
transition: "all 0.15s ease", width: 48,
pointerEvents: "auto", height: 36,
}} objectFit: "cover",
/> borderRadius: 3,
))} border: `2px solid ${isActive ? "var(--accent)" : "var(--border)"}`,
opacity: isActive ? 1 : 0.6,
transition: "all 0.15s ease",
}}
/>
<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) => { {videos.map((_, i) => {
const mediaIndex = images.length + i; const mediaIndex = images.length + i;
@@ -332,6 +370,8 @@ export default function SimpleGallery({
borderRadius: 7, borderRadius: 7,
border: `1px solid ${mediaHovered ? "var(--border-strong)" : "transparent"}`, border: `1px solid ${mediaHovered ? "var(--border-strong)" : "transparent"}`,
transition: "border-color 0.15s ease", transition: "border-color 0.15s ease",
backgroundColor: "var(--overlay, #1a1a1a)",
boxShadow: "var(--shadow-sm)"
}} }}
> >
{inlineMedia()} {inlineMedia()}

View File

@@ -15,16 +15,15 @@ const ThemeSwitch = () => {
if (!mounted) return null if (!mounted) return null
const next = () => { const nextTheme = themes[(themes.indexOf(theme as (typeof themes)[number]) + 1) % themes.length]
const i = themes.indexOf(theme as (typeof themes)[number]) const next = () => setTheme(nextTheme)
setTheme(themes[(i + 1) % themes.length]) const label = `Theme: ${theme}. Click to switch to ${nextTheme}.`
}
return ( return (
<button <button
onClick={next} onClick={next}
aria-label={`Theme: ${theme}`} aria-label={label}
title={`Theme: ${theme}`} title={label}
style={{ style={{
position: 'fixed', position: 'fixed',
top: 'var(--space-4)', top: 'var(--space-4)',

View File

@@ -16,6 +16,21 @@ export type ContactMethod = {
icon: 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 = { export type Project = {
id: number; id: number;
slug: string; slug: string;
@@ -23,7 +38,7 @@ export type Project = {
description: string; description: string;
details?: string; details?: string;
tags: string[]; tags: string[];
images?: string[]; images?: GalleryImage[];
videos?: string[]; videos?: string[];
link: string; link: string;
year: string; year: string;
@@ -106,7 +121,7 @@ export const PROJECTS: Project[] = [{
id: 1, id: 1,
slug: "workflows-app", slug: "workflows-app",
title: "Flowdesk: Hit the ground running", 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. details: `# Flowdesk: A Flow-based support platform designed for event-based organizations.
<br/> <br/>
@@ -121,195 +136,45 @@ export const PROJECTS: Project[] = [{
## App Overview ## 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." 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> <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 ## 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. 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. 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. 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", link: "https://flowdesk.hwilliams.dev",
year: "2026", 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" ] //videos: [ "img/projects/fam/runthrough.mp4" ]
},{ },{
id: 2, id: 2,

0
deploy-trigger/.gitkeep Normal file
View File

View File

@@ -8,6 +8,13 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1 - 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 restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3050"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3050"]

View File

@@ -6,3 +6,6 @@ node_modules
.env*.local .env*.local
.vscode .vscode
.idea .idea
deploy-trigger
scripts

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View 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
View 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

View 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"

View 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

View File

@@ -33,6 +33,41 @@
background-color: var(--surface-hover); 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 { .action-button-svg {
stroke: var(--fg-secondary); stroke: var(--fg-secondary);
} }

View File

@@ -114,12 +114,22 @@
video { video {
max-width: 90vw; 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) { @media (max-width: 768px) {
.markdown-container { .markdown-container {
div:last-of-type { > div:last-of-type {
margin-bottom: 4em !important; margin-bottom: 4em !important;
} }
} }