6 Commits

Author SHA1 Message Date
5fc9515c0a Merge branch 'post-card-refactor' of https://scm.wyattjmiller.com/wymiller/my-website-v2 into post-card-refactor
Some checks failed
Build and Release Docker Images / build-and-push (./backend, public/Dockerfile, my-website-v2_public) (pull_request) Has been cancelled
Build and Release Docker Images / build-and-push (./backend, task/Dockerfile, my-website-v2_task) (pull_request) Has been cancelled
Build and Release Docker Images / build-and-push (./frontend, Dockerfile, my-website-v2_frontend) (pull_request) Has been cancelled
Build and Release Docker Images / create-release (pull_request) Has been cancelled
2026-01-13 00:15:10 -05:00
2d945f6015 added ImageCarousel component, added icons to action buttons, modified project route 2026-01-13 00:15:08 -05:00
20321ece21 added images to project route, datasource 2026-01-13 00:14:31 -05:00
c116b545fe update frontend dockerfile
Some checks failed
Build and Release Docker Images / build-and-push (./backend, public/Dockerfile, my-website-v2_public) (pull_request) Failing after 21m29s
Build and Release Docker Images / build-and-push (./backend, task/Dockerfile, my-website-v2_task) (pull_request) Failing after 27m31s
Build and Release Docker Images / build-and-push (./frontend, Dockerfile, my-website-v2_frontend) (pull_request) Failing after 20m20s
Build and Release Docker Images / create-release (pull_request) Has been skipped
2025-12-25 13:02:10 -05:00
d05f58b67c added nix-ld env vars to allow lsps to work
Some checks failed
Build and Release Docker Images / build-and-push (./backend, public/Dockerfile, my-website-v2_public) (pull_request) Failing after 23m22s
Build and Release Docker Images / build-and-push (./backend, task/Dockerfile, my-website-v2_task) (pull_request) Failing after 28m11s
Build and Release Docker Images / build-and-push (./frontend, Dockerfile, my-website-v2_frontend) (pull_request) Failing after 19m58s
Build and Release Docker Images / create-release (pull_request) Has been cancelled
cause they are precompiled to work on non-nixos systems (with a linker)
2025-12-21 15:20:46 -05:00
02d1c4b784 added project modal, portal, and backend code to get description 2025-12-21 15:19:30 -05:00
10 changed files with 167 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
use sqlx::{FromRow, Pool, Postgres, Row};
use sqlx::{Pool, Postgres};
use crate::routes::projects::Project;
@@ -7,7 +7,7 @@ impl ProjectsDatasource {
pub async fn get_all(pool: &Pool<Postgres>) -> Result<Vec<Project>, sqlx::Error> {
sqlx::query_as!(
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 p.project_id, title, repo, summary, description, tech, wip, p.created_at, COALESCE(array_agg(pi.url) FILTER (WHERE pi.url IS NOT NULL), '{}') as images FROM projects p LEFT JOIN project_images pi ON p.project_id = pi.project_id AND pi.deleted_at IS NULL WHERE p.deleted_at IS NULL GROUP BY p.project_id, p.title, p.repo, p.summary, p.description, p.tech, p.wip, p.created_at ORDER BY p.created_at DESC"
)
.fetch_all(pool)
.await

View File

@@ -9,11 +9,13 @@ pub struct Project {
pub title: String,
pub repo: Option<String>,
pub summary: String,
pub description: Option<String>,
pub tech: String,
pub wip: Option<bool>,
#[serde(serialize_with = "serialize_datetime")]
#[serde(deserialize_with = "deserialize_datetime")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub images: Option<Vec<String>>,
}
pub struct ProjectsRoute;
@@ -66,5 +68,3 @@ impl ProjectsRoute {
}
}
}

View File

