Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
version: 2
updates:
# Python dependencies. Dependabot reads pyproject.toml + poetry.lock via the
# "pip" ecosystem and opens PRs when newer versions are available.
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
# Batch low-risk updates into one PR to cut review noise.
minor-and-patch:
update-types:
- "minor"
- "patch"
# Major version bumps are left ungrouped so each gets its own reviewable PR.

# Keep the GitHub Actions used by CI current (also a supply-chain surface).
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install Poetry
run: pipx install poetry
- name: Install dependencies
run: poetry install
- name: Run tests
run: poetry run pytest -q

security-audit:
runs-on: ubuntu-latest
# Informational: surfaces newly disclosed CVEs without blocking the build.
# Dependabot opens the fix PRs. Flip this to false to make audits blocking.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install Poetry
run: pipx install poetry
- name: Install dependencies
run: poetry install
- name: Audit dependencies
run: |
poetry run pip install pip-audit
poetry run pip-audit
4 changes: 2 additions & 2 deletions backend/app/api/homepage.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ async def index(
db.query(Project).filter(Project.featured.is_(False)).all()
)
context = {
"request": request,
"featured_project": featured_project,
"projects": projects,
}
# Starlette 1.x requires the request as the first positional argument.
return templates.TemplateResponse(
"homepage/index.html", context
request, "homepage/index.html", context
)
2 changes: 1 addition & 1 deletion backend/app/database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pathlib import Path
from sqlalchemy import Engine, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker

BASE_DIR: Path = Path(__file__).resolve().parents[2]
Expand Down
12 changes: 0 additions & 12 deletions backend/app/models/contact_message.py

This file was deleted.

25 changes: 25 additions & 0 deletions frontend/static/css/contact.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
/* Contact Form Styles */

/* Airtable embed container — matches the project card styling */
.airtable-embed-wrap {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 16px rgba(36, 38, 15, 0.08);
background: var(--white);
line-height: 0;
}

.airtable-embed-wrap #airtable-form {
display: block;
width: 100%;
/* Narrow/tablet widths stack the name fields, so the form needs ~746px.
Sized to fit the whole form and remove the inner scrollbar. */
height: 780px;
border: 0;
}

/* On wide screens the name fields sit side by side, so the form is shorter. */
@media (min-width: 1200px) {
.airtable-embed-wrap #airtable-form {
height: 700px;
}
}

.contact-form .single-form .input-form .primary-btn {
background: var(--primary);
color: var(--white);
Expand Down
57 changes: 43 additions & 14 deletions frontend/static/css/projects.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,17 @@

/* Featured Project Hero */
.featured-hero {
display: flex;
gap: 40px;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 40px;
margin-bottom: 48px;
box-shadow: 0 2px 16px rgba(36, 38, 15, 0.08);
}

.featured-content {
flex: 1;
min-width: 0;
.featured-hero::after {
content: "";
display: table;
clear: both;
}

.featured-badge {
Expand Down Expand Up @@ -80,6 +78,7 @@
color: #444;
line-height: 1.6;
margin-bottom: 16px;
white-space: pre-line;
}

.featured-tech {
Expand Down Expand Up @@ -124,13 +123,31 @@
}

.featured-image {
flex: 0 0 380px;
float: right;
width: 380px;
max-width: 380px;
margin: 0 0 24px 40px;
cursor: pointer;
}

.featured-image:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 8px;
}

.featured-image img {
width: 100%;
height: auto;
display: block;
/* Floated beside the text the image sits proud of the container edge, so
its corners need softening; the stacked layout below resets this. */
border-radius: 8px;
transition: transform 0.3s ease;
}

.featured-image:hover img {
transform: scale(1.02);
}

/* Other Projects */
Expand Down Expand Up @@ -196,6 +213,7 @@
color: #666;
line-height: 1.5;
margin-bottom: 12px;
white-space: pre-line;
}

.project-card-tech {
Expand Down Expand Up @@ -288,12 +306,7 @@
/* Responsive */
@media (max-width: 768px) {
.featured-hero {
flex-direction: column;
padding: 24px;
gap: 24px;
}

.featured-content {
text-align: center;
}

Expand All @@ -308,9 +321,14 @@
}

.featured-image {
flex: none;
float: none;
width: 100%;
max-width: 100%;
order: -1;
margin: 0 0 20px;
}

.featured-image img {
border-radius: 0;
}

.featured-title {
Expand All @@ -321,3 +339,14 @@
grid-template-columns: 1fr;
}
}

/* Accessibility */
@media (prefers-reduced-motion: reduce) {
.featured-image img {
transition: none;
}

.featured-image:hover img {
transform: none;
}
}
Binary file added frontend/static/images/fnd/fnd_screenshot.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions frontend/static/js/projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Featured Project — open the hero image in the shared lightbox
document.addEventListener("DOMContentLoaded", function () {
var trigger = document.querySelector(".featured-image");
var overlay = document.getElementById("lightboxOverlay");
if (!trigger || !overlay) return;

var img = trigger.querySelector("img");
if (!img) return;

var lightboxImage = document.getElementById("lightboxImage");
var lightboxCaption = document.getElementById("lightboxCaption");

function openLightbox() {
lightboxImage.src = img.src;
lightboxImage.alt = img.alt;
lightboxCaption.textContent = img.alt;
// Single image — clearing has-gallery keeps the prev/next arrows hidden
overlay.classList.remove("has-gallery");
overlay.classList.add("active");
}

trigger.addEventListener("click", openLightbox);

trigger.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openLightbox();
}
});

