diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fdead23..d352481 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,10 @@ # Architecture -StudyMap is a Next.js 16 (App Router) static site. There is no database, no auth, and no server-side logic. All place data ships as JSON in the repo. This document is a map for new contributors. +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. --- @@ -9,15 +13,19 @@ StudyMap is a Next.js 16 (App Router) static site. There is no database, no auth ``` 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/ # 8 JSON files, one per place type +│ ├── 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 @@ -31,24 +39,34 @@ studymap/ │ ├── 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 -└── tsconfig.json +├── 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/` -Eight JSON files, one per place category. Each file is a flat array of place objects. This is the only place place data lives — nothing is duplicated elsewhere. +Nine JSON files, one per place category (imported by `studymap.config.ts` at the repo root, not +directly by `src/lib/places.ts` - see [Data flow](#data-flow) below). Each file is a flat array of +place objects. This is the only place place data lives — nothing is duplicated elsewhere. | File | Type key | |------|----------| @@ -60,6 +78,7 @@ Eight JSON files, one per place category. Each file is a flat array of place obj | `stationery.json` | `stationery` | | `internet_cafe.json` | `internet_cafe` | | `imp_locations.json` | `imp_locations` | +| `repair_shop.json` | `repair_shop` | Record shape (defined in `src/lib/types.ts`): @@ -68,7 +87,7 @@ Record shape (defined in `src/lib/types.ts`): id: string; // kebab-case, unique across all types name: string; type: PlaceType; - city: "mumbai" | "thane" | "navi_mumbai"; + city: string; // free-form slug, e.g. "mumbai", "navi_mumbai" - any city worldwide lat: number; lng: number; address?: string; @@ -94,6 +113,8 @@ Next.js App Router. Each folder is a route segment. | `/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. @@ -108,14 +129,26 @@ components/ │ ├── 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) -│ ├── map-view.tsx # Leaflet map (markers, tiles, scroll guard, user dot) -│ ├── filter-panel.tsx # city + type checkboxes, result count, reset -│ └── near-me-button.tsx # triggers browser geolocation +│ ├── 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 +│ ├── 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 +│ ├── navbar.tsx # nav links + sign-in/sign-out │ ├── footer.tsx │ ├── page-container.tsx # max-width wrapper used by most pages │ ├── theme-provider.tsx @@ -130,6 +163,9 @@ components/ └── 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. @@ -144,7 +180,14 @@ Pure TypeScript modules. No JSX, no React imports. Each file has a single respon | `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 | +| `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`). --- @@ -160,14 +203,15 @@ src/lib/places.ts getPlaces() reads the config's Place[] src/components/map/places-map.tsx │ reads URL params via share.ts → parseMapState() │ calls filterPlaces() on every filter change - │ passes filtered Place[] down to MapView + │ 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/filter-panel.tsx (type + city checkboxes) + ├──▶ 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 - │ renders one Leaflet CircleMarker per place, coloured by PLACE_TYPE_COLORS + │ clusters overlapping pins (Supercluster), coloured by PLACE_TYPE_COLORS │ on marker click: opens PinPopup │ ▼ @@ -176,7 +220,9 @@ src/components/pins/pin-popup.tsx buildShareUrl(state) ← src/lib/share.ts ``` -**No network requests at runtime.** The JSON is bundled at build time via static `import` statements in `studymap.config.ts`. The Leaflet tile layer is the only external request when the map is open. +**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. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index c19c02e..89da372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Repair shop as a ninth place category (color: red `#DC2626`), wired through the full design system (type definitions, color tokens, CSS custom properties, Tailwind utilities, hero legend). - Map auto-fits to all loaded places on first load instead of a fixed Mumbai crop. - CARTO basemap saturation pass for improved visual clarity before the MapTiler migration. +- Config-driven region and dataset: `studymap.config.ts` at the repo root holds `center`, `defaultZoom`, `bounds`, and a `cities` display-order registry, plus the place-data imports. Retargeting StudyMap to a new city or dataset means editing this one file. +- Data-source abstraction: `src/lib/places.ts` now reads places from `studymap.config.ts` instead of importing `data/places/*.json` directly. `src/lib/constants.ts` removed, fully superseded. +- Template-repo support for forks: "Use this template" enabled on the repo, plus `studymap.config.example.ts` and a minimal `data/places.sample/` dataset to start from. +- `SELF-HOSTING.md`: full walkthrough for running a fork - config, dataset, env vars, optional Supabase setup, deployment, keeping a fork current. +- Saved custom places and a home location for signed-in users: a private pin layer with its own search and city filter, add/edit/delete, and "Nearest to home" distance sorting. Backed by new `user_places`/`user_home` Supabase tables, RLS-scoped to each user. +- Personal calendar deadlines/events for signed-in users, overlaid on the public exam calendar. Backed by a new `user_events` Supabase table, RLS-scoped to each user. +- GitHub Discussions enabled, with a "Request a city" discussion template. +- `CONTRIBUTORS.md` and a documented recognition process (add your handle on your first merged PR). +- `good first issue` / `help wanted` labels applied to the issues that are actually ready to pick up, plus a "Good first issues" README section pointing newcomers at them. +- Vitest + React Testing Library; unit tests for `lib/geo`, `lib/share`, and `lib/places` (`npm run test:unit`). +- `docs/OFFLINE_CACHING.md`: what the PWA service worker caches and how to force a fresh load after a deploy. +- `repair_shop` documented as a valid place type in `CONTRIBUTING.md`, `data/CONTRIBUTING.md`, `README.md`, and the data validator (was a valid `PlaceType` but undocumented and rejected by `scripts/validate-places.mjs`). + +### Changed + +- `getCities()` now orders cities by `studymap.config.ts`'s `cities` registry, falling back to alphabetical for anything not in that list (previously always alphabetical). ### Fixed @@ -30,14 +46,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 17 places that shared identical coordinates are now spread to their true locations. - Hero section: explicit space rendered between category count and the word "categories". - Initial tile load optimized: `keepBuffer` and `updateWhenZooming` tuned for faster first paint. +- Saved-places and personal-events UI now surfaces a clear, actionable error when the backend tables haven't been set up yet, instead of silently rendering an empty state. +- Various `react-hooks` lint violations (`set-state-in-effect`, `refs`) across effect-based state resets and a vendored particles component. + +### Removed + +- `src/lib/constants.ts`, superseded by `studymap.config.ts`. ### Closes issues +- #29 Config-driven region (`studymap.config.ts`) +- #30 Data-source abstraction (configurable places path) +- #31 SELF-HOSTING.md +- #33 Template repo + config.example + sample dataset +- #35 good-first-issue labels + starter board + README section +- #36 CONTRIBUTORS recognition +- #37 Enable Discussions + request-a-city template +- #39 Vitest + RTL unit tests for lib/{geo,share,places} +- #60 Saved custom places + home location (signed-in, private) +- #61 Personal calendar deadlines/events (signed-in, private) - #62 Bottom sheet / Filters fails to open on real mobile devices - #63 Map popups auto-close immediately after opening - #64 Tune marker clustering so unrelated categories don't render as overlapping pins - #65 Migrate basemap provider for genuine colorful light+dark tiles - #66 Service worker tile-cache check targets a stale hostname +- #67 Document PWA service-worker caching behaviour + how to force-refresh ## [1.2.2] - 2026-06-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf03d4c..46f19ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,7 @@ Proof goes in the pull request, never in the committed JSON. ## The place record -Each place is one object inside `data/places/.json`. Valid types: `book_shop`, `library`, `exam_centre`, `imp_locations`, `stationery`, `internet_cafe`, `airport`, `train_station`. +Each place is one object inside `data/places/.json`. Valid types: `book_shop`, `library`, `exam_centre`, `imp_locations`, `stationery`, `internet_cafe`, `airport`, `train_station`, `repair_shop`. ```json { diff --git a/README.md b/README.md index 019e064..5e1b597 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ npm install npm run dev ``` -Open http://localhost:3000. No environment variables needed; the map reads place data directly from `data/places/`. +Open http://localhost:3000. No environment variables needed; the map reads place data via `studymap.config.ts`, which imports it from `data/places/`. ## Data schema @@ -35,6 +35,7 @@ Places live in `data/places/.json`, one file per category: | `stationery.json` | `stationery` | | `internet_cafe.json` | `internet_cafe` | | `imp_locations.json` | `imp_locations` | +| `repair_shop.json` | `repair_shop` | Each record shape (`src/lib/types.ts`): @@ -42,8 +43,8 @@ Each record shape (`src/lib/types.ts`): { id: string; // kebab-case, unique across all types name: string; - type: PlaceType; // one of the 8 keys above - city: "mumbai" | "thane" | "navi_mumbai"; + type: PlaceType; // one of the 9 keys above + city: string; // free-form slug, e.g. "mumbai", "navi_mumbai" - any city worldwide lat: number; lng: number; address?: string; @@ -65,33 +66,45 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. ## Architecture ``` +studymap.config.ts # region + dataset config - the one file a fork edits to retarget StudyMap src/ app/ page.tsx # homepage (hero + map preview) map/page.tsx # full interactive map + calendar/ # public exam calendar + signed-in personal events + login/ # optional sign-in (Google OAuth, email) + auth/callback/ # OAuth code exchange contribute/page.tsx # contribution guide legal/ # privacy, terms, disclaimer layout.tsx # root layout (navbar, footer, theme) components/ home/ # Hero, MapPreview - map/ # PlacesMap, MapView, FilterPanel, NearMeButton + map/ # PlacesMap, MapView, MapPanel, MyPlacesPanel, dialogs + calendar/ # PersonalEventDialog pins/ # PinPopup layout/ # Navbar, Footer lib/ - places.ts # getPlaces(), filterPlaces(), city loader + places.ts # getPlaces(), filterPlaces(), getCities() - reads from studymap.config.ts geo.ts # distance calculation, LatLng type types.ts # PlaceType, City, Place interface map.ts # PLACE_TYPE_COLORS, directionsUrl share.ts # URL state encode/decode for shareable links site.ts # site metadata, navLinks + user-places.ts # saved places + home location (signed-in only) + user-events.ts # personal calendar events (signed-in only) data/ - places/ # 8 JSON files, one per place type + places/ # 9 JSON files, one per place type +supabase/ + migrations/ # SQL for the two optional, sign-in-gated tables (run once by hand) ``` +See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. + ## Tech stack -- **Next.js 16** (App Router, static export) -- **Leaflet + react-leaflet** (interactive map) +- **Next.js 16** (App Router) +- **Leaflet + react-leaflet** (interactive map, marker clustering) +- **Supabase** (optional sign-in, gates saved places + personal calendar events) - **shadcn/ui + Tailwind v4** (UI components) - **next-themes** (dark/light mode) diff --git a/data/CONTRIBUTING.md b/data/CONTRIBUTING.md index db30c09..7aa30d8 100644 --- a/data/CONTRIBUTING.md +++ b/data/CONTRIBUTING.md @@ -14,6 +14,7 @@ Places live in `data/places/.json`, one file per place type, one JSON obje | `internet_cafe.json` | Cyber cafes and internet kiosks | | `airport.json` | Airports | | `train_station.json` | Railway stations | +| `repair_shop.json` | Phone, laptop, and gadget repair shops | ## Record format diff --git a/scripts/validate-places.mjs b/scripts/validate-places.mjs index 8c04266..cf51efb 100644 --- a/scripts/validate-places.mjs +++ b/scripts/validate-places.mjs @@ -30,6 +30,7 @@ const VALID_TYPES = new Set([ "internet_cafe", "airport", "train_station", + "repair_shop", ]); // Valid geographic coordinate ranges. Catches impossible values (e.g. lat=200) diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx index 2bbc1af..3037a95 100644 --- a/src/app/calendar/CalendarView.tsx +++ b/src/app/calendar/CalendarView.tsx @@ -12,6 +12,7 @@ import { type ExamEvent, } from "@/lib/exam-dates"; import { createClient } from "@/lib/supabase/client"; +import { isMissingTableError } from "@/lib/utils"; import { fetchUserEvents, PERSONAL_EVENT_CATEGORIES, @@ -112,6 +113,7 @@ export function CalendarView() { const [dialogOpen, setDialogOpen] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [lastUserId, setLastUserId] = useState(null); + const [eventsError, setEventsError] = useState(null); // Clear stale events as soon as the signed-in user changes, during render // rather than an effect, so there's no stale-data flash. @@ -119,6 +121,7 @@ export function CalendarView() { if (userId !== lastUserId) { setLastUserId(userId); setPersonalEvents([]); + setEventsError(null); } useEffect(() => { @@ -133,8 +136,18 @@ export function CalendarView() { useEffect(() => { if (!user) return; fetchUserEvents() - .then(setPersonalEvents) - .catch(() => setPersonalEvents([])); + .then((events) => { + setPersonalEvents(events); + setEventsError(null); + }) + .catch((err) => { + setPersonalEvents([]); + setEventsError( + isMissingTableError(err) + ? "This deployment's personal-events table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't load your events. Try reloading.", + ); + }); }, [user]); function handleSaved(saved: PersonalEvent) { @@ -258,7 +271,11 @@ export function CalendarView() { - {personalEventsThisMonth.length === 0 ? ( + {eventsError ? ( +

+ {eventsError} +

+ ) : personalEventsThisMonth.length === 0 ? (

No personal events this month.

diff --git a/src/components/calendar/personal-event-dialog.tsx b/src/components/calendar/personal-event-dialog.tsx index 6d88e2a..87f3b07 100644 --- a/src/components/calendar/personal-event-dialog.tsx +++ b/src/components/calendar/personal-event-dialog.tsx @@ -26,6 +26,7 @@ import { type PersonalEvent, type PersonalEventCategory, } from "@/lib/user-events"; +import { isMissingTableError } from "@/lib/utils"; interface PersonalEventDialogProps { open: boolean; @@ -82,8 +83,12 @@ export function PersonalEventDialog({ : await createUserEvent(input); onSaved(saved); onOpenChange(false); - } catch { - setError("Couldn't save this event. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's personal-events table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't save this event. Try again.", + ); } finally { setSaving(false); } @@ -97,8 +102,12 @@ export function PersonalEventDialog({ await deleteUserEvent(event.id); onDeleted(event.id); onOpenChange(false); - } catch { - setError("Couldn't delete this event. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's personal-events table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't delete this event. Try again.", + ); } finally { setSaving(false); } diff --git a/src/components/map/map-panel.tsx b/src/components/map/map-panel.tsx index 3b682b6..88d43d5 100644 --- a/src/components/map/map-panel.tsx +++ b/src/components/map/map-panel.tsx @@ -24,6 +24,7 @@ import type { UserHome, UserPlaceRow } from "@/lib/user-places"; export interface MyPlacesProps { savedPlaces: UserPlaceRow[]; + error: string | null; cities: City[]; query: string; onQueryChange: (query: string) => void; diff --git a/src/components/map/my-places-panel.tsx b/src/components/map/my-places-panel.tsx index 39ee707..f25641d 100644 --- a/src/components/map/my-places-panel.tsx +++ b/src/components/map/my-places-panel.tsx @@ -18,6 +18,7 @@ import type { UserHome, UserPlaceRow } from "@/lib/user-places"; interface MyPlacesPanelProps { savedPlaces: UserPlaceRow[]; + error: string | null; cities: City[]; query: string; onQueryChange: (query: string) => void; @@ -33,6 +34,7 @@ interface MyPlacesPanelProps { /** Signed-in-only saved-places layer: own search, own city filter, own CRUD. */ export function MyPlacesPanel({ savedPlaces, + error, cities, query, onQueryChange, @@ -117,7 +119,11 @@ export function MyPlacesPanel({ )}
- {savedPlaces.length === 0 ? ( + {error ? ( +

+ {error} +

+ ) : savedPlaces.length === 0 ? (

No saved places yet.

diff --git a/src/components/map/places-map.tsx b/src/components/map/places-map.tsx index e3afe95..fcf0955 100644 --- a/src/components/map/places-map.tsx +++ b/src/components/map/places-map.tsx @@ -12,6 +12,7 @@ import { cityBounds, filterPlaces, getCities } from "@/lib/places"; import { placesByDistance, type LatLng } from "@/lib/geo"; import { buildShareUrl, mapStateToSearch, parseMapState } from "@/lib/share"; import { createClient } from "@/lib/supabase/client"; +import { isMissingTableError } from "@/lib/utils"; import { fetchUserHome, fetchUserPlaces, @@ -73,6 +74,7 @@ export function PlacesMap({ places }: PlacesMapProps) { const [placeDialogOpen, setPlaceDialogOpen] = React.useState(false); const [editingPlace, setEditingPlace] = React.useState(null); const [homeDialogOpen, setHomeDialogOpen] = React.useState(false); + const [myPlacesError, setMyPlacesError] = React.useState(null); const [lastUserId, setLastUserId] = React.useState(null); const cities = React.useMemo(() => getCities(places), [places]); @@ -84,6 +86,7 @@ export function PlacesMap({ places }: PlacesMapProps) { setLastUserId(userId); setSavedPlaces([]); setHome(null); + setMyPlacesError(null); } React.useEffect(() => { @@ -101,7 +104,19 @@ export function PlacesMap({ places }: PlacesMapProps) { React.useEffect(() => { if (!user) return; - fetchUserPlaces().then(setSavedPlaces).catch(() => setSavedPlaces([])); + fetchUserPlaces() + .then((rows) => { + setSavedPlaces(rows); + setMyPlacesError(null); + }) + .catch((err) => { + setSavedPlaces([]); + setMyPlacesError( + isMissingTableError(err) + ? "This deployment's saved-places table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't load your saved places. Try reloading.", + ); + }); fetchUserHome().then(setHome).catch(() => setHome(null)); }, [user]); @@ -269,6 +284,7 @@ export function PlacesMap({ places }: PlacesMapProps) { myPlaces: user ? { savedPlaces, + error: myPlacesError, cities: myCities, query: myQuery, onQueryChange: setMyQuery, diff --git a/src/components/map/user-home-dialog.tsx b/src/components/map/user-home-dialog.tsx index 0614f82..8e74356 100644 --- a/src/components/map/user-home-dialog.tsx +++ b/src/components/map/user-home-dialog.tsx @@ -14,6 +14,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { deleteUserHome, upsertUserHome, type UserHome } from "@/lib/user-places"; +import { isMissingTableError } from "@/lib/utils"; interface UserHomeDialogProps { open: boolean; @@ -82,8 +83,12 @@ export function UserHomeDialog({ const saved = await upsertUserHome({ label: label.trim(), lat: latNum, lng: lngNum }); onSaved(saved); onOpenChange(false); - } catch { - setError("Couldn't save your home location. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's home-location table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't save your home location. Try again.", + ); } finally { setSaving(false); } @@ -96,8 +101,12 @@ export function UserHomeDialog({ await deleteUserHome(); onDeleted(); onOpenChange(false); - } catch { - setError("Couldn't remove your home location. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's home-location table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't remove your home location. Try again.", + ); } finally { setSaving(false); } diff --git a/src/components/map/user-place-dialog.tsx b/src/components/map/user-place-dialog.tsx index ac5809d..7663c9b 100644 --- a/src/components/map/user-place-dialog.tsx +++ b/src/components/map/user-place-dialog.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "@/components/ui/select"; import { PLACE_TYPE_LABELS, PLACE_TYPES, type PlaceType } from "@/lib/types"; +import { isMissingTableError } from "@/lib/utils"; import { createUserPlace, deleteUserPlace, @@ -120,8 +121,12 @@ export function UserPlaceDialog({ : await createUserPlace(input); onSaved(saved); onOpenChange(false); - } catch { - setError("Couldn't save this place. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's saved-places table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't save this place. Try again.", + ); } finally { setSaving(false); } @@ -135,8 +140,12 @@ export function UserPlaceDialog({ await deleteUserPlace(place.id); onDeleted(place.id); onOpenChange(false); - } catch { - setError("Couldn't delete this place. Try again."); + } catch (err) { + setError( + isMissingTableError(err) + ? "This deployment's saved-places table isn't set up yet. See SELF-HOSTING.md." + : "Couldn't delete this place. Try again.", + ); } finally { setSaving(false); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..599eaed 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** True when a Supabase/PostgREST error means the table doesn't exist, i.e. a migration was never run. */ +export function isMissingTableError(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "PGRST205" + ) +}