diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ce58a0d --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..08c3b7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/backend/app/api/homepage.py b/backend/app/api/homepage.py index 21e12d4..902b1df 100644 --- a/backend/app/api/homepage.py +++ b/backend/app/api/homepage.py @@ -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 ) diff --git a/backend/app/database.py b/backend/app/database.py index 1ae73f7..a64bcf6 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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] diff --git a/backend/app/models/contact_message.py b/backend/app/models/contact_message.py deleted file mode 100644 index 79d1b38..0000000 --- a/backend/app/models/contact_message.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, func -from app.database import Base - - -class ContactMessage(Base): - __tablename__: str = "contact_messages" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(100), nullable=False) - email = Column(String(100), nullable=False) - message = Column(Text, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/frontend/static/css/contact.css b/frontend/static/css/contact.css index 8effd48..f9d6b62 100644 --- a/frontend/static/css/contact.css +++ b/frontend/static/css/contact.css @@ -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); diff --git a/frontend/static/css/projects.css b/frontend/static/css/projects.css index dda3647..564a36c 100644 --- a/frontend/static/css/projects.css +++ b/frontend/static/css/projects.css @@ -37,9 +37,6 @@ /* Featured Project Hero */ .featured-hero { - display: flex; - gap: 40px; - align-items: center; background: #fff; border-radius: 12px; padding: 40px; @@ -47,9 +44,10 @@ 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 { @@ -80,6 +78,7 @@ color: #444; line-height: 1.6; margin-bottom: 16px; + white-space: pre-line; } .featured-tech { @@ -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 */ @@ -196,6 +213,7 @@ color: #666; line-height: 1.5; margin-bottom: 12px; + white-space: pre-line; } .project-card-tech { @@ -288,12 +306,7 @@ /* Responsive */ @media (max-width: 768px) { .featured-hero { - flex-direction: column; padding: 24px; - gap: 24px; - } - - .featured-content { text-align: center; } @@ -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 { @@ -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; + } +} diff --git a/frontend/static/images/fnd/fnd_screenshot.jpeg b/frontend/static/images/fnd/fnd_screenshot.jpeg new file mode 100644 index 0000000..76a5d63 Binary files /dev/null and b/frontend/static/images/fnd/fnd_screenshot.jpeg differ diff --git a/frontend/static/js/projects.js b/frontend/static/js/projects.js new file mode 100644 index 0000000..e92cc8f --- /dev/null +++ b/frontend/static/js/projects.js @@ -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(); + }); +}); diff --git a/frontend/static/js/timeline.js b/frontend/static/js/timeline.js index 8bcaa0c..cad2e51 100644 --- a/frontend/static/js/timeline.js +++ b/frontend/static/js/timeline.js @@ -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); diff --git a/frontend/templates/components/contact.html b/frontend/templates/components/contact.html index b15b1dc..d7a5237 100644 --- a/frontend/templates/components/contact.html +++ b/frontend/templates/components/contact.html @@ -6,10 +6,11 @@
{{ featured_project.description }}
+ Featured Project +{{ featured_project.description }}
+ + {% if featured_project.tech_stack %} +