Skip to content

Latest commit

 

History

History
281 lines (232 loc) · 14 KB

File metadata and controls

281 lines (232 loc) · 14 KB

Architecture

StudyMap is a Next.js 16 (App Router) app. The public map, calendar, and place data need no database and no auth - all place data ships as JSON, imported via studymap.config.ts at the repo root. Signing in (Supabase Auth) is optional and only gates two private features: saved custom places and personal calendar events, both backed by RLS-scoped Supabase tables under supabase/migrations/. This document is a map for new contributors.


Folder layout

studymap/
├── .github/
│   ├── DISCUSSION_TEMPLATE/
│   │   └── request-a-city.yml    # discussion template: request coverage for a new city
│   ├── ISSUE_TEMPLATE/
│   │   ├── add-place.yml         # issue form: suggest a new place
│   │   ├── bug_report.yml        # issue form: file a bug
│   │   ├── feature_request.yml   # issue form: request a feature
│   │   └── config.yml            # disables blank issues, adds contact links
│   ├── workflows/                # CI: lint+typecheck, build+validate-data
│   └── pull_request_template.md
├── data/
│   ├── CONTRIBUTING.md           # data-specific contribution rules
│   ├── places/                   # 9 JSON files, one per place type
│   └── places.sample/            # minimal placeholder dataset for forks (see SELF-HOSTING.md)
├── public/
│   ├── brand/                    # OG image (og.svg, og-preview.html)
│   ├── icons/                    # PWA icons: 192px + 512px, normal + maskable
│   ├── logo-dark.svg
│   ├── logo-light.svg
│   ├── logo-mark.svg
│   ├── favicon.svg
│   ├── manifest.webmanifest      # PWA manifest
│   └── sw.js                     # service worker (offline support)
├── src/
│   ├── app/                      # Next.js App Router pages and layouts
│   ├── components/               # React components, grouped by feature
│   └── lib/                      # pure TypeScript utilities (no JSX)
├── supabase/
│   └── migrations/                # SQL for the two optional, sign-in-gated tables (user_places,
│                                   #   user_home, user_events), run once by hand - see SELF-HOSTING.md
├── ARCHITECTURE.md
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE
├── README.md
├── SECURITY.md
├── SELF-HOSTING.md
├── components.json               # shadcn/ui component config
├── eslint.config.mjs
├── next.config.js
├── package.json
├── postcss.config.mjs
├── studymap.config.ts             # region + dataset config; the file a fork edits to retarget StudyMap
├── studymap.config.example.ts     # starter version of the above, pointing at data/places.sample/
├── tsconfig.json
└── vitest.config.ts

data/places/

One JSON file per place category (imported by studymap.config.ts at the repo root, not directly by src/lib/places.ts - see Data flow below). Each file is a flat array of place objects. This is the only place place data lives — nothing is duplicated elsewhere.

The record shape, valid type keys, and per-field rules are documented once in data/CONTRIBUTING.md and enforced by data/places.schema.json via npm run validate - that pair is the source of truth, not this file. The PlaceType union and Place interface in src/lib/types.ts mirror the same schema.

src/app/

Next.js App Router. Each folder is a route segment.

Route File Purpose
/ page.tsx Homepage: hero, feature grid, CTA
/map map/page.tsx Full interactive map
/calendar calendar/page.tsx Exam calendar (SAT, IB, IGCSE)
/docs docs/page.tsx Docs index linking to guides
/docs/contributing How to add a place via PR
/docs/exam-centres How to use the map for exam centres
/docs/github-student-pack GitHub Student Pack guide
/contribute contribute/page.tsx Contribution hub (add place / bug / question)
/about about/page.tsx Principles, stats, maintainers
/legal/* Privacy, terms, disclaimer
/offline offline/page.tsx PWA offline fallback
/login login/page.tsx Optional sign-in (Google OAuth, email)
/auth/callback auth/callback/route.ts OAuth code exchange (dynamic, not static-export-safe)

layout.tsx (root) wraps every page with Navbar, Footer, and the theme provider.

src/components/

Components are grouped by feature, not by type.

components/
├── home/
│   ├── hero.tsx            # landing hero with particle field
│   ├── hero-particles.tsx  # canvas particle animation
│   └── map-preview.tsx     # static map thumbnail on homepage
├── map/
│   ├── places-map.tsx        # top-level map container: filtering, URL state, geolocation,
│   │                         #   auth + saved-places/home state, merges the private pin layer
│   ├── map-view.tsx          # Leaflet map (clustering, tiles, scroll guard, user dot)
│   ├── map-panel.tsx         # search, category chips, city select, results list, my-places slot
│   ├── my-places-panel.tsx   # signed-in-only saved-places section: own search/city filter, CRUD
│   ├── user-place-dialog.tsx # add/edit/delete a saved place
│   ├── user-home-dialog.tsx  # set/edit/remove the saved home location
│   ├── suggest-place-dialog.tsx # public "suggest a place" form -> pre-filled GitHub issue link
│   ├── category-chips.tsx    # type filter chips with counts
│   ├── results-list.tsx      # scrollable place list, optionally distance-sorted
│   ├── map-sheet.tsx         # mobile bottom sheet (vaul) wrapping MapPanel
│   ├── near-me-button.tsx    # desktop geolocation trigger
│   ├── near-me-fab.tsx       # mobile floating geolocation trigger
│   ├── map-error-boundary.tsx
│   └── filters.ts            # `PlaceFilters` type
├── calendar/
│   └── personal-event-dialog.tsx  # add/edit/delete a signed-in user's calendar event
├── pins/
│   └── pin-popup.tsx       # popup on marker click (directions, share, added_by)
├── layout/
│   ├── navbar.tsx          # nav links + sign-in/sign-out
│   ├── footer.tsx
│   ├── page-container.tsx  # max-width wrapper used by most pages
│   ├── theme-provider.tsx
│   └── theme-toggle.tsx
├── legal/
│   └── legal-page.tsx      # shared shell for privacy/terms/disclaimer pages
├── ui/                     # shadcn/ui primitives (Badge, Button, Card, Checkbox,
│                           #   Dialog, DropdownMenu, Input, Label, Particles,
│                           #   Select, Separator, Sheet, Sonner, Switch, Table, Tabs)
├── analytics.tsx           # Umami analytics script (privacy-friendly, cookieless)
├── mdx-content.tsx         # MDX renderer used by docs pages
└── pwa-register.tsx        # service worker registration for offline / PWA support

src/app/calendar/CalendarView.tsx (a page-level component, not under components/) renders the public exam calendar and, when signed in, the personal-events overlay.

src/lib/

Pure TypeScript modules. No JSX, no React imports. Each file has a single responsibility.

File What it does
types.ts Place, PlaceType, City types; label maps
places.ts Reads places from studymap.config.ts; exports getPlaces() and filterPlaces()
geo.ts Haversine distance, placesByDistance(), formatDistance()
map.ts PLACE_TYPE_COLORS (color-blind-safe palette); directionsUrl()
share.ts URL state encode/decode for shareable filtered map links
site.ts Site name, tagline, repo URL, nav link list
exam-dates.ts EXAM_EVENTS array with SAT/IB/IGCSE session data
fonts.ts next/font setup (Inter, Space Grotesk, JetBrains Mono)
utils.ts cn() — clsx + tailwind-merge helper; isMissingTableError() — detects an unrun Supabase migration
user-places.ts CRUD for saved places + home location (user_places, user_home tables)
user-events.ts CRUD for personal calendar events (user_events table)
supabase/client.ts, supabase/server.ts Browser and server Supabase clients (@supabase/ssr)

types.ts, places.ts, geo.ts, map.ts, share.ts, exam-dates.ts have no Supabase dependency and no side effects, they're the easiest targets for new unit tests (see *.test.ts next to geo.ts, share.ts, and places.ts, run via npm run test:unit).


Data flow

studymap.config.ts         imports data/places/*.json, exports the merged Place[]
       │
       ▼
src/lib/places.ts          getPlaces() reads the config's Place[]
       │                   filterPlaces() narrows by type and/or city
       ▼
src/components/map/places-map.tsx
       │  reads URL params via share.ts → parseMapState()
       │  calls filterPlaces() on every filter change
       │  if signed in: fetches saved places/home (user-places.ts) and merges
       │  them into the Place[] passed down, on top of the public filters
       │
       ├──▶ src/components/map/map-panel.tsx      (search, chips, city select, my-places-panel)
       ├──▶ src/components/map/near-me-button.tsx  (geolocation → placesByDistance())
       │
       ▼
src/components/map/map-view.tsx
       │  clusters overlapping pins (Supercluster), coloured by PLACE_TYPE_COLORS
       │  on marker click: opens PinPopup
       │
       ▼
src/components/pins/pin-popup.tsx
          directionsUrl(lat, lng)  ←  src/lib/map.ts
          buildShareUrl(state)     ←  src/lib/share.ts

The public map makes no network requests at runtime beyond map tiles. Place JSON is bundled at build time via static import statements in studymap.config.ts. Signing in adds real runtime requests: Supabase Auth, plus user-places.ts/user-events.ts calls for the two private features.


State and data flow in places-map.tsx

The single most central component. It owns four independent pieces of state and merges them into one Place[] for the map:

State Source Notes
Filters (types, city, query) useState, seeded from the URL via parseMapState() query is debounced (250 ms typing, 0 ms clearing) into debouncedQuery before filterPlaces() runs
Focus (focusId) useState, seeded from the URL Set when a result row or pin is selected; collapses the mobile sheet
Geolocation (userLocation, sortByDistance) useState, set by the near-me button/FAB Drives placesByDistance() for "nearest to you" sorting
Auth (user, savedPlaces, home) Supabase onAuthStateChange listener Cleared synchronously during render (not in an effect) whenever user.id changes, so there's no stale-data flash between accounts

Filters and focus are mirrored back into the URL on every change (mapStateToSearch()), which is what makes map links shareable. Filters and geolocation each run through filterPlaces()/placesByDistance() independently; they don't affect each other.

Merge order for what actually reaches the map: filterPlaces(places, filters)visible, then visible is concatenated with savedPlaces (mapped through userPlaceToPlace()) → mapPlaces. Saved places always render regardless of the public filters — they're a private layer on top, not filtered by type/city/query. If a signed-in user's saved-places fetch fails because the Supabase table doesn't exist yet (self-hosted, migrations not run), isMissingTableError() catches it and shows a specific "table isn't set up, see SELF-HOSTING.md" message instead of a generic error.

Desktop and mobile share the same panelProps object (filters, results, my-places) — the only difference is which shell renders it: a fixed sidebar (<aside>) on lg: and up, or the MapSheet bottom drawer below that, toggled by sheetOpen/snap.


Key modules

src/lib/places.ts

The single aggregation point for place data. Every component that needs places calls getPlaces() here — nothing imports the JSON directly.

getPlaces(): Place[]
filterPlaces(places, { types?, cities? }): Place[]
getCities(places, preferredOrder?): City[]

Region and dataset settings (initial map center, default zoom, coordinate bounds, the city display order, and which data/places/*.json files get loaded) live in studymap.config.ts at the repo root, the one file a fork edits to retarget StudyMap.

src/lib/geo.ts

All distance math lives here. Used by places-map.tsx to find the five nearest places when the user enables geolocation.

haversineKm(a: LatLng, b: LatLng): number
placesByDistance(places, origin): PlaceWithDistance[]
formatDistance(km): string   // "450 m" | "3.2 km"

src/lib/map.ts

Visual and navigation constants for the map. Keeping colours here (not inside the component) means the filter panel swatches and the Leaflet markers stay in sync automatically.

PLACE_TYPE_COLORS: Record<PlaceType, string>  // one hex per type
directionsUrl(lat, lng): string               // Google Maps deep-link

src/lib/share.ts

Encodes the active filter state (selected types, cities, focused place ID) into a query string so filtered views are copy-pasteable.

parseMapState(search: string): MapShareState
mapStateToSearch(state): string
buildShareUrl(state): string

Adding a place

  1. Pick the right file in data/places/<type>.json.
  2. Append a record following the shape in src/lib/types.ts.
  3. Run npm run dev and open /map to verify the pin appears in the right spot.
  4. Open a pull request — see CONTRIBUTING.md for the quality gate.

No code changes are needed to add a place. getPlaces() picks up the new entry automatically because it does a static import of the whole file.