diff --git a/.gitignore b/.gitignore index 5ef6a52..fdc11b1 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# deploy watcher trigger flags +deploy-trigger/*.flag diff --git a/app/api/deploy/route.ts b/app/api/deploy/route.ts new file mode 100644 index 0000000..7cf5e46 --- /dev/null +++ b/app/api/deploy/route.ts @@ -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_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 }); +} diff --git a/app/project/[slug]/page.tsx b/app/project/[slug]/page.tsx index 7824587..595a20a 100644 --- a/app/project/[slug]/page.tsx +++ b/app/project/[slug]/page.tsx @@ -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 && ( +
+

Gallery

+ +
+ )}
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ diff --git a/components/ProjectCard.tsx b/components/ProjectCard.tsx index bfaa00b..e8d8335 100644 --- a/components/ProjectCard.tsx +++ b/components/ProjectCard.tsx @@ -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 + }} href={"/project/"+project.slug} title={`See more details about ${project.title}`}>See More Link + }} target="_blank" href={project.link} title={`Open ${project.link} in a new tab`}>Link
)} @@ -168,8 +168,9 @@ export default function ProjectCard({ project, visible, selected = false, setSel } function imgComponent(){ + const image = project.images![0]; return ( - {`${project.title} + {imageDescription(image) ); } diff --git a/components/SimpleGallery.tsx b/components/SimpleGallery.tsx index b8d3bc5..7fa21d2 100644 --- a/components/SimpleGallery.tsx +++ b/components/SimpleGallery.tsx @@ -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({ {`${title} setIsEnlarged(true)} @@ -216,11 +235,17 @@ export default function SimpleGallery({ ) : ( {`${title} 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) => ( - {`${title} 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, - transition: "all 0.15s ease", - pointerEvents: "auto", - }} - /> - ))} + {images.map((image, i) => { + const description = imageDescription(image); + const isActive = i === active; + return ( + + ); + })} {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()} diff --git a/components/ThemeSwitch.tsx b/components/ThemeSwitch.tsx index c2cab95..4c2a974 100644 --- a/components/ThemeSwitch.tsx +++ b/components/ThemeSwitch.tsx @@ -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 (