Skip to content

Repository files navigation

Abstract Altitudes

Capturing aerial imagery through drone flights, showcasing landscapes & cityscapes, and unique perspectives from above.

Table of Contents

Features

  • Image Gallery – high-resolution stills
  • Interactive Map – every shot geotagged
  • Pano Viewer – 360° spherical images via Marzipano
  • Responsive Design – desktop, tablet & mobile
  • PWA ready – installable offline app

Technologies Used

  • Frontend: React 19 + Vite
  • Backend: Node.js 26 + Express
  • DB: MongoDB Atlas (collection abstractaltitudes)
  • Object Storage: AWS S3 bucket (originals/archival)
  • CDN / Delivery: BunnyCDN (thumbnails, images, pano tiles)
  • Image Processing: Sharp
  • Reverse Geocoding: Mapbox
  • Containerisation: Docker / Docker Compose

Prerequisites

  1. MongoDB Atlas cluster + database with a collection named abstractaltitudes
  2. AWS S3 bucket (and IAM keys with PutObject, DeleteObject, ListBucket)
  3. Mapbox account (https://mapbox.com) – enable Geocoding API
  4. Google Cloud account – enable Maps JavaScript API (key reserved for future use)
  5. Node ≥ 26 & pnpm ≥ 11
  6. Docker Desktop (only if you run the containerised version)

Environment Variables

The table below shows every key you must supply.
Put them in .env (local dev) and env.production (build time) unless the Scope column says otherwise.

Variable Example / Hint Scope Purpose
MONGODB_SERVER host-00-00.xxx.mongodb.net:27017,host-00-01.xxx.mongodb.net:27017,host-00-02.xxx.mongodb.net:27017 both MongoDB Atlas replica-set hosts (comma-separated, with port)
MONGODB_DB abstractaltitudes both database name
MONGODB_DB_USER dbUser both Atlas DB user
MONGODB_DB_PASSWORD superSecretPw both Atlas DB password
AWS_BUCKET my-drone-assets both S3 bucket for originals & derivatives
AWS_ACCESS_KEY_ID AKIA… both IAM key with PutObject, DeleteObject, ListBucket
AWS_SECRET_ACCESS_KEY wJalrXUtnFEMI/K7MDENG/bPxRfiCY… both IAM secret
AWS_DEFAULT_REGION eu-central-1 both bucket region
MAPBOX_SECRET_TOKEN sk.eyJ1Ijoi…… prod only server-side reverse-geocoding
VITE_MAPBOX_PUBLIC_TOKEN pk.eyJ1Ijoi…… both browser-side tiles & fonts
VITE_GOOGLE_MAPS_API_KEY AIzaSyA…… both Google services (reserved)
VITE_API_URL http://localhost:8081/api dev only front-end → back-end route
VITE_API_URL /api prod only same route, root-relative
VITE_BUNNYCDN_BASE_URL https://yourzone.b-cdn.net both BunnyCDN pull zone base URL for tiles & thumbnails
CORS_ORIGINS https://abstractaltitudes.com both comma-separated allowed CORS origins
VITE_GOOGLE_MAPS_MAP_ID abc123def456 both Google Maps Map ID for styled map

The table below shows every key you must supply.
Put them in .env (local dev) only for the following new folder paths used in media management and archiving:

Variable Example / Hint Scope Purpose
INPUT_DIRECTORY /Users/me/DroneUploads dev only Base folder containing media folders to ingest and process.
OUTPUT_DIRECTORY /Users/me/DroneJPGs dev only Destination folder for converted high-res JPEG images.
ARCHIVE_DIRECTORY /Users/me/DroneArchive dev only Folder where processed media folders are moved after upload.

Note: These directories are used by the backend management scripts for organizing, converting, archiving, and uploading drone media content locally during ingestion before cloud upload. They must be properly configured on your local system but should not be defined in production environment files or build-time configs.


Why these variables matter

  • INPUT_DIRECTORY is where the uploader scans for new drone media uploads (original RAW, TIFF, panoramas, etc).
  • OUTPUT_DIRECTORY is where your conversion script exports high-quality JPEGs from TIFF or panorama sources for downstream use.
  • ARCHIVE_DIRECTORY is where processed media folders are moved after successful upload and conversion, preventing duplicate processing.

Add these entries with appropriate absolute paths to your .env file only, as they represent local file system paths used during development and batch ingestion.

> [!IMPORTANT]
> Never commit .env or env.production; they are already listed in .gitignore.

Installation

git clone https://github.com/wrangel/abstractaltitudes.git
cd abstractaltitudes
pnpm install

Create the two env-files in the root (see table above) and fill in the values.

Usage / Scripts

Command Purpose
pnpm dev Start backend + Vite frontend locally (no Docker)
pnpm test:unit Unit tests for src/shared/ (node:test, no framework)
pnpm test Build & run the full stack locally in Docker
pnpm prod Build images and push wrangel/abstractaltitudes-{frontend,backend}:2.1 to Docker Hub

Keeping things up to date

Two separate concerns, deliberately not one command:

What How
Project dependencies Renovate PRs (.github/renovate.json5) — CI runs the tests
Homebrew / this Mac ./scripts/update-mac.sh (dry run) · --apply to upgrade

pnpm dev -u used to do both. It deleted pnpm-lock.yaml and ran pnpm up --latest, which mattered because Dockerfile.* and CI install with --frozen-lockfile — so the lockfile decides what ships, and regenerating it unreviewed meant production got whatever was newest that morning. The flag now just prints where to go instead.

Renovate runs Monday mornings: minor and patch updates arrive as two grouped PRs, majors one PR each, GitHub Actions and Docker base images monthly. Security fixes skip the schedule. Only versions at least 3 days old are proposed, and Renovate merges the PR itself when test and build pass — except npm majors, which wait for you. The Dependency Dashboard issue lists what is pending and anything that failed, so check it if the PRs stop coming. CI does not run frontend:build (see the note in ci.yml), so a major bump to Vite, React or the viewers still wants a local pnpm test before merging.

Why not Dependabot: it supports pnpm 7–10, and on pnpm 12 every run failed without opening a PR. The same upgrade also hid our dependencies from GitHub's dependency graph, which is what pmOnFail: ignore in pnpm-workspace.yaml fixes, and CI checks that it stays fixed.

To change pnpm version, edit packageManager in package.json (Renovate proposes this too). The Dockerfiles read it from there. Don't use pnpm self-update: it rewrites that field as a side effect.

Management helpers:

pnpm manage keep-books   # sync DB ↔ S3 metadata
pnpm manage handle-media # ingest new imagery
pnpm manage:dry handle-media -n # ingest new imagery, but do not upload it neither to AWS nor Mongo db

Per-photo pages (SEO)

Each photo is addressable at /photo/<slug>/. Opening the viewer pushes that URL; Back closes it. Nothing unmounts — the URL and the React tree are independent, which is what keeps ViewerPanorama's WebGL context alive.

pnpm frontend:build runs scripts/prerender-photos.mjs after Vite. It fetches $VITE_API_URL/combined-data and writes a static build/photo/<slug>/index.html per photo, with title, description, Open Graph tags and ImageObject JSON-LD baked in, plus build/sitemap.xml. nginx's existing try_files serves them — no nginx or backend change involved.

It also writes /places/ hub pages — an index, one per country, and one per region — linked from the gallery footer so crawlers have a path down to the photo pages. These are standalone static documents that deliberately do not boot the SPA: React replaces #root on mount, which would discard any server-rendered markup placed there. A place needs at least MIN_PHOTOS_PER_PAGE (3) photos to get its own page, so thin doorway pages never get generated; grids cap at MAX_PHOTOS_PER_GRID (48), with full coverage guaranteed by the sitemap. Both knobs live in src/shared/placePages.mjs.

The social share card (og:image — what Slack, WhatsApp, LinkedIn and X show when the link is pasted; it never appears on the site itself) is regenerated on every build into build/og-image.jpg from the newest non-panorama photo. Panoramas are skipped because an equirectangular frame cropped to a 1.9:1 card shows a distorted middle band. Per-photo pages use their own photo instead, so this file only backs the site root and the /places/ hubs.

public/og-image.jpg is the committed fallback, used when a build cannot reach the API or sharp fails — the build warns and keeps it rather than shipping a broken og:image. Refresh that fallback with node scripts/generate-og-image.mjs [filter].

Dev note: /places/ pages are build artifacts. A Vite dev plugin (servePrerenderedPlaces in vite.config.mjs) serves them from build/ so the footer link works in pnpm dev too — but it shows whatever the last pnpm frontend:build produced, and 404s with instructions if you have not built yet. To exercise the real thing end to end, use pnpm exec vite preview --port 3001 (port 3001 is in CORS_ORIGINS; the default 4173 is not, so the API call would be blocked).

Where the build gets its data

data/photos.json is a committed snapshot of the fields the prerender needs. The build prefers the live API when it can reach it, and silently falls back to the snapshot when it cannot — so builds work offline, during an outage, and on a Synology whose router will not hairpin its own domain.

pnpm data:refresh   # re-fetch from $VITE_API_URL, then commit the result

Run that after adding photos, otherwise a build that cannot reach the API will prerender the previous set. The prerender warns when the snapshot is over 30 days old, and git diff makes staleness visible.

This snapshot exists because the build once fetched live and hard-failed: an API outage then blocked building the very fix for that outage, and even a local pnpm test stack depended on the production host being up.

Other things to know:

  • New photos need pnpm data:refresh plus a frontend rebuild to get their own page. Until then they still load and work, just with the generic site-wide metadata.
  • Deploy the backend before refreshing the snapshot. Slugs and place fields come from the API; against an older backend the refresh refuses rather than writing slugless data.
  • REQUIRE_PRERENDER=0 is an emergency opt-out (REQUIRE_PRERENDER=0 pnpm prod) that ships without photo/places pages. Only reachable if the snapshot is missing too.

Metadata defaults live between the <!-- seo:start --> / <!-- seo:end --> markers in index.html — that block is what gets replaced per photo, so keep anything the photo pages also need (favicons, manifest, site-level JSON-LD) outside it.

Media Upload Folder Layout

Place drone media folders inside INPUT_DIRECTORY ($INPUT_DIRECTORY). collectMetadata auto-detects type by file count/pattern:

your-ingest-folder/                    # INPUT_DIRECTORY
├── hdr-folder/                        # 5 JPGs + 1 TIFF → type: "hdr"
│   ├── 5x JPG files                   # original drone still
│   └── 1x TIFF file                   # 16-bit TIFF derived from RAW
│
├── wa-folder/                         # **NEW** 10 JPGs (1 DJI + 9 PANO) → type: "wide_angle"
│   ├── DJI_20260215154930_0022_D.JPG  # Main image → modified/wa_*.webp
│   ├── PANO_0001.JPG → PANO_0009.JPG  # 9x reference → original/
│
├── pano-folder/                       # Panorama → type: "pano", 25 JPGs
│   ├── DJI_0001.JPG                   # individual aerial shots
│   ├── DJI_0002.JPG
│   ├── pano-equirect.jpg              # stitched 360° equirectangular (e.g. from PTGui Pro)
│   ├── pano-equirect.pts              # stitched 360° equirectangular project file (e.g. from PTGui Pro)
│   └── project-title.zip              # Marzipano project exported from https://www.marzipano.net/tool/

Before running pnpm manage handle-media, ensure your INPUT_DIRECTORY contains folders with one of these exact structures:

Media Type File Count Structure Processing
hdr 6 files 5 JPG + 1 TIFF JPG→original/, TIFF→wa_*.webp
wide_angle 10 JPGs 1 DJI*.JPG + 9 PANO*.JPG 9 PANO→original/, DJI→wa_*.webp
pano 5+ files DJI JPGs + equirectangular + Marzipano ZIP Pano viewer files

The uploader walks these folders, uploads originals to S3, writes metadata to MongoDB and generates the multiple sizes required by the gallery.

Contributing

  1. Fork the repo
  2. git checkout -b feature/YourFeature
  3. Commit & push
  4. Open a pull request

License

MIT – see LICENSE

Contact

GitHub: @wrangel
Email: contact@abstractaltitudes.anonaddy.com

Acknowledgments

Thanks to Perplexity.ai, kimi.com, Microsoft Copilot, and Marius Hosting for making this possible.

About

Aerial drone photography portfolio. 360° panoramas and deep-zoom stills, served as a router-less React SPA with per-photo pages prerendered for search.

Topics

Resources

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages