added deploy watch, pull script. minor refactors
3
.gitignore
vendored
@@ -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
@@ -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 "../../../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={{
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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}} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
211
data/content.ts
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ node_modules
|
|||||||
.env*.local
|
.env*.local
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||