added project modal, portal, and backend code to get description

This commit is contained in:
2025-12-21 15:19:30 -05:00
parent 3e24c3936a
commit 02d1c4b784
7 changed files with 58 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
use sqlx::{FromRow, Pool, Postgres, Row}; use sqlx::{Pool, Postgres};
use crate::routes::projects::Project; use crate::routes::projects::Project;
@@ -7,7 +7,7 @@ impl ProjectsDatasource {
pub async fn get_all(pool: &Pool<Postgres>) -> Result<Vec<Project>, sqlx::Error> { pub async fn get_all(pool: &Pool<Postgres>) -> Result<Vec<Project>, sqlx::Error> {
sqlx::query_as!( sqlx::query_as!(
Project, Project,
"SELECT project_id, title, repo, summary, tech, wip, created_at FROM projects p WHERE deleted_at IS NULL ORDER BY p.created_at DESC" "SELECT project_id, title, repo, summary, description, tech, wip, created_at FROM projects p WHERE deleted_at IS NULL ORDER BY p.created_at DESC"
) )
.fetch_all(pool) .fetch_all(pool)
.await .await

View File

@@ -9,6 +9,7 @@ pub struct Project {
pub title: String, pub title: String,
pub repo: Option<String>, pub repo: Option<String>,
pub summary: String, pub summary: String,
pub description: Option<String>,
pub tech: String, pub tech: String,
pub wip: Option<bool>, pub wip: Option<bool>,
#[serde(serialize_with = "serialize_datetime")] #[serde(serialize_with = "serialize_datetime")]
@@ -66,5 +67,3 @@ impl ProjectsRoute {
} }
} }
} }

View File

@@ -15,9 +15,9 @@ import * as $projects_index from "./routes/projects/index.tsx";
import * as $rss_index from "./routes/rss/index.tsx"; 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 $Portal from "./islands/Portal.tsx";
import * as $ProjectCard from "./islands/ProjectCard.tsx"; import * as $ProjectCard from "./islands/ProjectCard.tsx";
import * as $modal from "./islands/modal.tsx"; import * as $ProjectModal from "./islands/ProjectModal.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 = {
@@ -37,9 +37,9 @@ const manifest = {
}, },
islands: { islands: {
"./islands/Counter.tsx": $Counter, "./islands/Counter.tsx": $Counter,
"./islands/Portal.tsx": $Portal,
"./islands/ProjectCard.tsx": $ProjectCard, "./islands/ProjectCard.tsx": $ProjectCard,
"./islands/modal.tsx": $modal, "./islands/ProjectModal.tsx": $ProjectModal,
"./islands/portal.tsx": $portal,
}, },
baseUrl: import.meta.url, baseUrl: import.meta.url,
} satisfies Manifest; } satisfies Manifest;

View File

