added project modal, portal, and backend code to get description
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user