From 3e24c3936aa97107f7749e79727dd129284e02df Mon Sep 17 00:00:00 2001 From: "Wyatt J. Miller" Date: Tue, 2 Dec 2025 14:07:18 -0500 Subject: [PATCH] wip: added project modal still want to add carousel, summary, etc. --- frontend/fresh.gen.ts | 4 + frontend/islands/ProjectCard.tsx | 64 ++++++++++--- frontend/islands/modal.tsx | 158 +++++++++++++++++++++++++++++++ frontend/islands/portal.tsx | 38 ++++++++ 4 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 frontend/islands/modal.tsx create mode 100644 frontend/islands/portal.tsx diff --git a/frontend/fresh.gen.ts b/frontend/fresh.gen.ts index 94994fb..e177290 100644 --- a/frontend/fresh.gen.ts +++ b/frontend/fresh.gen.ts @@ -16,6 +16,8 @@ import * as $rss_index from "./routes/rss/index.tsx"; import * as $sitemap_index from "./routes/sitemap/index.tsx"; import * as $Counter from "./islands/Counter.tsx"; import * as $ProjectCard from "./islands/ProjectCard.tsx"; +import * as $modal from "./islands/modal.tsx"; +import * as $portal from "./islands/portal.tsx"; import { type Manifest } from "$fresh/server.ts"; const manifest = { @@ -36,6 +38,8 @@ const manifest = { islands: { "./islands/Counter.tsx": $Counter, "./islands/ProjectCard.tsx": $ProjectCard, + "./islands/modal.tsx": $modal, + "./islands/portal.tsx": $portal, }, baseUrl: import.meta.url, } satisfies Manifest; diff --git a/frontend/islands/ProjectCard.tsx b/frontend/islands/ProjectCard.tsx index 73bc65e..5198623 100644 --- a/frontend/islands/ProjectCard.tsx +++ b/frontend/islands/ProjectCard.tsx @@ -1,4 +1,10 @@ +import { useState } from "preact/hooks"; +import { Portal } from "./portal.tsx"; +import { Modal } from "./modal.tsx"; + export const ProjectCard = function ProjectCard(props: ProjectProps) { + const [open, setOpen] = useState(false); + return (
props.repo && open(props.repo, "_blank")} + onClick={() => { + // clicking the card (not the link) opens the modal + console.log("opened portal"); + setOpen(true); + }} >

- + { + // clicking the link should not open the modal + e.stopPropagation(); + }} + > {props.title}

- {props.repo && ( - e.stopPropagation()} - > - Active - - )} + {props.repo && Active} {!props.repo && !props.wip && Dead} {props.wip && WIP}
@@ -42,6 +50,38 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {

{props.tech}

+ + {open && !props.wip ? ( + + setOpen(false)} + actions={[ + { + label: "Open repository", + onClick: () => { + if (props.repo) window.open(props.repo, "_blank"); + }, + variant: "primary", + }, + { + label: "Close", + onClick: () => setOpen(false), + variant: "secondary", + }, + ]} + > +
+

+ {props.summary} +

+

+ Technologies used: {props.tech} +

+
+
+
+ ) : null}
); }; diff --git a/frontend/islands/modal.tsx b/frontend/islands/modal.tsx new file mode 100644 index 0000000..816f30e --- /dev/null +++ b/frontend/islands/modal.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import type { ComponentChildren } from "preact"; + +type ModalAction = { + label: string; + onClick: () => void; + variant?: "primary" | "secondary" | "link"; +}; + +type ModalProps = { + title?: string; + /** + * Called after the modal has finished its exit animation. + * The Modal will run the exit animation internally and then call onClose(). + */ + onClose: () => void; + children: ComponentChildren; + ariaLabel?: string; + actions?: ModalAction[]; // rendered in footer; each button will be given flex-1 so buttons fill the width together + /** + * Optional: duration (ms) of enter/exit animation. Defaults to 200. + * Keep this in sync with the CSS transition-duration classes used below. + */ + animationDurationMs?: number; +}; + +export function Modal({ + title, + onClose, + children, + ariaLabel, + actions, + animationDurationMs = 200, +}: ModalProps) { + // Controls the entrance/exit animation state. true => visible (enter), false => hidden (exit) + const [isVisible, setIsVisible] = useState(false); + // Prevent double-triggering the close flow + const closingRef = useRef(false); + // Hold the timeout id for cleanup + const timeoutRef = useRef(null); + + useEffect(() => { + // Defer to next frame so initial "hidden" styles are applied before animating to visible. + const raf = requestAnimationFrame(() => setIsVisible(true)); + + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + startCloseFlow(); + } + } + document.addEventListener("keydown", onKey); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("keydown", onKey); + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // If no actions provided, render a single Close button + const footerActions: ModalAction[] = + actions && actions.length > 0 + ? actions + : [{ label: "Close", onClick: startCloseFlow, variant: "primary" }]; + + // Start exit animation and call onClose after animationDurationMs + function startCloseFlow() { + if (closingRef.current) return; + closingRef.current = true; + setIsVisible(false); + // Wait for the CSS transition to finish before signalling parent to actually unmount + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + onClose(); + }, animationDurationMs); + } + + // Animation classes (enter & exit): + // - panel: transitions opacity + transform for a subtle fade + pop + // - backdrop: transitions opacity for fade + const panelBase = + "relative z-10 max-w-lg w-full bg-white dark:bg-[#1f2937] rounded-lg shadow-xl p-6 mx-4 transform transition-all"; + // We explicitly set the CSS transition duration inline to keep class + timeout in sync. + const panelVisible = "opacity-100 translate-y-0 scale-100"; + const panelHidden = "opacity-0 translate-y-2 scale-95"; + + const backdropBase = + "absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"; + const backdropVisible = "opacity-100"; + const backdropHidden = "opacity-0"; + + // Footer button class generator + const renderActionButton = (act: ModalAction) => { + const base = + "flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none"; + const styles = + act.variant === "primary" + ? "bg-[#94e2d5] text-black hover:brightness-95" + : act.variant === "link" + ? "bg-transparent text-[#075985] underline" + : "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95"; + + return ( + + ); + }; + + return ( +