// timeline.js owns close/nav on the shared overlay, but it bails early when
// there is no timeline on the page — cover closing ourselves in that case so
// the overlay can never trap the viewer.
if (document.getElementById("timelineSwiper")) return;

function closeLightbox() {
overlay.classList.remove("active", "has-gallery");
}

var lightboxClose = document.getElementById("lightboxClose");
if (lightboxClose) {
lightboxClose.addEventListener("click", closeLightbox);
}

overlay.addEventListener("click", function (e) {
if (e.target === overlay) closeLightbox();
});

document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeLightbox();
});
});
3 changes: 3 additions & 0 deletions frontend/static/js/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ document.addEventListener("DOMContentLoaded", function () {
});

function navigateGallery(direction) {
// Only multi-image cards are navigable — guard against stale gallery
// state when the shared lightbox is opened by another section.
if (!overlay.classList.contains("has-gallery")) return;
if (galleryImages.length < 2) return;
var next = (galleryIndex + direction + galleryImages.length) % galleryImages.length;
showGalleryImage(next);
Expand Down
9 changes: 5 additions & 4 deletions frontend/templates/components/contact.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
<div class="section-title text-uppercase mb-5 top-margin7">
<h2 class="section-title">Get in touch</h2>
</div>
<iframe id="airtable-form" class="airtable-embed"
src="https://airtable.com/embed/appnqCCbKQac7WyI7/pagPtFDO5EnQ4wNTQ/form"
frameborder="0" loading="lazy" width="100%" height="610"
style="background: transparent; border: 1px solid #D7E9E3;"></iframe>
<div class="airtable-embed-wrap">
<iframe id="airtable-form" class="airtable-embed"
src="https://airtable.com/embed/appnqCCbKQac7WyI7/pagPtFDO5EnQ4wNTQ/form"
frameborder="0" loading="lazy" width="100%" height="780"></iframe>
</div>
</div>
</div>
</div>
Expand Down
10 changes: 10 additions & 0 deletions frontend/templates/components/lightbox.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!-- Shared Lightbox Component — used by the journey timeline and the featured project -->
<div class="lightbox-overlay" id="lightboxOverlay">
<button class="lightbox-close" id="lightboxClose" aria-label="Close lightbox">&times;</button>
<button class="lightbox-nav lightbox-prev" id="lightboxPrev" aria-label="Previous image"><i
class="fas fa-chevron-left"></i></button>
<img class="lightbox-image" id="lightboxImage" src="" alt="">
<button class="lightbox-nav lightbox-next" id="lightboxNext" aria-label="Next image"><i
class="fas fa-chevron-right"></i></button>
<p class="lightbox-caption" id="lightboxCaption"></p>
</div>
39 changes: 20 additions & 19 deletions frontend/templates/components/projects.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,29 @@ <h2 class="section-title text-uppercase projects-heading">Projects</h2>
{% if featured_project %}
<!-- Featured Project Hero -->
<div class="featured-hero">
<div class="featured-content">
<span class="featured-badge">Featured Project</span>
<h3 class="featured-title">{{ featured_project.title }}</h3>
<p class="featured-desc">{{ featured_project.description }}</p>
<span class="featured-badge">Featured Project</span>
<h3 class="featured-title">{{ featured_project.title }}</h3>

{% if featured_project.tech_stack %}
<div class="featured-tech">
{% for tech in featured_project.tech_stack.split(',') %}
<span class="tech-badge">{{ tech.strip() }}</span>
{% endfor %}
</div>
{% endif %}

{% if featured_project.link %}
<a href="{{ featured_project.link }}" class="featured-cta" target="_blank">
View Project <i class="fas fa-arrow-right"></i>
</a>
{% endif %}
</div>
<div class="featured-image">
<div class="featured-image" role="button" tabindex="0"
aria-label="View larger image of {{ featured_project.title }}">
<img src="{{ featured_project.image_path }}" alt="{{ featured_project.title }}" loading="lazy">
</div>

<p class="featured-desc">{{ featured_project.description }}</p>

{% if featured_project.tech_stack %}
<div class="featured-tech">
{% for tech in featured_project.tech_stack.split(',') %}
<span class="tech-badge">{{ tech.strip() }}</span>
{% endfor %}
</div>
{% endif %}

{% if featured_project.link %}
<a href="{{ featured_project.link }}" class="featured-cta" target="_blank">
View Project <i class="fas fa-arrow-right"></i>
</a>
{% endif %}
</div>
{% endif %}

Expand Down
Loading
Loading