Capturing aerial imagery through drone flights, showcasing landscapes & cityscapes, and unique perspectives from above.
- Features
- Technologies Used
- Prerequisites
- Environment Variables
- Installation
- Usage / Scripts
- Media Upload Folder Layout
- Contributing
- License
- Contact
- Acknowledgments
- 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
- 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
- MongoDB Atlas cluster + database with a collection named
abstractaltitudes - AWS S3 bucket (and IAM keys with
PutObject,DeleteObject,ListBucket) - Mapbox account (https://mapbox.com) – enable Geocoding API
- Google Cloud account – enable Maps JavaScript API (key reserved for future use)
- Node ≥ 26 & pnpm ≥ 11
- Docker Desktop (only if you run the containerised version)
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.
INPUT_DIRECTORYis where the uploader scans for new drone media uploads (original RAW, TIFF, panoramas, etc).OUTPUT_DIRECTORYis where your conversion script exports high-quality JPEGs from TIFF or panorama sources for downstream use.ARCHIVE_DIRECTORYis 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.
git clone https://github.com/wrangel/abstractaltitudes.git
cd abstractaltitudes
pnpm installCreate the two env-files in the root (see table above) and fill in the values.
| 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 |
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 dbEach 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).
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 resultRun 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:refreshplus 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=0is 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.
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.
- Fork the repo
git checkout -b feature/YourFeature- Commit & push
- Open a pull request
MIT – see LICENSE
GitHub: @wrangel
Email: contact@abstractaltitudes.anonaddy.com
Thanks to Perplexity.ai, kimi.com, Microsoft Copilot, and Marius Hosting for making this possible.