@@ -1,10 +1,20 @@
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Portal } from "./portal.tsx"; import { Portal } from "./Portal.tsx";
import { Modal } from "./modal.tsx"; import { type ModalAction, ProjectModal } from "./ProjectModal.tsx";
export const ProjectCard = function ProjectCard(props: ProjectProps) { export const ProjectCard = function ProjectCard(props: ProjectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const modalButtons: Array<ModalAction> = [
{
label: "Open repository",
onClick: () => {
if (props.repo) globalThis.open(props.repo, "_blank");
},
variant: "primary",
},
];
return ( return (
<div <div
class={`md:m-8 group space-y-1 rounded-md ${ class={`md:m-8 group space-y-1 rounded-md ${
@@ -53,33 +63,20 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
{open && !props.wip ? ( {open && !props.wip ? (
<Portal into="body"> <Portal into="body">
<Modal <ProjectModal
title={props.title} title={props.title}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
actions={[ actions={modalButtons}
{
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"> <div class="space-y-3">
<p class="text-sm text-gray-800 dark:text-gray-200"> <p class="text-sm text-gray-800 dark:text-gray-200">
{props.summary} {props.description}
</p> </p>
<p class="text-xs font-mono text-gray-600 dark:text-gray-300"> <p class="text-xs font-mono text-gray-600 dark:text-gray-300">
Technologies used: {props.tech} Technologies used: {props.tech}
</p> </p>
</div> </div>
</Modal> </ProjectModal>
</Portal> </Portal>
) : null} ) : null}
</div> </div>
@@ -90,6 +87,7 @@ type ProjectProps = {
title: string; title: string;
repo?: string; repo?: string;
summary: string; summary: string;
description?: string;
tech: string; tech: string;
wip?: boolean; wip?: boolean;
}; };

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
type ModalAction = { export type ModalAction = {
label: string; label: string;
onClick: () => void; onClick: () => void;
variant?: "primary" | "secondary" | "link"; variant?: "primary" | "secondary" | "link";
@@ -24,7 +24,7 @@ type ModalProps = {
animationDurationMs?: number; animationDurationMs?: number;
}; };
export function Modal({ export const ProjectModal = function ProjectModal({
title, title,
onClose, onClose,
children, children,
@@ -32,15 +32,11 @@ export function Modal({
actions, actions,
animationDurationMs = 200, animationDurationMs = 200,
}: ModalProps) { }: ModalProps) {
// Controls the entrance/exit animation state. true => visible (enter), false => hidden (exit)
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
// Prevent double-triggering the close flow
const closingRef = useRef(false); const closingRef = useRef(false);
// Hold the timeout id for cleanup
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
// Defer to next frame so initial "hidden" styles are applied before animating to visible.
const raf = requestAnimationFrame(() => setIsVisible(true)); const raf = requestAnimationFrame(() => setIsVisible(true));
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -60,52 +56,55 @@ export function Modal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// If no actions provided, render a single Close button
const footerActions: ModalAction[] = const footerActions: ModalAction[] =
actions && actions.length > 0 actions && actions.length > 0
? actions ? [
...actions,
{
label: "Close",
onClick: startCloseFlow,
variant: "secondary",
},
]
: [{ label: "Close", onClick: startCloseFlow, variant: "primary" }]; : [{ label: "Close", onClick: startCloseFlow, variant: "primary" }];
// Start exit animation and call onClose after animationDurationMs
function startCloseFlow() { function startCloseFlow() {
if (closingRef.current) return; if (closingRef.current) return;
closingRef.current = true; closingRef.current = true;
setIsVisible(false); setIsVisible(false);
// Wait for the CSS transition to finish before signalling parent to actually unmount timeoutRef.current = globalThis.setTimeout(() => {
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null; timeoutRef.current = null;
onClose(); onClose();
}, animationDurationMs); }, animationDurationMs);
} }
// Animation classes (enter & exit):
// - panel: transitions opacity + transform for a subtle fade + pop
// - backdrop: transitions opacity for fade
const panelBase = 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"; "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 panelVisible = "opacity-100 translate-y-0 scale-100";
const panelHidden = "opacity-0 translate-y-2 scale-95"; const panelHidden = "opacity-0 translate-y-2 scale-95";
const backdropBase = const backdropBase =
"absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"; "absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity";
const backdropVisible = "opacity-100"; const backdropVisible = "opacity-100";
const backdropHidden = "opacity-0"; const backdropHidden = "opacity-0";
// Footer button class generator const renderActionButton = (action: ModalAction) => {
const renderActionButton = (act: ModalAction) => {
const base = const base =
"flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none"; "flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none";
const styles = const styles =
act.variant === "primary" action.variant === "primary"
? "bg-[#94e2d5] text-black hover:brightness-95" ? "bg-[#94e2d5] text-black hover:brightness-95 border-b-4 border-b-[#364153] shadow-xl"
: act.variant === "link" : action.variant === "link"
? "bg-transparent text-[#075985] underline" ? "bg-transparent text-[#075985] underline"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95"; : "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:brightness-95 border-b-4 border-b-[#94e2d5] shadow-xl";
return ( return (
<button key={act.label} onClick={act.onClick} class={`${base} ${styles}`}> <button
{act.label} key={action.label}
onClick={action.onClick}
class={`${base} ${styles}`}
type="button"
>
{action.label}
</button> </button>
); );
}; };
@@ -117,14 +116,12 @@ export function Modal({
role="dialog" role="dialog"
aria-label={ariaLabel ?? title ?? "Modal dialog"} aria-label={ariaLabel ?? title ?? "Modal dialog"}
> >
{/* Backdrop */}
<div <div
// inline style for transitionDuration to keep JS timeout and CSS synced
style={{ transitionDuration: `${animationDurationMs}ms` }} style={{ transitionDuration: `${animationDurationMs}ms` }}
class={`${backdropBase} ${isVisible ? backdropVisible : backdropHidden}`} class={`${backdropBase} ${
onClick={startCloseFlow} isVisible ? backdropVisible : backdropHidden
}`}
/> />
{/* Modal panel */}
<div <div
style={{ transitionDuration: `${animationDurationMs}ms` }} style={{ transitionDuration: `${animationDurationMs}ms` }}
class={`${panelBase} ${isVisible ? panelVisible : panelHidden}`} class={`${panelBase} ${isVisible ? panelVisible : panelHidden}`}
@@ -138,6 +135,7 @@ export function Modal({
onClick={startCloseFlow} onClick={startCloseFlow}
aria-label="Close modal" 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" class="ml-4 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 p-1"
type="button"
> >
</button> </button>
@@ -155,4 +153,4 @@ export function Modal({
</div> </div>
</div> </div>
); );
} };

View File

@@ -6,6 +6,7 @@ interface ProjectData {
title: string; title: string;
repo?: string; repo?: string;
summary: string; summary: string;
description?: string;
tech: string; tech: string;
wip?: boolean; wip?: boolean;
created_at: string; created_at: string;
@@ -43,12 +44,16 @@ export default function Projects({ data }: PageProps<ProjectData>) {
challenges that keep me busy when I'm not doing "real work" stuff! challenges that keep me busy when I'm not doing "real work" stuff!
</p> </p>
<div class="grid grid-cols-1 sm:grid-cols-2"> <div class="grid grid-cols-1 sm:grid-cols-2">
{projects.map((project: any) => { {projects.map((project: ProjectData) => {
return ( return (
<ProjectCard <ProjectCard
title={project.title} title={project.title}
repo={project.repo ?? undefined} repo={project.repo ?? undefined}
summary={project.summary} summary={project.summary}
description={
project.description ??
"No description found. Perhaps you should check out the repository instead!"
}
tech={project.tech} tech={project.tech}
wip={project.wip ?? true} wip={project.wip ?? true}
/> />