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);
+ }}
>
- {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 (
+
+ {/* Backdrop */}
+
+ {/* Modal panel */}
+
e.stopPropagation()}
+ >
+
+
+ {title}
+
+
+
+
+
+ {children}
+
+
+
+
+ {footerActions.map(renderActionButton)}
+
+
+
+
+ );
+}
diff --git a/frontend/islands/portal.tsx b/frontend/islands/portal.tsx
new file mode 100644
index 0000000..82ac973
--- /dev/null
+++ b/frontend/islands/portal.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from "preact/hooks";
+import { createPortal } from "preact/compat";
+import type { ComponentChildren } from "preact";
+
+type PortalProps = {
+ into?: string | HTMLElement;
+ children: ComponentChildren;
+};
+
+export function Portal({ into = "body", children }: PortalProps) {
+ const [host, setHost] = useState(null);
+
+ useEffect(() => {
+ if (typeof document === "undefined") return;
+
+ let target: HTMLElement | null = null;
+ if (typeof into === "string") {
+ target = into === "body" ? document.body : document.querySelector(into);
+ } else {
+ target = into;
+ }
+
+ if (!target) target = document.body;
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "preact-portal-root";
+ target.appendChild(wrapper);
+ setHost(wrapper);
+
+ return () => {
+ if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
+ setHost(null);
+ };
+ }, [into]);
+
+ if (!host) return null;
+ return createPortal(children, host);
+}