@@ -76,8 +76,10 @@
RUST_BACKTRACE = "1";
RUST_SRC_PATH = "${pkgs.rustToolchain}/lib/rustlib/src/rust/library";
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
# ZELLIJ_CONFIG_FILE = "config.kdl";
# PATH = "$PATH:$HOME/.local/share/nvim/mason/bin/deno";
NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.stdenv.cc.cc
];
NIX_LD = pkgs.lib.fileContents "${pkgs.stdenv.cc}/nix-support/dynamic-linker";
};
};
});

View File

@@ -4,10 +4,10 @@ RUN apk add bash
# USER deno
RUN deno cache --reload deno.json
COPY . .
RUN deno cache --reload deno.json
RUN bash -c 'deno cache main.ts'
RUN bash -c 'deno task build'

View File

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

View File

@@ -0,0 +1,82 @@
import { useState } from "preact/hooks";
export const ImageCarousel = function ImageCarousel(props: ImageCarouselProps) {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const nextImage = (e: Event) => {
e.stopPropagation();
if (props.images && props.images?.length > 0) {
const localImage = props.images;
setCurrentImageIndex((prev) => (prev + 1) % localImage.length || 0);
}
};
const prevImage = (e: Event) => {
e.stopPropagation();
if (props.images && props.images.length > 0) {
const localImage = props.images;
setCurrentImageIndex(
(prev) => (prev - 1 + localImage.length) % localImage.length,
);
}
};
return (
<div class="relative rounded overflow-hidden bg-gray-100 dark:bg-gray-800">
<div class="relative w-full h-96">
{props.images.map((image, index) => (
<img
key={index}
src={image}
alt={`screenshot ${index + 1}`}
class={`absolute inset-0 w-full h-full object-contain transition-opacity duration-500 ${
index === currentImageIndex ? "opacity-100" : "opacity-0"
}`}
/>
))}
</div>
{props.images.length > 1 && (
<>
<button
type="button"
onClick={prevImage}
class="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-10 h-10 flex items-center justify-center transition-all"
aria-label="Previous image"
>
</button>
<button
type="button"
onClick={nextImage}
class="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full w-10 h-10 flex items-center justify-center transition-all"
aria-label="Next image"
>
</button>
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 bg-black/30 px-3 py-2 rounded-full">
{props.images.map((_, index) => (
<button
key={index}
type="button"
onClick={(e) => {
e.stopPropagation();
setCurrentImageIndex(index);
}}
class={`w-2.5 h-2.5 rounded-full transition-all ${
index === currentImageIndex
? "bg-white scale-125"
: "bg-white/50 hover:bg-white/75"
}`}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
</>
)}
</div>
);
};
type ImageCarouselProps = {
images: Array<string>;
};

View File

@@ -1,10 +1,23 @@
import { useState } from "preact/hooks";
import { Portal } from "./portal.tsx";
import { Modal } from "./modal.tsx";
import { Portal } from "./Portal.tsx";
import { type ModalAction, ProjectModal } from "./ProjectModal.tsx";
import { ImageCarousel } from "./ImageCarousel.tsx";
import * as hi from "@preact-icons/hi2";
export const ProjectCard = function ProjectCard(props: ProjectProps) {
const [open, setOpen] = useState(false);
const modalButtons: Array<ModalAction> = [
{
label: "Open repository",
icon: <hi.HiCodeBracket />,
onClick: () => {
if (props.repo) globalThis.open(props.repo, "_blank");
},
variant: "primary",
},
];
return (
<div
class={`md:m-8 group space-y-1 rounded-md ${
@@ -21,7 +34,6 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
}
onClick={() => {
// clicking the card (not the link) opens the modal
console.log("opened portal");
setOpen(true);
}}
>
@@ -53,33 +65,23 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
{open && !props.wip ? (
<Portal into="body">
<Modal
<ProjectModal
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",
},
]}
actions={modalButtons}
>
<div class="space-y-3">
{props.images && props.images.length > 0 && (
<ImageCarousel images={props.images} />
)}
<p class="text-sm text-gray-800 dark:text-gray-200">
{props.summary}
{props.description}
</p>
<p class="text-xs font-mono text-gray-600 dark:text-gray-300">
Technologies used: {props.tech}
</p>
</div>
</Modal>
</ProjectModal>
</Portal>
) : null}
</div>
@@ -90,6 +92,8 @@ type ProjectProps = {
title: string;
repo?: string;
summary: string;
description?: string;
tech: string;
wip?: boolean;
images?: string[];
};

View File

@@ -1,8 +1,11 @@
import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import * as hi from "jsr:@preact-icons/hi2";
type ModalAction = {
export type ModalAction = {
label: string;
// deno-lint-ignore no-explicit-any
icon?: any;
onClick: () => void;
variant?: "primary" | "secondary" | "link";
};
@@ -24,7 +27,7 @@ type ModalProps = {
animationDurationMs?: number;
};
export function Modal({
export const ProjectModal = function ProjectModal({
title,
onClose,
children,
@@ -32,15 +35,11 @@ export function Modal({
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) {
@@ -60,52 +59,56 @@ export function Modal({
// 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
? [
...actions,
{
label: "Close",
icon: <hi.HiXCircle />,
onClick: startCloseFlow,
variant: "secondary",
},
]
: [{ 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 = globalThis.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 renderActionButton = (action: ModalAction) => {
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 flex items-center justify-center gap-2";
const styles =
act.variant === "primary"
? "bg-[#94e2d5] text-black hover:brightness-95"
: act.variant === "link"
action.variant === "primary"
? "bg-[#94e2d5] text-black hover:brightness-95 border-b-4 border-b-[#364153] shadow-xl"
: action.variant === "link"
? "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 (
<button key={act.label} onClick={act.onClick} class={`${base} ${styles}`}>
{act.label}
<button
key={action.label}
onClick={action.onClick}
class={`${base} ${styles}`}
type="button"
>
{action.icon} {action.label}
</button>
);
};
@@ -117,14 +120,12 @@ export function Modal({
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}
class={`${backdropBase} ${
isVisible ? backdropVisible : backdropHidden
}`}
/>
{/* Modal panel */}
<div
style={{ transitionDuration: `${animationDurationMs}ms` }}
class={`${panelBase} ${isVisible ? panelVisible : panelHidden}`}
@@ -138,6 +139,7 @@ export function Modal({
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"
type="button"
>
</button>
@@ -155,4 +157,4 @@ export function Modal({
</div>
</div>
);
}
};

View File

@@ -6,26 +6,26 @@ interface ProjectData {
title: string;
repo?: string;
summary: string;
description?: string;
tech: string;
wip?: boolean;
created_at: string;
images: Array<string>;
}
export const handler: Handlers<ProjectData> = {
export const handler: Handlers<Array<ProjectData>> = {
async GET(_req: Request, ctx: FreshContext) {
const projectResult = await fetch(
`${Deno.env.get("BASE_URI_API")}/projects`,
);
const projectData = await projectResult.json();
return ctx.render({
projectData,
});
return ctx.render(projectData);
},
};
export default function Projects({ data }: PageProps<ProjectData>) {
const { projectData: projects } = data;
export default function Projects(props: PageProps<Array<ProjectData>>) {
const projects = props.data;
return (
<div class="space-y-12 px-10 py-8 sm:min-h-screen bg-[#313244]">
@@ -43,14 +43,19 @@ export default function Projects({ data }: PageProps<ProjectData>) {
challenges that keep me busy when I'm not doing "real work" stuff!
</p>
<div class="grid grid-cols-1 sm:grid-cols-2">
{projects.map((project: any) => {
{projects.map((project: ProjectData) => {
return (
<ProjectCard
title={project.title}
repo={project.repo ?? undefined}
summary={project.summary}
description={
project.description ??
"No description found. Perhaps you should check out the repository instead!"
}
tech={project.tech}
wip={project.wip ?? true}
images={project.images}
/>
);
})}