From 3debb1ce1eb47032c482992c6fe6e2c79cf198cd Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Mon, 20 Jul 2026 20:29:25 +0200 Subject: [PATCH] Add CityTracker v1: map-based city tracker with multi-user support React/Vite frontend and Express/SQLite backend for tracking visited and planned cities on a map, with per-city notes, photos, links, and participant-based sharing between family members. Includes session auth with admin-managed user accounts, an OSM/Nominatim geocoding proxy, and a single-image Docker deployment. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 13 + .env.example | 16 + .eslintrc.json | 23 + .gitignore | 13 + .prettierrc.json | 6 + Dockerfile | 43 + README.md | 152 + client/index.html | 12 + client/package.json | 30 + client/src/App.tsx | 52 + client/src/api/auth.ts | 22 + client/src/api/cities.ts | 60 + client/src/api/client.ts | 61 + client/src/api/geocode.ts | 10 + client/src/api/users.ts | 22 + .../src/components/city/CitySearchInput.tsx | 77 + client/src/components/city/LinkListEditor.tsx | 82 + .../src/components/city/ParticipantPicker.tsx | 36 + client/src/components/city/PhotoUploader.tsx | 60 + client/src/components/layout/AppShell.tsx | 35 + .../src/components/layout/ProtectedRoute.tsx | 12 + client/src/components/map/CityMap.tsx | 46 + client/src/components/map/CityMarker.tsx | 14 + client/src/components/map/MapFilters.tsx | 68 + client/src/context/AuthContext.tsx | 75 + client/src/hooks/useCities.ts | 92 + client/src/hooks/useUsers.ts | 41 + client/src/main.tsx | 10 + client/src/pages/AdminUsersPage.tsx | 171 + client/src/pages/CityDetailPage.tsx | 247 + client/src/pages/LoginPage.tsx | 64 + client/src/pages/MapDashboardPage.tsx | 52 + client/src/pages/SetupPage.tsx | 88 + client/src/styles/global.css | 571 ++ client/tsconfig.json | 17 + client/vite.config.ts | 16 + docker-compose.yml | 22 + package-lock.json | 6425 +++++++++++++++++ package.json | 24 + server/package.json | 31 + server/src/app.ts | 51 + server/src/config.ts | 37 + server/src/db/connection.ts | 11 + server/src/db/migrate.ts | 36 + server/src/db/migrations/0001_init.sql | 53 + server/src/index.ts | 24 + server/src/middleware/requireAdmin.ts | 10 + server/src/middleware/requireAuth.ts | 25 + server/src/middleware/requireParticipant.ts | 34 + server/src/middleware/session.ts | 76 + server/src/routes/auth.routes.ts | 85 + server/src/routes/cities.routes.ts | 129 + server/src/routes/geocode.routes.ts | 30 + server/src/routes/users.routes.ts | 97 + server/src/services/auth.service.ts | 40 + server/src/services/cities.service.ts | 303 + server/src/services/geocode.service.ts | 81 + server/src/services/uploads.service.ts | 37 + server/src/services/users.service.ts | 97 + server/src/types/express.d.ts | 7 + server/tsconfig.json | 17 + shared/package.json | 18 + shared/src/index.ts | 2 + shared/src/schemas.ts | 81 + shared/src/types.ts | 49 + shared/tsconfig.json | 18 + 66 files changed, 10359 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/App.tsx create mode 100644 client/src/api/auth.ts create mode 100644 client/src/api/cities.ts create mode 100644 client/src/api/client.ts create mode 100644 client/src/api/geocode.ts create mode 100644 client/src/api/users.ts create mode 100644 client/src/components/city/CitySearchInput.tsx create mode 100644 client/src/components/city/LinkListEditor.tsx create mode 100644 client/src/components/city/ParticipantPicker.tsx create mode 100644 client/src/components/city/PhotoUploader.tsx create mode 100644 client/src/components/layout/AppShell.tsx create mode 100644 client/src/components/layout/ProtectedRoute.tsx create mode 100644 client/src/components/map/CityMap.tsx create mode 100644 client/src/components/map/CityMarker.tsx create mode 100644 client/src/components/map/MapFilters.tsx create mode 100644 client/src/context/AuthContext.tsx create mode 100644 client/src/hooks/useCities.ts create mode 100644 client/src/hooks/useUsers.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/AdminUsersPage.tsx create mode 100644 client/src/pages/CityDetailPage.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/pages/MapDashboardPage.tsx create mode 100644 client/src/pages/SetupPage.tsx create mode 100644 client/src/styles/global.css create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db/connection.ts create mode 100644 server/src/db/migrate.ts create mode 100644 server/src/db/migrations/0001_init.sql create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/requireAdmin.ts create mode 100644 server/src/middleware/requireAuth.ts create mode 100644 server/src/middleware/requireParticipant.ts create mode 100644 server/src/middleware/session.ts create mode 100644 server/src/routes/auth.routes.ts create mode 100644 server/src/routes/cities.routes.ts create mode 100644 server/src/routes/geocode.routes.ts create mode 100644 server/src/routes/users.routes.ts create mode 100644 server/src/services/auth.service.ts create mode 100644 server/src/services/cities.service.ts create mode 100644 server/src/services/geocode.service.ts create mode 100644 server/src/services/uploads.service.ts create mode 100644 server/src/services/users.service.ts create mode 100644 server/src/types/express.d.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 shared/src/schemas.ts create mode 100644 shared/src/types.ts create mode 100644 shared/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a7fb9a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +**/node_modules +**/dist +**/*.tsbuildinfo +.git +.tools +data +data-dev +*.db +*.db-journal +*.db-wal +*.db-shm +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89d6a86 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Port exposed on the host (the container always listens on 3000 internally) +HOST_PORT=3000 + +# Long random string used to sign session cookies. Generate one with: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=change-me-to-a-long-random-string + +# Credentials for the first admin account, created automatically on first startup +# if no admin exists yet. If you leave these unset, use the in-app /setup screen +# on first visit instead. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me + +# Set to true only if a reverse proxy in front of this container terminates TLS +# and forwards HTTPS traffic (e.g. Nginx Proxy Manager with SSL enabled). +COOKIE_SECURE=false diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..da47f4c --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "env": { + "es2022": true, + "node": true, + "browser": true + }, + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-explicit-any": "off" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a73e6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.tools/ +dist/ +build/ +*.tsbuildinfo +.env +*.log +.DS_Store +data/ +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f9fdc87 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48b06c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ---- Stage 1: build shared + client (static assets) ---- +FROM node:20-bookworm-slim AS build-client +WORKDIR /app +COPY package.json ./ +COPY shared/package.json shared/package.json +COPY server/package.json server/package.json +COPY client/package.json client/package.json +RUN npm install +COPY shared shared +COPY client client +RUN npm run build -w shared && npm run build -w client + +# ---- Stage 2: build shared + server (compiled JS) ---- +FROM node:20-bookworm-slim AS build-server +WORKDIR /app +COPY package.json ./ +COPY shared/package.json shared/package.json +COPY server/package.json server/package.json +COPY client/package.json client/package.json +RUN npm install +COPY shared shared +COPY server server +RUN npm run build -w shared && npm run build -w server + +# ---- Stage 3: runtime ---- +# node:20-bookworm-slim (glibc) is used instead of alpine so that better-sqlite3 and argon2's +# native bindings can use prebuilt binaries rather than compiling from source. +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json ./ +COPY shared/package.json shared/package.json +COPY server/package.json server/package.json +COPY client/package.json client/package.json +RUN npm install --omit=dev --workspace=shared --workspace=server + +COPY --from=build-server /app/shared/dist shared/dist +COPY --from=build-server /app/server/dist server/dist +COPY --from=build-client /app/client/dist client/dist + +EXPOSE 3000 +CMD ["node", "server/dist/index.js"] diff --git a/README.md b/README.md index aa864e3..ffa9091 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,154 @@ # CityTracker +A self-hostable webapp for tracking cities you've visited (or plan to visit) on a +map, with ratings, notes, photos, and links — built for sharing between family +members or a partner, each with their own login. + +## Features + +- Interactive world map (Leaflet + OpenStreetMap tiles, no API keys) with pins + colored by status (visited / planned) and a heart badge for favorites +- Add a city by searching its name (via OpenStreetMap Nominatim) or by clicking + directly on the map +- Per-city notes, a "liked" heart, one photo, a list of links, and the people + who were there (picked from your user list) +- Filter the map by visited/planned, favorites, year, or country +- Multiple user accounts; a city is visible/editable by everyone listed as a + participant on it +- Admin-managed user accounts (no public self-registration) +- Single Docker image, SQLite database, file-based photo storage — easy to back + up as one Docker volume + +## Tech stack + +- **Frontend:** React + TypeScript, Vite, React Router, TanStack Query, Leaflet +- **Backend:** Node.js + TypeScript, Express, better-sqlite3, argon2, multer +- **Database:** SQLite, single file, plain SQL migrations run automatically on + startup +- **Auth:** server-side sessions (httpOnly cookies), backed by a SQLite table so + logins survive container restarts + +## Project structure + +``` +CityTracker/ +├── shared/ # TypeScript types + zod validation schemas, used by both client and server +├── server/ # Express API (auth, cities, uploads, geocoding proxy) +└── client/ # React app (Vite) +``` + +## Running with Docker (recommended) + +This is the intended way to run CityTracker — one image, one volume. + +1. Copy the example environment file and fill in real values: + + ```sh + cp .env.example .env + ``` + + At minimum, set: + - `SESSION_SECRET` — a long random string (generate one with + `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`) + - `ADMIN_USERNAME` / `ADMIN_PASSWORD` — credentials for the first admin + account, created automatically the first time the server starts. If you + leave these unset, open the app once it's running and use the one-time + `/setup` screen instead. + +2. Build and start the container: + + ```sh + docker compose up -d --build + ``` + +3. Open `http://:` (default port `3000`) and log in with + the admin account you configured. + +All application data — the SQLite database and uploaded photos — lives under +`/app/data` inside the container, on the `citytracker-data` named volume. Back +up that volume (e.g. `docker run --rm -v citytracker_citytracker-data:/data -v $(pwd):/backup alpine tar czf /backup/citytracker-backup.tar.gz /data`) +to back up everything. + +### Behind Nginx Proxy Manager (or another reverse proxy) + +The container just needs to be reachable and listening on `HOST_PORT`. Point a +proxy host at `docker.covenant.lan:` (or wherever the container +runs), enable SSL as usual in your proxy. If your proxy terminates TLS and +forwards plain HTTP to the container, set `COOKIE_SECURE=true` in `.env` so +session cookies are still marked `Secure` correctly; leave it `false` if the +container itself is only ever reached over plain HTTP inside your LAN. + +### First admin account + +On first startup, if no admin user exists yet: + +- If `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`, that account is + created automatically. +- Otherwise, visiting the app shows a one-time setup screen to create the + first admin account by hand. + +After that, only an admin can create further user accounts, from the **Users** +page in the app. + +## Adding more users + +Log in as an admin, go to **Users**, and use the "Add a user" form. Admins can +also promote/demote other admins, deactivate/reactivate accounts, and reset +passwords from the same page. Deactivated accounts can no longer log in but +remain visible (grayed out) on cities they previously participated in, so +history isn't lost. + +## Local development (without Docker) + +Requires Node.js 20 (native modules `better-sqlite3` and `argon2` need a +recent Node build with prebuilt binaries available). + +```sh +npm install +npm run build -w shared # shared types must be built once before starting the server or client + +# in one terminal +PORT=3000 SESSION_SECRET=dev-secret ADMIN_USERNAME=admin ADMIN_PASSWORD=devpassword123 npm run dev:server + +# in another terminal +npm run dev:client +``` + +The Vite dev server (`http://localhost:5173`) proxies `/api` and `/uploads` +requests to the backend on port 3000. + +To produce a production build of everything (used by the Docker image): + +```sh +npm run build +npm start +``` + +## Configuration reference + +All configuration is via environment variables (see `.env.example`): + +| Variable | Description | Default | +|---|---|---| +| `PORT` | Port the server listens on inside the container | `3000` | +| `HOST_PORT` | Port exposed on the Docker host (docker-compose only) | `3000` | +| `SESSION_SECRET` | Secret used to sign session cookies. **Required** in production. | — | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Credentials for the auto-created first admin account | — | +| `DB_PATH` | Path to the SQLite database file | `/app/data/citytracker.db` (Docker) | +| `UPLOAD_DIR` | Directory where uploaded photos are stored | `/app/data/uploads` (Docker) | +| `COOKIE_SECURE` | Mark session cookies `Secure` (only if TLS reaches the container or is terminated just in front of it) | `false` | + +## Notes on scope / what's not included + +- **Trips** (grouping cities into a named trip with its own date range) is + planned as a future addition. The database schema was deliberately kept + simple enough that adding `trips` / `trip_cities` tables later won't require + reworking existing data. +- The UI is English-only by design, to keep the app simple; phone/browser + auto-translate tools work fine over it if needed. +- Uploaded photos are served from `/uploads/` without a + per-request auth check — acceptable for a private LAN/home-use deployment + since filenames are non-guessable UUIDs, but worth knowing if you expose + this instance beyond your own network. +- There is no automated test suite; this is a personal/family-scale project + verified through manual end-to-end testing. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..7b68852 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + CityTracker + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..d357a0a --- /dev/null +++ b/client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@citytracker/client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "clean": "rimraf dist" + }, + "dependencies": { + "@citytracker/shared": "*", + "@tanstack/react-query": "^5.51.23", + "leaflet": "^1.9.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.26.1" + }, + "devDependencies": { + "@types/leaflet": "^1.9.12", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "rimraf": "^5.0.9", + "typescript": "^5.5.4", + "vite": "^6.4.3" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..19ac239 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,52 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import { ProtectedRoute } from './components/layout/ProtectedRoute'; +import { AppShell } from './components/layout/AppShell'; +import { LoginPage } from './pages/LoginPage'; +import { SetupPage } from './pages/SetupPage'; +import { MapDashboardPage } from './pages/MapDashboardPage'; +import { CityDetailPage } from './pages/CityDetailPage'; +import { AdminUsersPage } from './pages/AdminUsersPage'; + +const queryClient = new QueryClient(); + +function AppRoutes() { + const { loading } = useAuth(); + + if (loading) { + return
Loading…
; + } + + return ( + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts new file mode 100644 index 0000000..86a840f --- /dev/null +++ b/client/src/api/auth.ts @@ -0,0 +1,22 @@ +import type { User, SetupStatus } from '@citytracker/shared'; +import { api } from './client'; + +export function login(username: string, password: string) { + return api.post('/auth/login', { username, password }); +} + +export function logout() { + return api.post('/auth/logout'); +} + +export function fetchMe() { + return api.get('/auth/me'); +} + +export function fetchSetupStatus() { + return api.get('/auth/setup-status'); +} + +export function setup(username: string, password: string) { + return api.post('/auth/setup', { username, password }); +} diff --git a/client/src/api/cities.ts b/client/src/api/cities.ts new file mode 100644 index 0000000..f830333 --- /dev/null +++ b/client/src/api/cities.ts @@ -0,0 +1,60 @@ +import type { + City, + CityStatus, + CreateCityInput, + UpdateCityInput, + SetParticipantsInput, + SetLinksInput, +} from '@citytracker/shared'; +import { api } from './client'; + +export interface CityListFilters { + status?: CityStatus; + favorite?: boolean; + year?: number; + country?: string; +} + +export function listCities(filters: CityListFilters = {}) { + const params = new URLSearchParams(); + if (filters.status) params.set('status', filters.status); + if (filters.favorite) params.set('favorite', 'true'); + if (filters.year) params.set('year', String(filters.year)); + if (filters.country) params.set('country', filters.country); + const qs = params.toString(); + return api.get(`/cities${qs ? `?${qs}` : ''}`); +} + +export function getCity(id: number) { + return api.get(`/cities/${id}`); +} + +export function createCity(input: CreateCityInput) { + return api.post('/cities', input); +} + +export function updateCity(id: number, input: UpdateCityInput) { + return api.patch(`/cities/${id}`, input); +} + +export function deleteCity(id: number) { + return api.delete(`/cities/${id}`); +} + +export function setParticipants(id: number, input: SetParticipantsInput) { + return api.put(`/cities/${id}/participants`, input); +} + +export function setLinks(id: number, input: SetLinksInput) { + return api.put(`/cities/${id}/links`, input); +} + +export function uploadPhoto(id: number, file: File) { + const formData = new FormData(); + formData.append('photo', file); + return api.postForm(`/cities/${id}/photo`, formData); +} + +export function deletePhoto(id: number) { + return api.delete(`/cities/${id}/photo`); +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..a0181cd --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,61 @@ +const BASE = '/api'; + +export class ApiError extends Error { + status: number; + body: unknown; + + constructor(status: number, body: unknown) { + const code = + body && typeof body === 'object' && 'error' in body ? String((body as any).error) : null; + super(code || `Request failed with status ${status}`); + this.status = status; + this.body = body; + } +} + +async function request(path: string, options: RequestInit = {}): Promise { + const isFormData = options.body instanceof FormData; + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + ...options, + headers: { + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), + ...options.headers, + }, + }); + + if (res.status === 204) { + return undefined as T; + } + + const contentType = res.headers.get('content-type') || ''; + const body = contentType.includes('application/json') ? await res.json() : undefined; + + if (!res.ok) { + throw new ApiError(res.status, body); + } + + return body as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, data?: unknown) => + request(path, { + method: 'POST', + body: data !== undefined ? JSON.stringify(data) : undefined, + }), + patch: (path: string, data?: unknown) => + request(path, { + method: 'PATCH', + body: data !== undefined ? JSON.stringify(data) : undefined, + }), + put: (path: string, data?: unknown) => + request(path, { + method: 'PUT', + body: data !== undefined ? JSON.stringify(data) : undefined, + }), + delete: (path: string) => request(path, { method: 'DELETE' }), + postForm: (path: string, formData: FormData) => + request(path, { method: 'POST', body: formData }), +}; diff --git a/client/src/api/geocode.ts b/client/src/api/geocode.ts new file mode 100644 index 0000000..20364d8 --- /dev/null +++ b/client/src/api/geocode.ts @@ -0,0 +1,10 @@ +import type { GeocodeResult } from '@citytracker/shared'; +import { api } from './client'; + +export function searchGeocode(query: string) { + return api.get<{ results: GeocodeResult[] }>(`/geocode/search?q=${encodeURIComponent(query)}`); +} + +export function reverseGeocode(lat: number, lng: number) { + return api.get<{ result: GeocodeResult | null }>(`/geocode/reverse?lat=${lat}&lng=${lng}`); +} diff --git a/client/src/api/users.ts b/client/src/api/users.ts new file mode 100644 index 0000000..6f512e8 --- /dev/null +++ b/client/src/api/users.ts @@ -0,0 +1,22 @@ +import type { User, CreateUserInput, UpdateUserInput, ResetPasswordInput } from '@citytracker/shared'; +import { api } from './client'; + +export function listUsers() { + return api.get('/users'); +} + +export function createUser(input: CreateUserInput) { + return api.post('/users', input); +} + +export function updateUser(id: number, input: UpdateUserInput) { + return api.patch(`/users/${id}`, input); +} + +export function resetPassword(id: number, input: ResetPasswordInput) { + return api.post(`/users/${id}/reset-password`, input); +} + +export function deleteUser(id: number) { + return api.delete(`/users/${id}`); +} diff --git a/client/src/components/city/CitySearchInput.tsx b/client/src/components/city/CitySearchInput.tsx new file mode 100644 index 0000000..bbcc317 --- /dev/null +++ b/client/src/components/city/CitySearchInput.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react'; +import type { GeocodeResult } from '@citytracker/shared'; +import { searchGeocode } from '../../api/geocode'; + +interface CitySearchInputProps { + onSelect: (result: GeocodeResult) => void; +} + +export function CitySearchInput({ onSelect }: CitySearchInputProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const debounceRef = useRef>(); + const suppressNextSearchRef = useRef(false); + + useEffect(() => { + if (suppressNextSearchRef.current) { + suppressNextSearchRef.current = false; + return undefined; + } + if (query.trim().length < 3) { + setResults([]); + return undefined; + } + setLoading(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + try { + const { results: found } = await searchGeocode(query.trim()); + setResults(found); + setOpen(true); + } finally { + setLoading(false); + } + }, 400); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + function handleSelect(result: GeocodeResult) { + onSelect(result); + suppressNextSearchRef.current = true; + setQuery(result.displayName); + setOpen(false); + setResults([]); + } + + return ( +
+ setQuery(e.target.value)} + onFocus={() => results.length > 0 && setOpen(true)} + placeholder="Start typing a city name…" + /> + {loading && Searching…} + {open && results.length > 0 && ( +
    + {results.map((result, index) => ( +
  • + +
  • + ))} +
+ )} + {open && !loading && results.length === 0 && query.trim().length >= 3 && ( +
+ No results — you can still enter details manually or click the map. +
+ )} +
+ ); +} diff --git a/client/src/components/city/LinkListEditor.tsx b/client/src/components/city/LinkListEditor.tsx new file mode 100644 index 0000000..c18d13f --- /dev/null +++ b/client/src/components/city/LinkListEditor.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import type { CityLink } from '@citytracker/shared'; + +interface LinkListEditorProps { + links: CityLink[]; + onSave: (links: { url: string; label: string }[]) => Promise; +} + +interface DraftLink { + url: string; + label: string; +} + +export function LinkListEditor({ links, onSave }: LinkListEditorProps) { + const [draft, setDraft] = useState( + links.map((l) => ({ url: l.url, label: l.label })), + ); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + + function updateLink(index: number, field: keyof DraftLink, value: string) { + setDraft((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l))); + setDirty(true); + } + + function addLink() { + setDraft((prev) => [...prev, { url: '', label: '' }]); + setDirty(true); + } + + function removeLink(index: number) { + setDraft((prev) => prev.filter((_, i) => i !== index)); + setDirty(true); + } + + async function handleSave() { + setSaving(true); + try { + await onSave(draft.filter((l) => l.url.trim())); + setDirty(false); + } finally { + setSaving(false); + } + } + + return ( +
+ {draft.map((link, index) => ( +
+ updateLink(index, 'label', e.target.value)} + /> + updateLink(index, 'url', e.target.value)} + /> + +
+ ))} +
+ + {dirty && ( + + )} +
+
+ ); +} diff --git a/client/src/components/city/ParticipantPicker.tsx b/client/src/components/city/ParticipantPicker.tsx new file mode 100644 index 0000000..efb4edb --- /dev/null +++ b/client/src/components/city/ParticipantPicker.tsx @@ -0,0 +1,36 @@ +import { useUsers } from '../../hooks/useUsers'; + +interface ParticipantPickerProps { + selectedIds: number[]; + onChange: (ids: number[]) => void; +} + +export function ParticipantPicker({ selectedIds, onChange }: ParticipantPickerProps) { + const { data: users, isLoading } = useUsers(); + + function toggle(id: number) { + if (selectedIds.includes(id)) { + onChange(selectedIds.filter((existing) => existing !== id)); + } else { + onChange([...selectedIds, id]); + } + } + + if (isLoading) return

Loading participants…

; + + return ( +
+ Who was there? + {(users ?? []).map((u) => ( + + ))} +
+ ); +} diff --git a/client/src/components/city/PhotoUploader.tsx b/client/src/components/city/PhotoUploader.tsx new file mode 100644 index 0000000..74ea3fc --- /dev/null +++ b/client/src/components/city/PhotoUploader.tsx @@ -0,0 +1,60 @@ +import { useRef, useState, type ChangeEvent } from 'react'; + +interface PhotoUploaderProps { + photoFilename: string | null; + onUpload: (file: File) => Promise; + onRemove: () => Promise; +} + +export function PhotoUploader({ photoFilename, onUpload, onRemove }: PhotoUploaderProps) { + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleFileChange(e: ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setBusy(true); + setError(null); + try { + await onUpload(file); + } catch { + setError('Could not upload photo. Please use a JPEG, PNG, or WebP image under 8 MB.'); + } finally { + setBusy(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + async function handleRemove() { + setBusy(true); + try { + await onRemove(); + } finally { + setBusy(false); + } + } + + return ( +
+ {photoFilename ? ( +
+ City + +
+ ) : ( +

No photo yet.

+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/client/src/components/layout/AppShell.tsx b/client/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..468d822 --- /dev/null +++ b/client/src/components/layout/AppShell.tsx @@ -0,0 +1,35 @@ +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; + +export function AppShell() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + async function handleLogout() { + await logout(); + navigate('/login', { replace: true }); + } + + return ( +
+
+ CityTracker + +
+ {user?.displayName || user?.username} + +
+
+
+ +
+
+ ); +} diff --git a/client/src/components/layout/ProtectedRoute.tsx b/client/src/components/layout/ProtectedRoute.tsx new file mode 100644 index 0000000..b680bb3 --- /dev/null +++ b/client/src/components/layout/ProtectedRoute.tsx @@ -0,0 +1,12 @@ +import { Navigate } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { useAuth } from '../../context/AuthContext'; + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, needsSetup } = useAuth(); + + if (needsSetup) return ; + if (!user) return ; + + return <>{children}; +} diff --git a/client/src/components/map/CityMap.tsx b/client/src/components/map/CityMap.tsx new file mode 100644 index 0000000..2e423da --- /dev/null +++ b/client/src/components/map/CityMap.tsx @@ -0,0 +1,46 @@ +import 'leaflet/dist/leaflet.css'; +import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet'; +import { useNavigate } from 'react-router-dom'; +import type { City } from '@citytracker/shared'; +import { cityMarkerIcon } from './CityMarker'; + +interface CityMapProps { + cities: City[]; + onMapClick?: (lat: number, lng: number) => void; +} + +function ClickHandler({ onMapClick }: { onMapClick?: (lat: number, lng: number) => void }) { + useMapEvents({ + click(e) { + onMapClick?.(e.latlng.lat, e.latlng.lng); + }, + }); + return null; +} + +export function CityMap({ cities, onMapClick }: CityMapProps) { + const navigate = useNavigate(); + + return ( + + + + {cities.map((city) => ( + + +
+ {city.name} +
{city.country}
+ +
+
+
+ ))} +
+ ); +} diff --git a/client/src/components/map/CityMarker.tsx b/client/src/components/map/CityMarker.tsx new file mode 100644 index 0000000..ed46795 --- /dev/null +++ b/client/src/components/map/CityMarker.tsx @@ -0,0 +1,14 @@ +import L from 'leaflet'; +import type { City } from '@citytracker/shared'; + +export function cityMarkerIcon(city: City): L.DivIcon { + const color = city.status === 'visited' ? '#2f9e44' : '#1971c2'; + const heart = city.liked ? '♥' : ''; + return L.divIcon({ + className: 'city-marker', + html: `${heart}`, + iconSize: [22, 22], + iconAnchor: [11, 11], + popupAnchor: [0, -14], + }); +} diff --git a/client/src/components/map/MapFilters.tsx b/client/src/components/map/MapFilters.tsx new file mode 100644 index 0000000..96067c7 --- /dev/null +++ b/client/src/components/map/MapFilters.tsx @@ -0,0 +1,68 @@ +import type { CityListFilters } from '../../api/cities'; + +interface MapFiltersProps { + filters: CityListFilters; + countries: string[]; + years: number[]; + onChange: (filters: CityListFilters) => void; +} + +export function MapFilters({ filters, countries, years, onChange }: MapFiltersProps) { + return ( +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx new file mode 100644 index 0000000..eaacaae --- /dev/null +++ b/client/src/context/AuthContext.tsx @@ -0,0 +1,75 @@ +import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'; +import type { User } from '@citytracker/shared'; +import * as authApi from '../api/auth'; +import { ApiError } from '../api/client'; + +interface AuthContextValue { + user: User | null; + loading: boolean; + needsSetup: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + completeSetup: (username: string, password: string) => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [needsSetup, setNeedsSetup] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const me = await authApi.fetchMe(); + setUser(me); + setNeedsSetup(false); + } catch (err) { + setUser(null); + if (err instanceof ApiError && err.status === 401) { + try { + const status = await authApi.fetchSetupStatus(); + setNeedsSetup(status.needsSetup); + } catch { + setNeedsSetup(false); + } + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const login = useCallback(async (username: string, password: string) => { + const loggedInUser = await authApi.login(username, password); + setUser(loggedInUser); + setNeedsSetup(false); + }, []); + + const completeSetup = useCallback(async (username: string, password: string) => { + const newUser = await authApi.setup(username, password); + setUser(newUser); + setNeedsSetup(false); + }, []); + + const logout = useCallback(async () => { + await authApi.logout(); + setUser(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/client/src/hooks/useCities.ts b/client/src/hooks/useCities.ts new file mode 100644 index 0000000..5fb921a --- /dev/null +++ b/client/src/hooks/useCities.ts @@ -0,0 +1,92 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { + CreateCityInput, + UpdateCityInput, + SetParticipantsInput, + SetLinksInput, +} from '@citytracker/shared'; +import * as citiesApi from '../api/cities'; +import type { CityListFilters } from '../api/cities'; + +const CITIES_KEY = ['cities']; + +export function useCities(filters: CityListFilters) { + return useQuery({ + queryKey: [...CITIES_KEY, filters], + queryFn: () => citiesApi.listCities(filters), + }); +} + +export function useCity(id: number | undefined) { + return useQuery({ + queryKey: ['city', id], + queryFn: () => citiesApi.getCity(id!), + enabled: id !== undefined, + }); +} + +function useInvalidateCities() { + const queryClient = useQueryClient(); + return (cityId?: number) => { + queryClient.invalidateQueries({ queryKey: CITIES_KEY }); + if (cityId !== undefined) { + queryClient.invalidateQueries({ queryKey: ['city', cityId] }); + } + }; +} + +export function useCreateCity() { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (input: CreateCityInput) => citiesApi.createCity(input), + onSuccess: () => invalidate(), + }); +} + +export function useUpdateCity(id: number) { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (input: UpdateCityInput) => citiesApi.updateCity(id, input), + onSuccess: () => invalidate(id), + }); +} + +export function useDeleteCity() { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (id: number) => citiesApi.deleteCity(id), + onSuccess: () => invalidate(), + }); +} + +export function useSetParticipants(id: number) { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (input: SetParticipantsInput) => citiesApi.setParticipants(id, input), + onSuccess: () => invalidate(id), + }); +} + +export function useSetLinks(id: number) { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (input: SetLinksInput) => citiesApi.setLinks(id, input), + onSuccess: () => invalidate(id), + }); +} + +export function useUploadPhoto(id: number) { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: (file: File) => citiesApi.uploadPhoto(id, file), + onSuccess: () => invalidate(id), + }); +} + +export function useDeletePhoto(id: number) { + const invalidate = useInvalidateCities(); + return useMutation({ + mutationFn: () => citiesApi.deletePhoto(id), + onSuccess: () => invalidate(id), + }); +} diff --git a/client/src/hooks/useUsers.ts b/client/src/hooks/useUsers.ts new file mode 100644 index 0000000..f2b65f3 --- /dev/null +++ b/client/src/hooks/useUsers.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { CreateUserInput, UpdateUserInput, ResetPasswordInput } from '@citytracker/shared'; +import * as usersApi from '../api/users'; + +const USERS_KEY = ['users']; + +export function useUsers() { + return useQuery({ queryKey: USERS_KEY, queryFn: usersApi.listUsers }); +} + +export function useCreateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateUserInput) => usersApi.createUser(input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: USERS_KEY }), + }); +} + +export function useUpdateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateUserInput }) => + usersApi.updateUser(id, input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: USERS_KEY }), + }); +} + +export function useResetPassword() { + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: ResetPasswordInput }) => + usersApi.resetPassword(id, input), + }); +} + +export function useDeleteUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => usersApi.deleteUser(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: USERS_KEY }), + }); +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..00bb1e0 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/global.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/client/src/pages/AdminUsersPage.tsx b/client/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..73be4e6 --- /dev/null +++ b/client/src/pages/AdminUsersPage.tsx @@ -0,0 +1,171 @@ +import { useState, type FormEvent } from 'react'; +import { useUsers, useCreateUser, useUpdateUser, useResetPassword } from '../hooks/useUsers'; +import { ApiError } from '../api/client'; + +export function AdminUsersPage() { + const { data: users, isLoading } = useUsers(); + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + const resetPassword = useResetPassword(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + async function handleCreate(e: FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await createUser.mutateAsync({ + username, + password, + displayName: displayName || undefined, + isAdmin, + }); + setUsername(''); + setPassword(''); + setDisplayName(''); + setIsAdmin(false); + } catch (err) { + const code = + err instanceof ApiError && err.body && typeof err.body === 'object' && 'error' in err.body + ? String((err.body as { error: unknown }).error) + : null; + setError( + code === 'username_taken' + ? 'That username is already taken.' + : 'Could not create user. Check the fields and try again.', + ); + } finally { + setSubmitting(false); + } + } + + async function handleToggleAdmin(id: number, current: boolean) { + try { + await updateUser.mutateAsync({ id, input: { isAdmin: !current } }); + } catch { + window.alert( + 'Could not change admin status — you may be trying to demote the last remaining admin.', + ); + } + } + + async function handleToggleActive(id: number, current: boolean) { + try { + await updateUser.mutateAsync({ id, input: { isActive: !current } }); + } catch { + window.alert( + 'Could not change this account — you may be trying to deactivate the last remaining admin.', + ); + } + } + + async function handleResetPassword(id: number) { + const newPassword = window.prompt('Enter a new password (min 8 characters):'); + if (!newPassword) return; + try { + await resetPassword.mutateAsync({ id, input: { newPassword } }); + window.alert('Password reset.'); + } catch { + window.alert('Could not reset password. Make sure it is at least 8 characters.'); + } + } + + return ( +
+

Users

+ + + + + + + + + + + + + {isLoading && ( + + + + )} + {(users ?? []).map((u) => ( + + + + + + + + ))} + +
UsernameDisplay nameAdminActiveActions
Loading…
{u.username}{u.displayName || '—'} + + + + + +
+ +

Add a user

+
+ + + + + {error &&

{error}

} + +
+
+ ); +} diff --git a/client/src/pages/CityDetailPage.tsx b/client/src/pages/CityDetailPage.tsx new file mode 100644 index 0000000..5fad5d4 --- /dev/null +++ b/client/src/pages/CityDetailPage.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import type { CityStatus, GeocodeResult } from '@citytracker/shared'; +import { useAuth } from '../context/AuthContext'; +import { + useCity, + useCreateCity, + useUpdateCity, + useDeleteCity, + useSetParticipants, + useSetLinks, + useUploadPhoto, + useDeletePhoto, +} from '../hooks/useCities'; +import { CitySearchInput } from '../components/city/CitySearchInput'; +import { LinkListEditor } from '../components/city/LinkListEditor'; +import { ParticipantPicker } from '../components/city/ParticipantPicker'; +import { PhotoUploader } from '../components/city/PhotoUploader'; + +interface CityDetailPageProps { + mode: 'new' | 'edit'; +} + +interface NavState { + lat?: number; + lng?: number; +} + +export function CityDetailPage({ mode }: CityDetailPageProps) { + const { id } = useParams(); + const cityId = mode === 'edit' ? Number(id) : undefined; + const location = useLocation(); + const navigate = useNavigate(); + const { user } = useAuth(); + + const { data: city, isLoading } = useCity(cityId); + const createCity = useCreateCity(); + const updateCity = useUpdateCity(cityId ?? -1); + const deleteCity = useDeleteCity(); + const setParticipants = useSetParticipants(cityId ?? -1); + const setLinks = useSetLinks(cityId ?? -1); + const uploadPhoto = useUploadPhoto(cityId ?? -1); + const deletePhotoMutation = useDeletePhoto(cityId ?? -1); + + const navState = location.state as NavState | null; + + const [name, setName] = useState(''); + const [country, setCountry] = useState(''); + const [countryCode, setCountryCode] = useState(null); + const [lat, setLat] = useState(navState?.lat ?? null); + const [lng, setLng] = useState(navState?.lng ?? null); + const [status, setStatus] = useState('visited'); + const [visitDateStart, setVisitDateStart] = useState(''); + const [visitDateEnd, setVisitDateEnd] = useState(''); + const [notes, setNotes] = useState(''); + const [liked, setLiked] = useState(false); + const [participantIds, setParticipantIds] = useState(user ? [user.id] : []); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (city) { + setName(city.name); + setCountry(city.country); + setCountryCode(city.countryCode); + setLat(city.lat); + setLng(city.lng); + setStatus(city.status); + setVisitDateStart(city.visitDateStart ?? ''); + setVisitDateEnd(city.visitDateEnd ?? ''); + setNotes(city.notes); + setLiked(city.liked); + setParticipantIds(city.participants.map((p) => p.id)); + } + }, [city]); + + function handleGeocodeSelect(result: GeocodeResult) { + setName(result.displayName.split(',')[0]); + setCountry(result.country ?? ''); + setCountryCode(result.countryCode); + setLat(result.lat); + setLng(result.lng); + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + + if (lat === null || lng === null) { + setError('Please select a location by searching or clicking the map.'); + return; + } + + setSubmitting(true); + try { + if (mode === 'new') { + const created = await createCity.mutateAsync({ + name, + country, + countryCode: countryCode ?? null, + lat, + lng, + status, + visitDateStart: visitDateStart || undefined, + visitDateEnd: visitDateEnd || undefined, + notes, + liked, + participantIds, + }); + navigate(`/cities/${created.id}`, { replace: true }); + } else if (cityId) { + await updateCity.mutateAsync({ + name, + country, + countryCode, + lat, + lng, + status, + visitDateStart: visitDateStart || null, + visitDateEnd: visitDateEnd || null, + notes, + liked, + }); + await setParticipants.mutateAsync({ userIds: participantIds }); + } + } catch { + setError('Could not save the city. Please check the form and try again.'); + } finally { + setSubmitting(false); + } + } + + async function handleDelete() { + if (!cityId) return; + if (!window.confirm(`Delete ${city?.name}? This cannot be undone.`)) return; + await deleteCity.mutateAsync(cityId); + navigate('/', { replace: true }); + } + + if (mode === 'edit' && isLoading) { + return
Loading…
; + } + + return ( +
+

{mode === 'new' ? 'Add a city' : city?.name}

+
+ {mode === 'new' && ( + + )} +
+ + +
+ {lat !== null && lng !== null && ( +

+ Location: {lat.toFixed(4)}, {lng.toFixed(4)} +

+ )} +
+ + +
+
+ + +
+