wip: added project modal
still want to add carousel, summary, etc.
This commit is contained in:
@@ -16,6 +16,8 @@ import * as $rss_index from "./routes/rss/index.tsx";
|
|||||||
import * as $sitemap_index from "./routes/sitemap/index.tsx";
|
import * as $sitemap_index from "./routes/sitemap/index.tsx";
|
||||||
import * as $Counter from "./islands/Counter.tsx";
|
import * as $Counter from "./islands/Counter.tsx";
|
||||||
import * as $ProjectCard from "./islands/ProjectCard.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";
|
import { type Manifest } from "$fresh/server.ts";
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
@@ -36,6 +38,8 @@ const manifest = {
|
|||||||
islands: {
|
islands: {
|
||||||
"./islands/Counter.tsx": $Counter,
|
"./islands/Counter.tsx": $Counter,
|
||||||
"./islands/ProjectCard.tsx": $ProjectCard,
|
"./islands/ProjectCard.tsx": $ProjectCard,
|
||||||
|
"./islands/modal.tsx": $modal,
|
||||||
|
"./islands/portal.tsx": $portal,
|
||||||
},
|
},
|
||||||
baseUrl: import.meta.url,
|
baseUrl: import.meta.url,
|
||||||
} satisfies Manifest;
|
} satisfies Manifest;
|
||||||
|
|||||||
@@ -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) {
|
export const ProjectCard = function ProjectCard(props: ProjectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`md:m-8 group space-y-1 rounded-md ${
|
class={`md:m-8 group space-y-1 rounded-md ${
|
||||||
@@ -13,25 +19,27 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
onClick={() => props.repo && open(props.repo, "_blank")}
|
onClick={() => {
|
||||||
|
// clicking the card (not the link) opens the modal
|
||||||
|
console.log("opened portal");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg text-white font-black uppercase">
|
<h2 class="text-lg text-white font-black uppercase">
|
||||||
<a href={props.repo} target="_blank">
|
<a
|
||||||
|
href={props.repo}
|
||||||
|
target="_blank"
|
||||||
|
onClick={(e) => {
|
||||||
|
// clicking the link should not open the modal
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{props.title}
|
{props.title}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="bg-[#585b70] text-[#a6adc8] text-xs font-bold uppercase px-2.5 py-0.5 rounded-full">
|
<div class="bg-[#585b70] text-[#a6adc8] text-xs font-bold uppercase px-2.5 py-0.5 rounded-full">
|
||||||
{props.repo && (
|
{props.repo && <span>Active</span>}
|
||||||
<a
|
|
||||||
class="hover:underline"
|
|
||||||
href={props.repo}
|
|
||||||
target="_blank"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{!props.repo && !props.wip && <span>Dead</span>}
|
{!props.repo && !props.wip && <span>Dead</span>}
|
||||||
{props.wip && <span>WIP</span>}
|
{props.wip && <span>WIP</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +50,38 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
|
|||||||
<p class="whitespace-pre-wrap text-sm font-semibold text-[#a6adc8]">
|
<p class="whitespace-pre-wrap text-sm font-semibold text-[#a6adc8]">
|
||||||
{props.tech}
|
{props.tech}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{open && !props.wip ? (
|
||||||
|
<Portal into="body">
|
||||||
|
<Modal
|
||||||
|
title={props.title}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Open repository",
|
||||||
|
onClick: () => {
|
||||||
|
if (props.repo) window.open(props.repo, "_blank");
|
||||||
|
},
|
||||||
|
variant: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Close",
|
||||||
|
onClick: () => setOpen(false),
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-800 dark:text-gray-200">
|
||||||
|
{props.summary}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-mono text-gray-600 dark:text-gray-300">
|
||||||
|
Technologies used: {props.tech}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
158
frontend/islands/modal.tsx
Normal file
158
frontend/islands/modal.tsx
Normal file
@@ -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<number | null>(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 (
|
||||||
|
<button key={act.label} onClick={act.onClick} class={`${base} ${styles}`}>
|
||||||
|
{act.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel ?? title ?? "Modal dialog"}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
// inline style for transitionDuration to keep JS timeout and CSS synced
|
||||||
|
style={{ transitionDuration: `${animationDurationMs}ms` }}
|
||||||
|
class={`${backdropBase} ${isVisible ? backdropVisible : backdropHidden}`}
|
||||||
|
onClick={startCloseFlow}
|
||||||
|
/>
|
||||||
|
{/* Modal panel */}
|
||||||
|
<div
|
||||||
|
style={{ transitionDuration: `${animationDurationMs}ms` }}
|
||||||
|
class={`${panelBase} ${isVisible ? panelVisible : panelHidden}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={startCloseFlow}
|
||||||
|
aria-label="Close modal"
|
||||||
|
class="ml-4 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 p-1"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex gap-3 w-full">
|
||||||
|
{footerActions.map(renderActionButton)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/islands/portal.tsx
Normal file
38
frontend/islands/portal.tsx
Normal file
@@ -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<HTMLElement | null>(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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user