Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fc9515c0a | |||
| 2d945f6015 | |||
| 20321ece21 | |||
| c116b545fe | |||
| d05f58b67c | |||
| 02d1c4b784 | |||
| 3e24c3936a |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -15,7 +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 $ProjectModal from "./islands/ProjectModal.tsx";
|
||||
import { type Manifest } from "$fresh/server.ts";
|
||||
|
||||
const manifest = {
|
||||
@@ -35,7 +38,10 @@ const manifest = {
|
||||
},
|
||||
islands: {
|
||||
"./islands/Counter.tsx": $Counter,
|
||||
"./islands/ImageCarousel.tsx": $ImageCarousel,
|
||||
"./islands/Portal.tsx": $Portal,
|
||||
"./islands/ProjectCard.tsx": $ProjectCard,
|
||||
"./islands/ProjectModal.tsx": $ProjectModal,
|
||||
},
|
||||
baseUrl: import.meta.url,
|
||||
} satisfies Manifest;
|
||||
|
||||
82
frontend/islands/ImageCarousel.tsx
Normal file
82
frontend/islands/ImageCarousel.tsx
Normal 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>;
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -1,4 +1,23 @@
|
||||
import { useState } from "preact/hooks";
|
||||
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 ${
|
||||
@@ -13,25 +32,26 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
|
||||
}
|
||||
: {}
|
||||
}
|
||||
onClick={() => props.repo && open(props.repo, "_blank")}
|
||||
onClick={() => {
|
||||
// clicking the card (not the link) opens the modal
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<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}
|
||||
</a>
|
||||
</h2>
|
||||
<div class="bg-[#585b70] text-[#a6adc8] text-xs font-bold uppercase px-2.5 py-0.5 rounded-full">
|
||||
{props.repo && (
|
||||
<a
|
||||
class="hover:underline"
|
||||
href={props.repo}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Active
|
||||
</a>
|
||||
)}
|
||||
{props.repo && <span>Active</span>}
|
||||
{!props.repo && !props.wip && <span>Dead</span>}
|
||||
{props.wip && <span>WIP</span>}
|
||||
</div>
|
||||
@@ -42,6 +62,28 @@ export const ProjectCard = function ProjectCard(props: ProjectProps) {
|
||||
<p class="whitespace-pre-wrap text-sm font-semibold text-[#a6adc8]">
|
||||
{props.tech}
|
||||
</p>
|
||||
|
||||
{open && !props.wip ? (
|
||||
<Portal into="body">
|
||||
<ProjectModal
|
||||
title={props.title}
|
||||
onClose={() => setOpen(false)}
|
||||
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.description}
|
||||
</p>
|
||||
<p class="text-xs font-mono text-gray-600 dark:text-gray-300">
|
||||
Technologies used: {props.tech}
|
||||
</p>
|
||||
</div>
|
||||
</ProjectModal>
|
||||
</Portal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -50,6 +92,8 @@ type ProjectProps = {
|
||||
title: string;
|
||||
repo?: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
tech: string;
|
||||
wip?: boolean;
|
||||
images?: string[];
|
||||
};
|
||||
|
||||
160
frontend/islands/ProjectModal.tsx
Normal file
160
frontend/islands/ProjectModal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import * as hi from "jsr:@preact-icons/hi2";
|
||||
|
||||
export type ModalAction = {
|
||||
label: string;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
icon?: any;
|
||||
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 const ProjectModal = function ProjectModal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
ariaLabel,
|
||||
actions,
|
||||
animationDurationMs = 200,
|
||||
}: ModalProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const closingRef = useRef(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
}, []);
|
||||
|
||||
const footerActions: ModalAction[] =
|
||||
actions && actions.length > 0
|
||||
? [
|
||||
...actions,
|
||||
{
|
||||
label: "Close",
|
||||
icon: <hi.HiXCircle />,
|
||||
onClick: startCloseFlow,
|
||||
variant: "secondary",
|
||||
},
|
||||
]
|
||||
: [{ label: "Close", onClick: startCloseFlow, variant: "primary" }];
|
||||
|
||||
function startCloseFlow() {
|
||||
if (closingRef.current) return;
|
||||
closingRef.current = true;
|
||||
setIsVisible(false);
|
||||
timeoutRef.current = globalThis.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onClose();
|
||||
}, animationDurationMs);
|
||||
}
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
const renderActionButton = (action: ModalAction) => {
|
||||
const base =
|
||||
"flex-1 w-full px-4 py-2 rounded-md font-semibold focus:outline-none flex items-center justify-center gap-2";
|
||||
const styles =
|
||||
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 border-b-4 border-b-[#94e2d5] shadow-xl";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
class={`${base} ${styles}`}
|
||||
type="button"
|
||||
>
|
||||
{action.icon} {action.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"}
|
||||
>
|
||||
<div
|
||||
style={{ transitionDuration: `${animationDurationMs}ms` }}
|
||||
class={`${backdropBase} ${
|
||||
isVisible ? backdropVisible : backdropHidden
|
||||
}`}
|
||||
/>
|
||||
<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"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user