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 <noreply@anthropic.com>
This commit is contained in:
parent
65c41d594b
commit
3debb1ce1e
66 changed files with 10359 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
.tools
|
||||||
|
data
|
||||||
|
data-dev
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -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
|
||||||
23
.eslintrc.json
Normal file
23
.eslintrc.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
node_modules/
|
||||||
|
.tools/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
152
README.md
152
README.md
|
|
@ -1,2 +1,154 @@
|
||||||
# CityTracker
|
# 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://<your-host>:<HOST_PORT>` (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:<HOST_PORT>` (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/<random-filename>` 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.
|
||||||
|
|
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||||
|
<title>CityTracker</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
client/package.json
Normal file
30
client/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
client/src/App.tsx
Normal file
52
client/src/App.tsx
Normal file
|
|
@ -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 <div className="page-loading">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<MapDashboardPage />} />
|
||||||
|
<Route path="/cities/new" element={<CityDetailPage mode="new" />} />
|
||||||
|
<Route path="/cities/:id" element={<CityDetailPage mode="edit" />} />
|
||||||
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
client/src/api/auth.ts
Normal file
22
client/src/api/auth.ts
Normal file
|
|
@ -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<User>('/auth/login', { username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return api.post<void>('/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMe() {
|
||||||
|
return api.get<User>('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSetupStatus() {
|
||||||
|
return api.get<SetupStatus>('/auth/setup-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup(username: string, password: string) {
|
||||||
|
return api.post<User>('/auth/setup', { username, password });
|
||||||
|
}
|
||||||
60
client/src/api/cities.ts
Normal file
60
client/src/api/cities.ts
Normal file
|
|
@ -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<City[]>(`/cities${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCity(id: number) {
|
||||||
|
return api.get<City>(`/cities/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCity(input: CreateCityInput) {
|
||||||
|
return api.post<City>('/cities', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCity(id: number, input: UpdateCityInput) {
|
||||||
|
return api.patch<City>(`/cities/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCity(id: number) {
|
||||||
|
return api.delete<void>(`/cities/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setParticipants(id: number, input: SetParticipantsInput) {
|
||||||
|
return api.put<City>(`/cities/${id}/participants`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLinks(id: number, input: SetLinksInput) {
|
||||||
|
return api.put<City>(`/cities/${id}/links`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadPhoto(id: number, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', file);
|
||||||
|
return api.postForm<City>(`/cities/${id}/photo`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePhoto(id: number) {
|
||||||
|
return api.delete<void>(`/cities/${id}/photo`);
|
||||||
|
}
|
||||||
61
client/src/api/client.ts
Normal file
61
client/src/api/client.ts
Normal file
|
|
@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
patch: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
put: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
postForm: <T>(path: string, formData: FormData) =>
|
||||||
|
request<T>(path, { method: 'POST', body: formData }),
|
||||||
|
};
|
||||||
10
client/src/api/geocode.ts
Normal file
10
client/src/api/geocode.ts
Normal file
|
|
@ -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}`);
|
||||||
|
}
|
||||||
22
client/src/api/users.ts
Normal file
22
client/src/api/users.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { User, CreateUserInput, UpdateUserInput, ResetPasswordInput } from '@citytracker/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export function listUsers() {
|
||||||
|
return api.get<User[]>('/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(input: CreateUserInput) {
|
||||||
|
return api.post<User>('/users', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: number, input: UpdateUserInput) {
|
||||||
|
return api.patch<User>(`/users/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPassword(id: number, input: ResetPasswordInput) {
|
||||||
|
return api.post<void>(`/users/${id}/reset-password`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: number) {
|
||||||
|
return api.delete<void>(`/users/${id}`);
|
||||||
|
}
|
||||||
77
client/src/components/city/CitySearchInput.tsx
Normal file
77
client/src/components/city/CitySearchInput.tsx
Normal file
|
|
@ -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<GeocodeResult[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
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 (
|
||||||
|
<div className="city-search">
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => results.length > 0 && setOpen(true)}
|
||||||
|
placeholder="Start typing a city name…"
|
||||||
|
/>
|
||||||
|
{loading && <span className="search-loading">Searching…</span>}
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<ul className="search-results">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<button type="button" onClick={() => handleSelect(result)}>
|
||||||
|
{result.displayName}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{open && !loading && results.length === 0 && query.trim().length >= 3 && (
|
||||||
|
<div className="search-empty">
|
||||||
|
No results — you can still enter details manually or click the map.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
client/src/components/city/LinkListEditor.tsx
Normal file
82
client/src/components/city/LinkListEditor.tsx
Normal file
|
|
@ -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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftLink {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
|
||||||
|
const [draft, setDraft] = useState<DraftLink[]>(
|
||||||
|
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 (
|
||||||
|
<div className="link-editor">
|
||||||
|
{draft.map((link, index) => (
|
||||||
|
<div className="link-row" key={index}>
|
||||||
|
<input
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
value={link.label}
|
||||||
|
onChange={(e) => updateLink(index, 'label', e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="https://…"
|
||||||
|
value={link.url}
|
||||||
|
onChange={(e) => updateLink(index, 'url', e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => removeLink(index)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="link-actions">
|
||||||
|
<button type="button" className="btn btn-small" onClick={addLink}>
|
||||||
|
+ Add link
|
||||||
|
</button>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-small"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save links'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
client/src/components/city/ParticipantPicker.tsx
Normal file
36
client/src/components/city/ParticipantPicker.tsx
Normal file
|
|
@ -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 <p>Loading participants…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="participant-picker">
|
||||||
|
<legend>Who was there?</legend>
|
||||||
|
{(users ?? []).map((u) => (
|
||||||
|
<label
|
||||||
|
key={u.id}
|
||||||
|
className={`participant-option ${!u.isActive ? 'participant-inactive' : ''}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={selectedIds.includes(u.id)} onChange={() => toggle(u.id)} />
|
||||||
|
{u.displayName || u.username}
|
||||||
|
{!u.isActive && ' (deactivated)'}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
client/src/components/city/PhotoUploader.tsx
Normal file
60
client/src/components/city/PhotoUploader.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useRef, useState, type ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
interface PhotoUploaderProps {
|
||||||
|
photoFilename: string | null;
|
||||||
|
onUpload: (file: File) => Promise<unknown>;
|
||||||
|
onRemove: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoUploader({ photoFilename, onUpload, onRemove }: PhotoUploaderProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="photo-uploader">
|
||||||
|
{photoFilename ? (
|
||||||
|
<div className="photo-preview">
|
||||||
|
<img src={`/uploads/${photoFilename}`} alt="City" />
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleRemove} disabled={busy}>
|
||||||
|
Remove photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>No photo yet.</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/src/components/layout/AppShell.tsx
Normal file
35
client/src/components/layout/AppShell.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-header">
|
||||||
|
<span className="app-title">CityTracker</span>
|
||||||
|
<nav className="app-nav">
|
||||||
|
<NavLink to="/" end>
|
||||||
|
Map
|
||||||
|
</NavLink>
|
||||||
|
{user?.isAdmin && <NavLink to="/admin/users">Users</NavLink>}
|
||||||
|
</nav>
|
||||||
|
<div className="app-user">
|
||||||
|
<span className="app-user-name">{user?.displayName || user?.username}</span>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="app-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
client/src/components/layout/ProtectedRoute.tsx
Normal file
12
client/src/components/layout/ProtectedRoute.tsx
Normal file
|
|
@ -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 <Navigate to="/setup" replace />;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
46
client/src/components/map/CityMap.tsx
Normal file
46
client/src/components/map/CityMap.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<MapContainer center={[20, 10]} zoom={2} minZoom={2} className="city-map" worldCopyJump>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<ClickHandler onMapClick={onMapClick} />
|
||||||
|
{cities.map((city) => (
|
||||||
|
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city)}>
|
||||||
|
<Popup>
|
||||||
|
<div className="map-popup">
|
||||||
|
<strong>{city.name}</strong>
|
||||||
|
<div>{city.country}</div>
|
||||||
|
<button type="button" className="btn btn-small" onClick={() => navigate(`/cities/${city.id}`)}>
|
||||||
|
View details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client/src/components/map/CityMarker.tsx
Normal file
14
client/src/components/map/CityMarker.tsx
Normal file
|
|
@ -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 ? '<span class="marker-heart">♥</span>' : '';
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'city-marker',
|
||||||
|
html: `<span class="marker-pin" style="background:${color}"></span>${heart}`,
|
||||||
|
iconSize: [22, 22],
|
||||||
|
iconAnchor: [11, 11],
|
||||||
|
popupAnchor: [0, -14],
|
||||||
|
});
|
||||||
|
}
|
||||||
68
client/src/components/map/MapFilters.tsx
Normal file
68
client/src/components/map/MapFilters.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="map-filters">
|
||||||
|
<div className="filter-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chip ${filters.status === 'visited' ? 'chip-active' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...filters, status: filters.status === 'visited' ? undefined : 'visited' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Visited
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chip ${filters.status === 'planned' ? 'chip-active' : ''}`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...filters, status: filters.status === 'planned' ? undefined : 'planned' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Planned
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chip ${filters.favorite ? 'chip-active' : ''}`}
|
||||||
|
onClick={() => onChange({ ...filters, favorite: !filters.favorite })}
|
||||||
|
>
|
||||||
|
♥ Favorites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group">
|
||||||
|
<select
|
||||||
|
value={filters.year ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...filters, year: e.target.value ? Number(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">All years</option>
|
||||||
|
{years.map((year) => (
|
||||||
|
<option key={year} value={year}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filters.country ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...filters, country: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">All countries</option>
|
||||||
|
{countries.map((country) => (
|
||||||
|
<option key={country} value={country}>
|
||||||
|
{country}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
client/src/context/AuthContext.tsx
Normal file
75
client/src/context/AuthContext.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
completeSetup: (username: string, password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(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 (
|
||||||
|
<AuthContext.Provider value={{ user, loading, needsSetup, login, logout, completeSetup }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
92
client/src/hooks/useCities.ts
Normal file
92
client/src/hooks/useCities.ts
Normal file
|
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
41
client/src/hooks/useUsers.ts
Normal file
41
client/src/hooks/useUsers.ts
Normal file
|
|
@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
|
|
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
171
client/src/pages/AdminUsersPage.tsx
Normal file
171
client/src/pages/AdminUsersPage.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||||
|
<div className="admin-users-page">
|
||||||
|
<h1>Users</h1>
|
||||||
|
|
||||||
|
<table className="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Display name</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>Loading…</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{(users ?? []).map((u) => (
|
||||||
|
<tr key={u.id} className={!u.isActive ? 'row-inactive' : ''}>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td>{u.displayName || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => handleToggleAdmin(u.id, u.isAdmin)}
|
||||||
|
>
|
||||||
|
{u.isAdmin ? 'Demote' : 'Promote'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => handleToggleActive(u.id, u.isActive)}
|
||||||
|
>
|
||||||
|
{u.isActive ? 'Deactivate' : 'Reactivate'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => handleResetPassword(u.id)}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Add a user</h2>
|
||||||
|
<form className="create-user-form" onSubmit={handleCreate}>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="like-toggle">
|
||||||
|
<input type="checkbox" checked={isAdmin} onChange={(e) => setIsAdmin(e.target.checked)} />
|
||||||
|
Admin
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
client/src/pages/CityDetailPage.tsx
Normal file
247
client/src/pages/CityDetailPage.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||||
|
const [lat, setLat] = useState<number | null>(navState?.lat ?? null);
|
||||||
|
const [lng, setLng] = useState<number | null>(navState?.lng ?? null);
|
||||||
|
const [status, setStatus] = useState<CityStatus>('visited');
|
||||||
|
const [visitDateStart, setVisitDateStart] = useState('');
|
||||||
|
const [visitDateEnd, setVisitDateEnd] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [participantIds, setParticipantIds] = useState<number[]>(user ? [user.id] : []);
|
||||||
|
const [error, setError] = useState<string | null>(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 <div className="page-loading">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="city-detail-page">
|
||||||
|
<h1>{mode === 'new' ? 'Add a city' : city?.name}</h1>
|
||||||
|
<form className="city-form" onSubmit={handleSubmit}>
|
||||||
|
{mode === 'new' && (
|
||||||
|
<label>
|
||||||
|
Search for a city
|
||||||
|
<CitySearchInput onSelect={handleGeocodeSelect} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Country
|
||||||
|
<input value={country} onChange={(e) => setCountry(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{lat !== null && lng !== null && (
|
||||||
|
<p className="coords-hint">
|
||||||
|
Location: {lat.toFixed(4)}, {lng.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select value={status} onChange={(e) => setStatus(e.target.value as CityStatus)}>
|
||||||
|
<option value="visited">Visited</option>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="like-toggle">
|
||||||
|
<input type="checkbox" checked={liked} onChange={(e) => setLiked(e.target.checked)} />
|
||||||
|
♥ Liked
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Visit start date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={visitDateStart}
|
||||||
|
onChange={(e) => setVisitDateStart(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Visit end date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={visitDateEnd}
|
||||||
|
onChange={(e) => setVisitDateEnd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="What did you like? Tips, restaurants, ..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ParticipantPicker selectedIds={participantIds} onChange={setParticipantIds} />
|
||||||
|
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Saving…' : mode === 'new' ? 'Add city' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>
|
||||||
|
Delete city
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{mode === 'edit' && cityId && city && (
|
||||||
|
<>
|
||||||
|
<section className="city-section">
|
||||||
|
<h2>Photo</h2>
|
||||||
|
<PhotoUploader
|
||||||
|
photoFilename={city.photoFilename}
|
||||||
|
onUpload={(file) => uploadPhoto.mutateAsync(file)}
|
||||||
|
onRemove={() => deletePhotoMutation.mutateAsync()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="city-section">
|
||||||
|
<h2>Links</h2>
|
||||||
|
<LinkListEditor links={city.links} onSave={(links) => setLinks.mutateAsync({ links })} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
client/src/pages/LoginPage.tsx
Normal file
64
client/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, user, needsSetup } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (needsSetup) return <Navigate to="/setup" replace />;
|
||||||
|
if (user) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError && err.status === 401
|
||||||
|
? 'Invalid username or password.'
|
||||||
|
: 'Something went wrong. Please try again.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<h1>CityTracker</h1>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Logging in…' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
client/src/pages/MapDashboardPage.tsx
Normal file
52
client/src/pages/MapDashboardPage.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCities } from '../hooks/useCities';
|
||||||
|
import type { CityListFilters } from '../api/cities';
|
||||||
|
import { CityMap } from '../components/map/CityMap';
|
||||||
|
import { MapFilters } from '../components/map/MapFilters';
|
||||||
|
|
||||||
|
export function MapDashboardPage() {
|
||||||
|
const [filters, setFilters] = useState<CityListFilters>({});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: cities, isLoading } = useCities(filters);
|
||||||
|
const { data: allCities } = useCities({});
|
||||||
|
|
||||||
|
const countries = useMemo(
|
||||||
|
() => Array.from(new Set((allCities ?? []).map((c) => c.country))).sort(),
|
||||||
|
[allCities],
|
||||||
|
);
|
||||||
|
|
||||||
|
const years = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
(allCities ?? [])
|
||||||
|
.flatMap((c) => [c.visitDateStart, c.visitDateEnd])
|
||||||
|
.filter((d): d is string => !!d)
|
||||||
|
.map((d) => Number(d.slice(0, 4))),
|
||||||
|
),
|
||||||
|
).sort((a, b) => b - a),
|
||||||
|
[allCities],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleMapClick(lat: number, lng: number) {
|
||||||
|
navigate('/cities/new', { state: { lat, lng } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-dashboard">
|
||||||
|
<MapFilters filters={filters} countries={countries} years={years} onChange={setFilters} />
|
||||||
|
<div className="map-container">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="page-loading">Loading map…</div>
|
||||||
|
) : (
|
||||||
|
<CityMap cities={cities ?? []} onMapClick={handleMapClick} />
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-primary fab" onClick={() => navigate('/cities/new')}>
|
||||||
|
+ Add city
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
client/src/pages/SetupPage.tsx
Normal file
88
client/src/pages/SetupPage.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
|
|
||||||
|
function extractErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'error' in err.body) {
|
||||||
|
const code = String((err.body as { error: unknown }).error);
|
||||||
|
if (code === 'username_taken') return 'That username is already taken.';
|
||||||
|
if (code === 'setup_already_complete') return 'Setup was already completed. Please log in.';
|
||||||
|
}
|
||||||
|
return 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupPage() {
|
||||||
|
const { completeSetup, user, needsSetup } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (user) return <Navigate to="/" replace />;
|
||||||
|
if (!needsSetup) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await completeSetup(username, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<h1>Welcome to CityTracker</h1>
|
||||||
|
<p className="auth-hint">Create the first admin account to get started.</p>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Confirm password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Creating…' : 'Create admin account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
571
client/src/styles/global.css
Normal file
571
client/src/styles/global.css
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
:root {
|
||||||
|
--color-bg: #f7f7f5;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-border: #e0e0dc;
|
||||||
|
--color-text: #1f2421;
|
||||||
|
--color-text-muted: #6b7370;
|
||||||
|
--color-primary: #1971c2;
|
||||||
|
--color-primary-dark: #145591;
|
||||||
|
--color-danger: #c92a2a;
|
||||||
|
--color-success: #2f9e44;
|
||||||
|
--radius: 8px;
|
||||||
|
--spacing-1: 4px;
|
||||||
|
--spacing-2: 8px;
|
||||||
|
--spacing-3: 12px;
|
||||||
|
--spacing-4: 16px;
|
||||||
|
--spacing-5: 24px;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #14171a;
|
||||||
|
--color-surface: #1c2024;
|
||||||
|
--color-border: #2b3136;
|
||||||
|
--color-text: #e9ecef;
|
||||||
|
--color-text-muted: #9aa4ab;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
padding: var(--spacing-2) var(--spacing-4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: var(--spacing-1) var(--spacing-3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App shell */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--spacing-1) var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: rgba(25, 113, 194, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-user-name {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth pages */
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: var(--spacing-5);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form h1 {
|
||||||
|
margin: 0 0 var(--spacing-2);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input {
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map dashboard */
|
||||||
|
|
||||||
|
.map-dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: var(--spacing-1) var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: var(--spacing-1) var(--spacing-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-map {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--spacing-4);
|
||||||
|
bottom: var(--spacing-4);
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-pin {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-heart {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -6px;
|
||||||
|
color: #e64980;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-popup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* City form */
|
||||||
|
|
||||||
|
.city-detail-page {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-form input,
|
||||||
|
.city-form select,
|
||||||
|
.city-form textarea {
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row > label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-toggle {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn-danger {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-section {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-section h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* City search */
|
||||||
|
|
||||||
|
.city-search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--spacing-1) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results li button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results li button:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-loading,
|
||||||
|
.search-empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link editor */
|
||||||
|
|
||||||
|
.link-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Participant picker */
|
||||||
|
|
||||||
|
.participant-picker {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-option {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-inactive {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo uploader */
|
||||||
|
|
||||||
|
.photo-uploader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin users */
|
||||||
|
|
||||||
|
.admin-users-page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-2) var(--spacing-3);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-inactive {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user-form input {
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
17
client/tsconfig.json
Normal file
17
client/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
client/vite.config.ts
Normal file
16
client/vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/uploads': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
services:
|
||||||
|
citytracker:
|
||||||
|
build: .
|
||||||
|
image: citytracker:latest
|
||||||
|
container_name: citytracker
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DB_PATH: /app/data/citytracker.db
|
||||||
|
UPLOAD_DIR: /app/data/uploads
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
volumes:
|
||||||
|
- citytracker-data:/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
citytracker-data:
|
||||||
6425
package-lock.json
generated
Normal file
6425
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "citytracker",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "npm run dev -w server",
|
||||||
|
"dev:client": "npm run dev -w client",
|
||||||
|
"build": "npm run build -w shared && npm run build -w client && npm run build -w server",
|
||||||
|
"start": "npm run start -w server",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
server/package.json
Normal file
31
server/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "@citytracker/server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && node -e \"require('fs').cpSync('src/db/migrations','dist/db/migrations',{recursive:true})\"",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@citytracker/shared": "*",
|
||||||
|
"argon2": "^0.40.3",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20.14.15",
|
||||||
|
"rimraf": "^5.0.9",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
51
server/src/app.ts
Normal file
51
server/src/app.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { sessionMiddleware } from './middleware/session';
|
||||||
|
import { authRouter } from './routes/auth.routes';
|
||||||
|
import { usersRouter } from './routes/users.routes';
|
||||||
|
import { citiesRouter } from './routes/cities.routes';
|
||||||
|
import { geocodeRouter } from './routes/geocode.routes';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/users', usersRouter);
|
||||||
|
app.use('/api/cities', citiesRouter);
|
||||||
|
app.use('/api/geocode', geocodeRouter);
|
||||||
|
|
||||||
|
app.use('/uploads', express.static(config.uploadDir));
|
||||||
|
|
||||||
|
app.use(express.static(config.clientDistDir));
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(config.clientDistDir, 'index.html'), (err) => {
|
||||||
|
if (err) next(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
res.status(400).json({ error: 'upload_error', message: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err instanceof Error && err.message === 'unsupported_file_type') {
|
||||||
|
res.status(400).json({ error: 'unsupported_file_type' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
37
server/src/config.ts
Normal file
37
server/src/config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
function readBool(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
return value.toLowerCase() === 'true' || value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
let sessionSecret = process.env.SESSION_SECRET;
|
||||||
|
if (!sessionSecret) {
|
||||||
|
if (isProduction) {
|
||||||
|
throw new Error('SESSION_SECRET must be set in production. Set it in your .env file.');
|
||||||
|
}
|
||||||
|
sessionSecret = crypto.randomBytes(32).toString('hex');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
'SESSION_SECRET is not set — using an ephemeral random secret for this dev run. ' +
|
||||||
|
'Set SESSION_SECRET in .env for a stable/production setup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
isProduction,
|
||||||
|
port: Number(process.env.PORT) || 3000,
|
||||||
|
dbPath: process.env.DB_PATH || path.join(process.cwd(), 'data', 'citytracker.db'),
|
||||||
|
uploadDir: process.env.UPLOAD_DIR || path.join(process.cwd(), 'data', 'uploads'),
|
||||||
|
sessionSecret,
|
||||||
|
cookieSecure: readBool(process.env.COOKIE_SECURE, false),
|
||||||
|
adminUsername: process.env.ADMIN_USERNAME,
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD,
|
||||||
|
// __dirname is server/dist at runtime (or server/src under tsx) — resolve relative to this
|
||||||
|
// file's location, not process.cwd(), since cwd depends on how the process was launched.
|
||||||
|
clientDistDir:
|
||||||
|
process.env.CLIENT_DIST_DIR || path.join(__dirname, '..', '..', 'client', 'dist'),
|
||||||
|
};
|
||||||
11
server/src/db/connection.ts
Normal file
11
server/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||||
|
fs.mkdirSync(config.uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
export const db = new Database(config.dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
36
server/src/db/migrate.ts
Normal file
36
server/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
|
export function migrate(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const applied = new Set(
|
||||||
|
db.prepare('SELECT name FROM _migrations').all().map((row: any) => row.name as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith('.sql'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(file)) continue;
|
||||||
|
|
||||||
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
db.exec(sql);
|
||||||
|
db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file);
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Applied migration ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
server/src/db/migrations/0001_init.sql
Normal file
53
server/src/db/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0,1)),
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
country_code TEXT,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('visited','planned')),
|
||||||
|
visit_date_start TEXT,
|
||||||
|
visit_date_end TEXT,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
liked INTEGER NOT NULL DEFAULT 0 CHECK (liked IN (0,1)),
|
||||||
|
photo_filename TEXT,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_cities_status ON cities(status);
|
||||||
|
CREATE INDEX idx_cities_country ON cities(country);
|
||||||
|
CREATE INDEX idx_cities_liked ON cities(liked);
|
||||||
|
|
||||||
|
CREATE TABLE city_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_city_links_city_id ON city_links(city_id);
|
||||||
|
|
||||||
|
CREATE TABLE city_participants (
|
||||||
|
city_id INTEGER NOT NULL REFERENCES cities(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (city_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_city_participants_user_id ON city_participants(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
sess TEXT NOT NULL,
|
||||||
|
expires INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_expires ON sessions(expires);
|
||||||
24
server/src/index.ts
Normal file
24
server/src/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { db } from './db/connection';
|
||||||
|
import { migrate } from './db/migrate';
|
||||||
|
import { cleanupExpiredSessions } from './middleware/session';
|
||||||
|
import { bootstrapAdmin } from './services/auth.service';
|
||||||
|
import { createApp } from './app';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
migrate(db);
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
await bootstrapAdmin();
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`CityTracker server listening on port ${config.port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Fatal startup error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
10
server/src/middleware/requireAdmin.ts
Normal file
10
server/src/middleware/requireAdmin.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Response, NextFunction } from 'express';
|
||||||
|
import type { AuthedRequest } from './requireAuth';
|
||||||
|
|
||||||
|
export function requireAdmin(req: AuthedRequest, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.currentUser?.isAdmin) {
|
||||||
|
res.status(403).json({ error: 'forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
25
server/src/middleware/requireAuth.ts
Normal file
25
server/src/middleware/requireAuth.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import type { User } from '@citytracker/shared';
|
||||||
|
import * as usersService from '../services/users.service';
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
currentUser?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({ error: 'not_authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = usersService.getUserById(userId);
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
req.session.destroy(() => undefined);
|
||||||
|
res.status(401).json({ error: 'not_authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.currentUser = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
34
server/src/middleware/requireParticipant.ts
Normal file
34
server/src/middleware/requireParticipant.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { Response, NextFunction } from 'express';
|
||||||
|
import type { City } from '@citytracker/shared';
|
||||||
|
import type { AuthedRequest } from './requireAuth';
|
||||||
|
import * as citiesService from '../services/cities.service';
|
||||||
|
|
||||||
|
export interface CityScopedRequest extends AuthedRequest {
|
||||||
|
city?: City;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireParticipant(
|
||||||
|
req: CityScopedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): void {
|
||||||
|
const cityId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(cityId)) {
|
||||||
|
res.status(400).json({ error: 'invalid_city_id' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = citiesService.getCityById(cityId);
|
||||||
|
if (!city) {
|
||||||
|
res.status(404).json({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.currentUser || !citiesService.isParticipant(cityId, req.currentUser.id)) {
|
||||||
|
res.status(403).json({ error: 'forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.city = city;
|
||||||
|
next();
|
||||||
|
}
|
||||||
76
server/src/middleware/session.ts
Normal file
76
server/src/middleware/session.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import session from 'express-session';
|
||||||
|
import { db } from '../db/connection';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
class SqliteSessionStore extends session.Store {
|
||||||
|
get(
|
||||||
|
sid: string,
|
||||||
|
callback: (err: unknown, session?: session.SessionData | null) => void,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid) as
|
||||||
|
| { sess: string; expires: number }
|
||||||
|
| undefined;
|
||||||
|
if (!row || row.expires < Date.now()) {
|
||||||
|
callback(null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, JSON.parse(row.sess));
|
||||||
|
} catch (err) {
|
||||||
|
callback(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
|
||||||
|
try {
|
||||||
|
const expires = Date.now() + SESSION_MAX_AGE_MS;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (sid, sess, expires) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires`,
|
||||||
|
).run(sid, JSON.stringify(sessionData), expires);
|
||||||
|
callback?.();
|
||||||
|
} catch (err) {
|
||||||
|
callback?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(sid: string, callback?: (err?: unknown) => void): void {
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||||
|
callback?.();
|
||||||
|
} catch (err) {
|
||||||
|
callback?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(sid: string, _sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
|
||||||
|
try {
|
||||||
|
const expires = Date.now() + SESSION_MAX_AGE_MS;
|
||||||
|
db.prepare('UPDATE sessions SET expires = ? WHERE sid = ?').run(expires, sid);
|
||||||
|
callback?.();
|
||||||
|
} catch (err) {
|
||||||
|
callback?.(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupExpiredSessions(): void {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionMiddleware = session({
|
||||||
|
store: new SqliteSessionStore(),
|
||||||
|
secret: config.sessionSecret,
|
||||||
|
name: 'citytracker.sid',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
rolling: true,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: config.cookieSecure,
|
||||||
|
maxAge: SESSION_MAX_AGE_MS,
|
||||||
|
},
|
||||||
|
});
|
||||||
85
server/src/routes/auth.routes.ts
Normal file
85
server/src/routes/auth.routes.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { loginSchema, setupSchema } from '@citytracker/shared';
|
||||||
|
import * as usersService from '../services/users.service';
|
||||||
|
import * as authService from '../services/auth.service';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/requireAuth';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.get('/setup-status', (_req, res) => {
|
||||||
|
res.json({ needsSetup: authService.needsSetup() });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/setup', async (req, res) => {
|
||||||
|
if (!authService.needsSetup()) {
|
||||||
|
res.status(409).json({ error: 'setup_already_complete' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = setupSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usersService.getUserRowByUsername(parsed.data.username)) {
|
||||||
|
res.status(409).json({ error: 'username_taken' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await authService.hashPassword(parsed.data.password);
|
||||||
|
const user = usersService.createUser({
|
||||||
|
username: parsed.data.username,
|
||||||
|
passwordHash,
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: 'session_error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.session.userId = user.id;
|
||||||
|
res.status(201).json(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/login', async (req, res) => {
|
||||||
|
const parsed = loginSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = usersService.getUserRowByUsername(parsed.data.username);
|
||||||
|
if (!row || !row.is_active) {
|
||||||
|
res.status(401).json({ error: 'invalid_credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await authService.verifyPassword(row.password_hash, parsed.data.password);
|
||||||
|
if (!valid) {
|
||||||
|
res.status(401).json({ error: 'invalid_credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: 'session_error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.session.userId = row.id;
|
||||||
|
res.json(usersService.getUserById(row.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.clearCookie('citytracker.sid');
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get('/me', requireAuth, (req: AuthedRequest, res) => {
|
||||||
|
res.json(req.currentUser);
|
||||||
|
});
|
||||||
129
server/src/routes/cities.routes.ts
Normal file
129
server/src/routes/cities.routes.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
citySchema,
|
||||||
|
updateCitySchema,
|
||||||
|
setParticipantsSchema,
|
||||||
|
setLinksSchema,
|
||||||
|
} from '@citytracker/shared';
|
||||||
|
import * as citiesService from '../services/cities.service';
|
||||||
|
import * as usersService from '../services/users.service';
|
||||||
|
import { photoUpload, deletePhotoFile } from '../services/uploads.service';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/requireAuth';
|
||||||
|
import { requireParticipant, type CityScopedRequest } from '../middleware/requireParticipant';
|
||||||
|
|
||||||
|
export const citiesRouter = Router();
|
||||||
|
|
||||||
|
citiesRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function invalidParticipantIds(userIds: number[]): number[] {
|
||||||
|
return userIds.filter((id) => !usersService.getUserById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
citiesRouter.get('/', (req: AuthedRequest, res) => {
|
||||||
|
const { status, favorite, year, country } = req.query;
|
||||||
|
const cities = citiesService.listCitiesForUser(req.currentUser!.id, {
|
||||||
|
status: status === 'visited' || status === 'planned' ? status : undefined,
|
||||||
|
favorite: favorite === 'true',
|
||||||
|
year: year && !Array.isArray(year) ? Number(year) : undefined,
|
||||||
|
country: typeof country === 'string' ? country : undefined,
|
||||||
|
});
|
||||||
|
res.json(cities);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.get('/:id', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
res.json(req.city);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.post('/', (req: AuthedRequest, res) => {
|
||||||
|
const parsed = citySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidIds = invalidParticipantIds(parsed.data.participantIds ?? []);
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
res.status(400).json({ error: 'invalid_participants', ids: invalidIds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = citiesService.createCity({
|
||||||
|
...parsed.data,
|
||||||
|
createdBy: req.currentUser!.id,
|
||||||
|
});
|
||||||
|
res.status(201).json(city);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.patch('/:id', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
const parsed = updateCitySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { participantIds: _ignored, ...cityUpdates } = parsed.data;
|
||||||
|
const updated = citiesService.updateCity(req.city!.id, cityUpdates);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.delete('/:id', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
citiesService.deleteCity(req.city!.id);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.put('/:id/participants', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
const parsed = setParticipantsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidIds = invalidParticipantIds(parsed.data.userIds);
|
||||||
|
if (invalidIds.length > 0) {
|
||||||
|
res.status(400).json({ error: 'invalid_participants', ids: invalidIds });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = citiesService.setParticipants(req.city!.id, parsed.data.userIds);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.put('/:id/links', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
const parsed = setLinksSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = citiesService.setLinks(req.city!.id, parsed.data.links);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
citiesRouter.post(
|
||||||
|
'/:id/photo',
|
||||||
|
requireParticipant,
|
||||||
|
photoUpload.single('photo'),
|
||||||
|
(req: CityScopedRequest, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: 'no_file' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousFilename = req.city!.photoFilename;
|
||||||
|
citiesService.setPhotoFilename(req.city!.id, req.file.filename);
|
||||||
|
if (previousFilename) {
|
||||||
|
deletePhotoFile(previousFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(citiesService.getCityById(req.city!.id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
citiesRouter.delete('/:id/photo', requireParticipant, (req: CityScopedRequest, res) => {
|
||||||
|
const filename = req.city!.photoFilename;
|
||||||
|
citiesService.setPhotoFilename(req.city!.id, null);
|
||||||
|
if (filename) {
|
||||||
|
deletePhotoFile(filename);
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
30
server/src/routes/geocode.routes.ts
Normal file
30
server/src/routes/geocode.routes.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import * as geocodeService from '../services/geocode.service';
|
||||||
|
import { requireAuth } from '../middleware/requireAuth';
|
||||||
|
|
||||||
|
export const geocodeRouter = Router();
|
||||||
|
|
||||||
|
geocodeRouter.use(requireAuth);
|
||||||
|
|
||||||
|
geocodeRouter.get('/search', async (req, res) => {
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||||
|
if (!q) {
|
||||||
|
res.json({ results: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await geocodeService.search(q);
|
||||||
|
res.json({ results });
|
||||||
|
});
|
||||||
|
|
||||||
|
geocodeRouter.get('/reverse', async (req, res) => {
|
||||||
|
const lat = Number(req.query.lat);
|
||||||
|
const lng = Number(req.query.lng);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||||
|
res.status(400).json({ error: 'invalid_coordinates' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await geocodeService.reverse(lat, lng);
|
||||||
|
res.json({ result });
|
||||||
|
});
|
||||||
97
server/src/routes/users.routes.ts
Normal file
97
server/src/routes/users.routes.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { createUserSchema, updateUserSchema, resetPasswordSchema } from '@citytracker/shared';
|
||||||
|
import * as usersService from '../services/users.service';
|
||||||
|
import * as authService from '../services/auth.service';
|
||||||
|
import { requireAuth } from '../middleware/requireAuth';
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin';
|
||||||
|
|
||||||
|
export const usersRouter = Router();
|
||||||
|
|
||||||
|
usersRouter.use(requireAuth);
|
||||||
|
|
||||||
|
usersRouter.get('/', (_req, res) => {
|
||||||
|
res.json(usersService.listUsers());
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post('/', requireAdmin, async (req, res) => {
|
||||||
|
const parsed = createUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usersService.getUserRowByUsername(parsed.data.username)) {
|
||||||
|
res.status(409).json({ error: 'username_taken' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await authService.hashPassword(parsed.data.password);
|
||||||
|
const user = usersService.createUser({
|
||||||
|
username: parsed.data.username,
|
||||||
|
passwordHash,
|
||||||
|
displayName: parsed.data.displayName,
|
||||||
|
isAdmin: parsed.data.isAdmin,
|
||||||
|
});
|
||||||
|
res.status(201).json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.patch('/:id', requireAdmin, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const parsed = updateUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = usersService.getUserById(id);
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoting =
|
||||||
|
target.isAdmin && (parsed.data.isAdmin === false || parsed.data.isActive === false);
|
||||||
|
if (demoting && usersService.countActiveAdmins() <= 1) {
|
||||||
|
res.status(400).json({ error: 'last_admin' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = usersService.updateUser(id, parsed.data);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post('/:id/reset-password', requireAdmin, async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const parsed = resetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'invalid_input', issues: parsed.error.issues });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = usersService.getUserById(id);
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await authService.hashPassword(parsed.data.newPassword);
|
||||||
|
usersService.setPasswordHash(id, passwordHash);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.delete('/:id', requireAdmin, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const target = usersService.getUserById(id);
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isAdmin && usersService.countActiveAdmins() <= 1) {
|
||||||
|
res.status(400).json({ error: 'last_admin' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersService.updateUser(id, { isActive: false });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
40
server/src/services/auth.service.ts
Normal file
40
server/src/services/auth.service.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import { config } from '../config';
|
||||||
|
import * as usersService from './users.service';
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, { type: argon2.argon2id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await argon2.verify(hash, password);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsSetup(): boolean {
|
||||||
|
return usersService.countAdmins() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapAdmin(): Promise<void> {
|
||||||
|
if (usersService.countAdmins() > 0) return;
|
||||||
|
|
||||||
|
if (config.adminUsername && config.adminPassword) {
|
||||||
|
const passwordHash = await hashPassword(config.adminPassword);
|
||||||
|
usersService.createUser({
|
||||||
|
username: config.adminUsername,
|
||||||
|
passwordHash,
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Created initial admin user '${config.adminUsername}'`);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
'No admin user exists and ADMIN_USERNAME/ADMIN_PASSWORD are not set. ' +
|
||||||
|
'Open the app and use the first-run setup screen to create the first admin account.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
303
server/src/services/cities.service.ts
Normal file
303
server/src/services/cities.service.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { db } from '../db/connection';
|
||||||
|
import { config } from '../config';
|
||||||
|
import type { City, CityLink, CityStatus, User } from '@citytracker/shared';
|
||||||
|
|
||||||
|
interface CityRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
country_code: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
status: string;
|
||||||
|
visit_date_start: string | null;
|
||||||
|
visit_date_end: string | null;
|
||||||
|
notes: string;
|
||||||
|
liked: number;
|
||||||
|
photo_filename: string | null;
|
||||||
|
created_by: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkRow {
|
||||||
|
id: number;
|
||||||
|
city_id: number;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string | null;
|
||||||
|
is_admin: number;
|
||||||
|
is_active: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLink(row: LinkRow): CityLink {
|
||||||
|
return { id: row.id, url: row.url, label: row.label, position: row.position };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUser(row: UserRow): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isAdmin: !!row.is_admin,
|
||||||
|
isActive: !!row.is_active,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinksForCity(cityId: number): CityLink[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT * FROM city_links WHERE city_id = ? ORDER BY position, id')
|
||||||
|
.all(cityId) as LinkRow[];
|
||||||
|
return rows.map(toLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParticipantsForCity(cityId: number): User[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.* FROM users u
|
||||||
|
JOIN city_participants cp ON cp.user_id = u.id
|
||||||
|
WHERE cp.city_id = ?
|
||||||
|
ORDER BY u.username`,
|
||||||
|
)
|
||||||
|
.all(cityId) as UserRow[];
|
||||||
|
return rows.map(toUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCity(row: CityRow): City {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
country: row.country,
|
||||||
|
countryCode: row.country_code,
|
||||||
|
lat: row.lat,
|
||||||
|
lng: row.lng,
|
||||||
|
status: row.status as CityStatus,
|
||||||
|
visitDateStart: row.visit_date_start,
|
||||||
|
visitDateEnd: row.visit_date_end,
|
||||||
|
notes: row.notes,
|
||||||
|
liked: !!row.liked,
|
||||||
|
photoFilename: row.photo_filename,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
participants: getParticipantsForCity(row.id),
|
||||||
|
links: getLinksForCity(row.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityFilters {
|
||||||
|
status?: CityStatus;
|
||||||
|
favorite?: boolean;
|
||||||
|
year?: number;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCitiesForUser(userId: number, filters: CityFilters): City[] {
|
||||||
|
const conditions = ['cp.user_id = ?'];
|
||||||
|
const params: unknown[] = [userId];
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push('c.status = ?');
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
if (filters.favorite) {
|
||||||
|
conditions.push('c.liked = 1');
|
||||||
|
}
|
||||||
|
if (filters.country) {
|
||||||
|
conditions.push('c.country = ?');
|
||||||
|
params.push(filters.country);
|
||||||
|
}
|
||||||
|
if (filters.year) {
|
||||||
|
conditions.push(
|
||||||
|
"(strftime('%Y', c.visit_date_start) = ? OR strftime('%Y', c.visit_date_end) = ?)",
|
||||||
|
);
|
||||||
|
params.push(String(filters.year), String(filters.year));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT c.* FROM cities c
|
||||||
|
JOIN city_participants cp ON cp.city_id = c.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY c.created_at DESC`,
|
||||||
|
)
|
||||||
|
.all(...params) as CityRow[];
|
||||||
|
|
||||||
|
return rows.map(toCity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCityById(id: number): City | undefined {
|
||||||
|
const row = db.prepare('SELECT * FROM cities WHERE id = ?').get(id) as CityRow | undefined;
|
||||||
|
return row ? toCity(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isParticipant(cityId: number, userId: number): boolean {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT 1 FROM city_participants WHERE city_id = ? AND user_id = ?')
|
||||||
|
.get(cityId, userId);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCityParams {
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
countryCode?: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
status: CityStatus;
|
||||||
|
visitDateStart?: string | null;
|
||||||
|
visitDateEnd?: string | null;
|
||||||
|
notes?: string;
|
||||||
|
liked?: boolean;
|
||||||
|
participantIds?: number[];
|
||||||
|
createdBy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCity(params: CreateCityParams): City {
|
||||||
|
const insertCity = db.prepare(
|
||||||
|
`INSERT INTO cities
|
||||||
|
(name, country, country_code, lat, lng, status, visit_date_start, visit_date_end, notes, liked, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
const insertParticipant = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO city_participants (city_id, user_id) VALUES (?, ?)',
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
const result = insertCity.run(
|
||||||
|
params.name,
|
||||||
|
params.country,
|
||||||
|
params.countryCode ?? null,
|
||||||
|
params.lat,
|
||||||
|
params.lng,
|
||||||
|
params.status,
|
||||||
|
params.visitDateStart ?? null,
|
||||||
|
params.visitDateEnd ?? null,
|
||||||
|
params.notes ?? '',
|
||||||
|
params.liked ? 1 : 0,
|
||||||
|
params.createdBy,
|
||||||
|
);
|
||||||
|
const id = result.lastInsertRowid as number;
|
||||||
|
|
||||||
|
const participantIds = new Set([params.createdBy, ...(params.participantIds ?? [])]);
|
||||||
|
for (const userId of participantIds) {
|
||||||
|
insertParticipant.run(id, userId);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return getCityById(run())!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCityParams {
|
||||||
|
name?: string;
|
||||||
|
country?: string;
|
||||||
|
countryCode?: string | null;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
status?: CityStatus;
|
||||||
|
visitDateStart?: string | null;
|
||||||
|
visitDateEnd?: string | null;
|
||||||
|
notes?: string;
|
||||||
|
liked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCity(id: number, updates: UpdateCityParams): City | undefined {
|
||||||
|
const current = getCityById(id);
|
||||||
|
if (!current) return undefined;
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
name: updates.name ?? current.name,
|
||||||
|
country: updates.country ?? current.country,
|
||||||
|
country_code: updates.countryCode !== undefined ? updates.countryCode : current.countryCode,
|
||||||
|
lat: updates.lat ?? current.lat,
|
||||||
|
lng: updates.lng ?? current.lng,
|
||||||
|
status: updates.status ?? current.status,
|
||||||
|
visit_date_start:
|
||||||
|
updates.visitDateStart !== undefined ? updates.visitDateStart : current.visitDateStart,
|
||||||
|
visit_date_end:
|
||||||
|
updates.visitDateEnd !== undefined ? updates.visitDateEnd : current.visitDateEnd,
|
||||||
|
notes: updates.notes ?? current.notes,
|
||||||
|
liked: updates.liked !== undefined ? updates.liked : current.liked,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cities SET
|
||||||
|
name = ?, country = ?, country_code = ?, lat = ?, lng = ?, status = ?,
|
||||||
|
visit_date_start = ?, visit_date_end = ?, notes = ?, liked = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(
|
||||||
|
merged.name,
|
||||||
|
merged.country,
|
||||||
|
merged.country_code,
|
||||||
|
merged.lat,
|
||||||
|
merged.lng,
|
||||||
|
merged.status,
|
||||||
|
merged.visit_date_start,
|
||||||
|
merged.visit_date_end,
|
||||||
|
merged.notes,
|
||||||
|
merged.liked ? 1 : 0,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return getCityById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCity(id: number): void {
|
||||||
|
const city = getCityById(id);
|
||||||
|
if (!city) return;
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM cities WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
if (city.photoFilename) {
|
||||||
|
const filePath = path.join(config.uploadDir, city.photoFilename);
|
||||||
|
fs.unlink(filePath, () => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setParticipants(cityId: number, userIds: number[]): City | undefined {
|
||||||
|
const unique = Array.from(new Set(userIds));
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM city_participants WHERE city_id = ?').run(cityId);
|
||||||
|
const insert = db.prepare('INSERT INTO city_participants (city_id, user_id) VALUES (?, ?)');
|
||||||
|
for (const userId of unique) {
|
||||||
|
insert.run(cityId, userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
return getCityById(cityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLinks(
|
||||||
|
cityId: number,
|
||||||
|
links: { url: string; label: string }[],
|
||||||
|
): City | undefined {
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM city_links WHERE city_id = ?').run(cityId);
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT INTO city_links (city_id, url, label, position) VALUES (?, ?, ?, ?)',
|
||||||
|
);
|
||||||
|
links.forEach((link, index) => {
|
||||||
|
insert.run(cityId, link.url, link.label, index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
return getCityById(cityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPhotoFilename(cityId: number, filename: string | null): void {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE cities SET photo_filename = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||||
|
).run(filename, cityId);
|
||||||
|
}
|
||||||
81
server/src/services/geocode.service.ts
Normal file
81
server/src/services/geocode.service.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { GeocodeResult } from '@citytracker/shared';
|
||||||
|
|
||||||
|
const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
|
||||||
|
const USER_AGENT = `CityTracker/1.0 (${process.env.NOMINATIM_CONTACT || 'self-hosted instance, no contact configured'})`;
|
||||||
|
const MIN_INTERVAL_MS = 1100;
|
||||||
|
|
||||||
|
let lastRequestAt = 0;
|
||||||
|
let queue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function throttle<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const run = queue.then(async () => {
|
||||||
|
const wait = Math.max(0, lastRequestAt + MIN_INTERVAL_MS - Date.now());
|
||||||
|
if (wait > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||||
|
}
|
||||||
|
lastRequestAt = Date.now();
|
||||||
|
return fn();
|
||||||
|
});
|
||||||
|
queue = run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NominatimItem {
|
||||||
|
display_name: string;
|
||||||
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
address?: {
|
||||||
|
country?: string;
|
||||||
|
country_code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapItem(item: NominatimItem): GeocodeResult {
|
||||||
|
return {
|
||||||
|
displayName: item.display_name,
|
||||||
|
lat: Number(item.lat),
|
||||||
|
lng: Number(item.lon),
|
||||||
|
country: item.address?.country ?? null,
|
||||||
|
countryCode: item.address?.country_code ? item.address.country_code.toUpperCase() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(query: string): Promise<GeocodeResult[]> {
|
||||||
|
try {
|
||||||
|
const url = new URL(`${NOMINATIM_BASE}/search`);
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('format', 'jsonv2');
|
||||||
|
url.searchParams.set('addressdetails', '1');
|
||||||
|
url.searchParams.set('limit', '8');
|
||||||
|
|
||||||
|
const response = await throttle(() => fetch(url, { headers: { 'User-Agent': USER_AGENT } }));
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data = (await response.json()) as NominatimItem[];
|
||||||
|
return data.map(mapItem);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reverse(lat: number, lng: number): Promise<GeocodeResult | null> {
|
||||||
|
try {
|
||||||
|
const url = new URL(`${NOMINATIM_BASE}/reverse`);
|
||||||
|
url.searchParams.set('lat', String(lat));
|
||||||
|
url.searchParams.set('lon', String(lng));
|
||||||
|
url.searchParams.set('format', 'jsonv2');
|
||||||
|
url.searchParams.set('addressdetails', '1');
|
||||||
|
|
||||||
|
const response = await throttle(() => fetch(url, { headers: { 'User-Agent': USER_AGENT } }));
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data = (await response.json()) as NominatimItem;
|
||||||
|
if (!data || !data.lat) return null;
|
||||||
|
return mapItem(data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/services/uploads.service.ts
Normal file
37
server/src/services/uploads.service.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
cb(null, config.uploadDir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = MIME_EXTENSIONS[file.mimetype] ?? '';
|
||||||
|
cb(null, `${crypto.randomUUID()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const photoUpload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (!MIME_EXTENSIONS[file.mimetype]) {
|
||||||
|
cb(new Error('unsupported_file_type'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function deletePhotoFile(filename: string): void {
|
||||||
|
fs.unlink(path.join(config.uploadDir, filename), () => undefined);
|
||||||
|
}
|
||||||
97
server/src/services/users.service.ts
Normal file
97
server/src/services/users.service.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { db } from '../db/connection';
|
||||||
|
import type { User } from '@citytracker/shared';
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
is_admin: number;
|
||||||
|
is_active: number;
|
||||||
|
display_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUser(row: UserRow): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isAdmin: !!row.is_admin,
|
||||||
|
isActive: !!row.is_active,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUsers(): User[] {
|
||||||
|
const rows = db.prepare('SELECT * FROM users ORDER BY username').all() as UserRow[];
|
||||||
|
return rows.map(toUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserById(id: number): User | undefined {
|
||||||
|
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined;
|
||||||
|
return row ? toUser(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRowById(id: number): UserRow | undefined {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRowByUsername(username: string): UserRow | undefined {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
|
||||||
|
| UserRow
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countAdmins(): number {
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get() as {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
return row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countActiveAdmins(): number {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1 AND is_active = 1')
|
||||||
|
.get() as { count: number };
|
||||||
|
return row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(params: {
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}): User {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?)',
|
||||||
|
)
|
||||||
|
.run(params.username, params.passwordHash, params.displayName ?? null, params.isAdmin ? 1 : 0);
|
||||||
|
return getUserById(result.lastInsertRowid as number)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(
|
||||||
|
id: number,
|
||||||
|
updates: { displayName?: string | null; isAdmin?: boolean; isActive?: boolean },
|
||||||
|
): User | undefined {
|
||||||
|
const current = getUserRowById(id);
|
||||||
|
if (!current) return undefined;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
updates.displayName !== undefined ? updates.displayName : current.display_name;
|
||||||
|
const isAdmin = updates.isAdmin !== undefined ? (updates.isAdmin ? 1 : 0) : current.is_admin;
|
||||||
|
const isActive =
|
||||||
|
updates.isActive !== undefined ? (updates.isActive ? 1 : 0) : current.is_active;
|
||||||
|
|
||||||
|
db.prepare('UPDATE users SET display_name = ?, is_admin = ?, is_active = ? WHERE id = ?').run(
|
||||||
|
displayName,
|
||||||
|
isAdmin,
|
||||||
|
isActive,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPasswordHash(id: number, passwordHash: string): void {
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id);
|
||||||
|
}
|
||||||
7
server/src/types/express.d.ts
vendored
Normal file
7
server/src/types/express.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'express-session';
|
||||||
|
|
||||||
|
declare module 'express-session' {
|
||||||
|
interface SessionData {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/tsconfig.json
Normal file
17
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
shared/package.json
Normal file
18
shared/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@citytracker/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"rimraf": "^5.0.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
shared/src/index.ts
Normal file
2
shared/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './types';
|
||||||
|
export * from './schemas';
|
||||||
81
shared/src/schemas.ts
Normal file
81
shared/src/schemas.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, 'Username must be at least 3 characters')
|
||||||
|
.max(32, 'Username must be at most 32 characters')
|
||||||
|
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, numbers, . _ -');
|
||||||
|
|
||||||
|
export const passwordSchema = z.string().min(8, 'Password must be at least 8 characters').max(200);
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export const setupSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
export type SetupInput = z.infer<typeof setupSchema>;
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
displayName: z.string().trim().max(80).optional(),
|
||||||
|
isAdmin: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
displayName: z.string().trim().max(80).nullable().optional(),
|
||||||
|
isAdmin: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||||
|
|
||||||
|
export const resetPasswordSchema = z.object({
|
||||||
|
newPassword: passwordSchema,
|
||||||
|
});
|
||||||
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
|
export const citySchema = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Name is required').max(200),
|
||||||
|
country: z.string().trim().min(1, 'Country is required').max(120),
|
||||||
|
countryCode: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(2)
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((v) => (v ? v.toUpperCase() : v ?? null)),
|
||||||
|
lat: z.number().min(-90).max(90),
|
||||||
|
lng: z.number().min(-180).max(180),
|
||||||
|
status: z.enum(['visited', 'planned']),
|
||||||
|
visitDateStart: z.string().trim().min(1).max(10).optional().nullable(),
|
||||||
|
visitDateEnd: z.string().trim().min(1).max(10).optional().nullable(),
|
||||||
|
notes: z.string().max(20000).optional(),
|
||||||
|
liked: z.boolean().optional(),
|
||||||
|
participantIds: z.array(z.number().int().positive()).optional(),
|
||||||
|
});
|
||||||
|
export type CreateCityInput = z.infer<typeof citySchema>;
|
||||||
|
|
||||||
|
export const updateCitySchema = citySchema.partial();
|
||||||
|
export type UpdateCityInput = z.infer<typeof updateCitySchema>;
|
||||||
|
|
||||||
|
export const setParticipantsSchema = z.object({
|
||||||
|
userIds: z.array(z.number().int().positive()).min(1, 'At least one participant is required'),
|
||||||
|
});
|
||||||
|
export type SetParticipantsInput = z.infer<typeof setParticipantsSchema>;
|
||||||
|
|
||||||
|
export const linkSchema = z.object({
|
||||||
|
url: z.string().trim().url('Must be a valid URL').max(2000),
|
||||||
|
label: z.string().trim().max(200).optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setLinksSchema = z.object({
|
||||||
|
links: z.array(linkSchema).max(50),
|
||||||
|
});
|
||||||
|
export type SetLinksInput = z.infer<typeof setLinksSchema>;
|
||||||
49
shared/src/types.ts
Normal file
49
shared/src/types.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
export type CityStatus = 'visited' | 'planned';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityLink {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface City {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string | null;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
status: CityStatus;
|
||||||
|
visitDateStart: string | null;
|
||||||
|
visitDateEnd: string | null;
|
||||||
|
notes: string;
|
||||||
|
liked: boolean;
|
||||||
|
photoFilename: string | null;
|
||||||
|
createdBy: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
participants: User[];
|
||||||
|
links: CityLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeocodeResult {
|
||||||
|
displayName: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
country: string | null;
|
||||||
|
countryCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
needsSetup: boolean;
|
||||||
|
}
|
||||||
18
shared/tsconfig.json
Normal file
18
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue