Compare commits
2 commits
65c41d594b
...
ce16de8504
| Author | SHA1 | Date | |
|---|---|---|---|
| ce16de8504 | |||
| 3debb1ce1e |
67 changed files with 10637 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
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
155
client/src/components/city/LinkListEditor.tsx
Normal file
155
client/src/components/city/LinkListEditor.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import type { CityLink } from '@citytracker/shared';
|
||||
|
||||
interface LinkListEditorProps {
|
||||
links: CityLink[];
|
||||
onSave: (links: { url: string; label: string }[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DraftLink {
|
||||
type: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Platform {
|
||||
key: string;
|
||||
label: string;
|
||||
prefix: string | null;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: Platform[] = [
|
||||
{ key: 'website', label: 'Website', prefix: null, placeholder: 'https://…' },
|
||||
{ key: 'instagram', label: 'Instagram', prefix: 'https://instagram.com/', placeholder: 'username' },
|
||||
{ key: 'twitter', label: 'X (Twitter)', prefix: 'https://x.com/', placeholder: 'username' },
|
||||
{ key: 'facebook', label: 'Facebook', prefix: 'https://facebook.com/', placeholder: 'username' },
|
||||
{ key: 'tiktok', label: 'TikTok', prefix: 'https://tiktok.com/@', placeholder: 'username' },
|
||||
{ key: 'youtube', label: 'YouTube', prefix: 'https://youtube.com/@', placeholder: 'handle' },
|
||||
{ key: 'linkedin', label: 'LinkedIn', prefix: 'https://linkedin.com/in/', placeholder: 'username' },
|
||||
];
|
||||
|
||||
function platformFor(key: string): Platform {
|
||||
return PLATFORMS.find((p) => p.key === key) ?? PLATFORMS[0];
|
||||
}
|
||||
|
||||
function parseLinkForEditing(link: CityLink): DraftLink {
|
||||
for (const platform of PLATFORMS) {
|
||||
if (platform.prefix && link.url.startsWith(platform.prefix)) {
|
||||
return { type: platform.key, label: link.label, value: link.url.slice(platform.prefix.length) };
|
||||
}
|
||||
}
|
||||
return { type: 'website', label: link.label, value: link.url };
|
||||
}
|
||||
|
||||
function buildUrl(draft: DraftLink): string | null {
|
||||
const platform = platformFor(draft.type);
|
||||
const value = draft.value.trim();
|
||||
if (!value) return null;
|
||||
if (!platform.prefix) return value;
|
||||
return platform.prefix + value.replace(/^@+/, '');
|
||||
}
|
||||
|
||||
function buildLabel(draft: DraftLink): string {
|
||||
const trimmed = draft.label.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const platform = platformFor(draft.type);
|
||||
return platform.key === 'website' ? '' : platform.label;
|
||||
}
|
||||
|
||||
function toPayload(draft: DraftLink[]): { url: string; label: string }[] {
|
||||
return draft
|
||||
.map((d) => {
|
||||
const url = buildUrl(d);
|
||||
return url ? { url, label: buildLabel(d) } : null;
|
||||
})
|
||||
.filter((l): l is { url: string; label: string } => l !== null);
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
|
||||
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
|
||||
const [draft, setDraft] = useState<DraftLink[]>(() => links.map(parseLinkForEditing));
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastSavedRef = useRef<string>(JSON.stringify(toPayload(links.map(parseLinkForEditing))));
|
||||
|
||||
function persist(nextDraft: DraftLink[], immediate = false) {
|
||||
setDraft(nextDraft);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const run = async () => {
|
||||
const payload = toPayload(nextDraft);
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSavedRef.current) return;
|
||||
setStatus('saving');
|
||||
try {
|
||||
await onSave(payload);
|
||||
lastSavedRef.current = serialized;
|
||||
setStatus('saved');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
run();
|
||||
} else {
|
||||
debounceRef.current = setTimeout(run, AUTOSAVE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(index: number, patch: Partial<DraftLink>) {
|
||||
persist(draft.map((d, i) => (i === index ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
persist([...draft, { type: 'website', label: '', value: '' }], true);
|
||||
}
|
||||
|
||||
function removeLink(index: number) {
|
||||
persist(draft.filter((_, i) => i !== index), true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="link-editor">
|
||||
{draft.map((row, index) => {
|
||||
const platform = platformFor(row.type);
|
||||
return (
|
||||
<div className="link-row" key={index}>
|
||||
<select value={row.type} onChange={(e) => updateRow(index, { type: e.target.value })}>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
placeholder="Label (optional)"
|
||||
value={row.label}
|
||||
onChange={(e) => updateRow(index, { label: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder={platform.placeholder}
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(index, { value: e.target.value })}
|
||||
/>
|
||||
<button type="button" className="btn btn-ghost btn-small" onClick={() => removeLink(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="link-actions">
|
||||
<button type="button" className="btn btn-small" onClick={addLink}>
|
||||
+ Add link
|
||||
</button>
|
||||
<span className="save-status">
|
||||
{status === 'saving' && 'Saving…'}
|
||||
{status === 'saved' && 'Saved'}
|
||||
{status === 'error' && 'Could not save'}
|
||||
</span>
|
||||
</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}</>;
|
||||
}
|
||||
93
client/src/components/map/CityMap.tsx
Normal file
93
client/src/components/map/CityMap.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import 'leaflet/dist/leaflet.css';
|
||||
import { useRef } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { City } from '@citytracker/shared';
|
||||
import { cityMarkerIcon } from './CityMarker';
|
||||
|
||||
interface CityMapProps {
|
||||
cities: City[];
|
||||
focusCity?: 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;
|
||||
}
|
||||
|
||||
/** Roughly bounds a `radiusKm` circle around a point, for an initial "zoomed to this area" view. */
|
||||
function boundsAroundKm(lat: number, lng: number, radiusKm: number): [[number, number], [number, number]] {
|
||||
const latDelta = radiusKm / 111;
|
||||
const lngDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
|
||||
return [
|
||||
[lat - latDelta, lng - lngDelta],
|
||||
[lat + latDelta, lng + lngDelta],
|
||||
];
|
||||
}
|
||||
|
||||
function InitialFocus({ focusCity }: { focusCity?: City }) {
|
||||
const map = useMap();
|
||||
const appliedRef = useRef(false);
|
||||
|
||||
if (focusCity && !appliedRef.current) {
|
||||
appliedRef.current = true;
|
||||
map.fitBounds(boundsAroundKm(focusCity.lat, focusCity.lng, 500));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CityMap({ cities, focusCity, 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} />
|
||||
<InitialFocus focusCity={focusCity} />
|
||||
{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>
|
||||
{city.photoFilename && (
|
||||
<img
|
||||
className="map-popup-photo"
|
||||
src={`/uploads/${city.photoFilename}`}
|
||||
alt={city.name}
|
||||
/>
|
||||
)}
|
||||
{city.links.length > 0 && (
|
||||
<ul className="map-popup-links">
|
||||
{city.links.map((link) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noreferrer">
|
||||
{link.label || link.url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary 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>
|
||||
);
|
||||
}
|
||||
19
client/src/components/ui/HeartToggle.tsx
Normal file
19
client/src/components/ui/HeartToggle.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
interface HeartToggleProps {
|
||||
liked: boolean;
|
||||
onToggle: () => void;
|
||||
size?: 'small' | 'large';
|
||||
}
|
||||
|
||||
export function HeartToggle({ liked, onToggle, size = 'large' }: HeartToggleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`heart-toggle heart-toggle-${size} ${liked ? 'heart-toggle-liked' : ''}`}
|
||||
onClick={onToggle}
|
||||
aria-pressed={liked}
|
||||
aria-label={liked ? 'Liked' : 'Not liked'}
|
||||
>
|
||||
{liked ? '♥' : '♡'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
54
client/src/hooks/useCities.ts
Normal file
54
client/src/hooks/useCities.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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 useDeleteCity() {
|
||||
const invalidate = useInvalidateCities();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => citiesApi.deleteCity(id),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
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>,
|
||||
);
|
||||
188
client/src/pages/AdminUsersPage.tsx
Normal file
188
client/src/pages/AdminUsersPage.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
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 handleEditDisplayName(id: number, current: string | null) {
|
||||
const next = window.prompt('Display name:', current ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
await updateUser.mutateAsync({ id, input: { displayName: next.trim() || null } });
|
||||
} catch {
|
||||
window.alert('Could not update display name. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
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={() => handleEditDisplayName(u.id, u.displayName)}
|
||||
>
|
||||
Edit name
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
341
client/src/pages/CityDetailPage.tsx
Normal file
341
client/src/pages/CityDetailPage.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { CityStatus, GeocodeResult } from '@citytracker/shared';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities';
|
||||
import * as citiesApi from '../api/cities';
|
||||
import { CitySearchInput } from '../components/city/CitySearchInput';
|
||||
import { LinkListEditor } from '../components/city/LinkListEditor';
|
||||
import { ParticipantPicker } from '../components/city/ParticipantPicker';
|
||||
import { PhotoUploader } from '../components/city/PhotoUploader';
|
||||
import { HeartToggle } from '../components/ui/HeartToggle';
|
||||
|
||||
interface CityDetailPageProps {
|
||||
mode: 'new' | 'edit';
|
||||
}
|
||||
|
||||
interface NavState {
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
country: string;
|
||||
countryCode: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
status: CityStatus;
|
||||
visitDateStart: string;
|
||||
visitDateEnd: string;
|
||||
notes: string;
|
||||
liked: boolean;
|
||||
participantIds: number[];
|
||||
}
|
||||
|
||||
type SaveStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
|
||||
function isReadyToSave(f: FormState): boolean {
|
||||
return f.lat !== null && f.lng !== null && f.name.trim() !== '' && f.country.trim() !== '';
|
||||
}
|
||||
|
||||
export function CityDetailPage({ mode }: CityDetailPageProps) {
|
||||
const { id } = useParams();
|
||||
const initialCityId = mode === 'edit' ? Number(id) : undefined;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: city } = useCity(initialCityId);
|
||||
const deleteCity = useDeleteCity();
|
||||
|
||||
const cityIdRef = useRef<number | undefined>(initialCityId);
|
||||
const [cityId, setCityId] = useState<number | undefined>(initialCityId);
|
||||
const uploadPhoto = useUploadPhoto(cityId ?? -1);
|
||||
const deletePhotoMutation = useDeletePhoto(cityId ?? -1);
|
||||
|
||||
const navState = location.state as NavState | null;
|
||||
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: '',
|
||||
country: '',
|
||||
countryCode: null,
|
||||
lat: navState?.lat ?? null,
|
||||
lng: navState?.lng ?? null,
|
||||
status: 'visited',
|
||||
visitDateStart: '',
|
||||
visitDateEnd: '',
|
||||
notes: '',
|
||||
liked: false,
|
||||
participantIds: user ? [user.id] : [],
|
||||
});
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastPersistedRef = useRef<string | null>(null);
|
||||
const hydratedForIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Hydrate local form state from loaded city data exactly once per city id, so a background
|
||||
// refetch triggered by our own autosave doesn't clobber whatever the user has typed since.
|
||||
useEffect(() => {
|
||||
if (city && hydratedForIdRef.current !== city.id) {
|
||||
hydratedForIdRef.current = city.id;
|
||||
const hydrated: FormState = {
|
||||
name: city.name,
|
||||
country: city.country,
|
||||
countryCode: city.countryCode,
|
||||
lat: city.lat,
|
||||
lng: city.lng,
|
||||
status: city.status,
|
||||
visitDateStart: city.visitDateStart ?? '',
|
||||
visitDateEnd: city.visitDateEnd ?? '',
|
||||
notes: city.notes,
|
||||
liked: city.liked,
|
||||
participantIds: city.participants.map((p) => p.id),
|
||||
};
|
||||
setForm(hydrated);
|
||||
lastPersistedRef.current = JSON.stringify(hydrated);
|
||||
}
|
||||
}, [city]);
|
||||
|
||||
async function doSave(next: FormState) {
|
||||
if (!isReadyToSave(next)) return;
|
||||
|
||||
const snapshot = JSON.stringify(next);
|
||||
if (snapshot === lastPersistedRef.current) return;
|
||||
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
if (cityIdRef.current === undefined) {
|
||||
const created = await citiesApi.createCity({
|
||||
name: next.name,
|
||||
country: next.country,
|
||||
countryCode: next.countryCode,
|
||||
lat: next.lat!,
|
||||
lng: next.lng!,
|
||||
status: next.status,
|
||||
visitDateStart: next.visitDateStart || undefined,
|
||||
visitDateEnd: next.visitDateEnd || undefined,
|
||||
notes: next.notes,
|
||||
liked: next.liked,
|
||||
participantIds: next.participantIds,
|
||||
});
|
||||
cityIdRef.current = created.id;
|
||||
hydratedForIdRef.current = created.id;
|
||||
lastPersistedRef.current = snapshot;
|
||||
setCityId(created.id);
|
||||
setSaveStatus('saved');
|
||||
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||
navigate(`/cities/${created.id}`, { replace: true });
|
||||
} else {
|
||||
await citiesApi.updateCity(cityIdRef.current, {
|
||||
name: next.name,
|
||||
country: next.country,
|
||||
countryCode: next.countryCode,
|
||||
lat: next.lat!,
|
||||
lng: next.lng!,
|
||||
status: next.status,
|
||||
visitDateStart: next.visitDateStart || null,
|
||||
visitDateEnd: next.visitDateEnd || null,
|
||||
notes: next.notes,
|
||||
liked: next.liked,
|
||||
});
|
||||
lastPersistedRef.current = snapshot;
|
||||
setSaveStatus('saved');
|
||||
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(next: FormState, immediate = false) {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!isReadyToSave(next)) {
|
||||
setSaveStatus('idle');
|
||||
return;
|
||||
}
|
||||
setSaveStatus('unsaved');
|
||||
if (immediate) {
|
||||
doSave(next);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => doSave(next), AUTOSAVE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof FormState>(key: K, value: FormState[K], immediate = false) {
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
scheduleSave(next, immediate);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleParticipantsChange(ids: number[]) {
|
||||
setForm((prev) => ({ ...prev, participantIds: ids }));
|
||||
if (cityIdRef.current !== undefined) {
|
||||
try {
|
||||
await citiesApi.setParticipants(cityIdRef.current, { userIds: ids });
|
||||
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||
} catch {
|
||||
window.alert('Could not update participants — at least one participant is required.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleGeocodeSelect(result: GeocodeResult) {
|
||||
setForm((prev) => {
|
||||
const next: FormState = {
|
||||
...prev,
|
||||
name: result.displayName.split(',')[0],
|
||||
country: result.country ?? '',
|
||||
countryCode: result.countryCode,
|
||||
lat: result.lat,
|
||||
lng: result.lng,
|
||||
};
|
||||
scheduleSave(next, true);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!cityIdRef.current) return;
|
||||
if (!window.confirm(`Delete ${form.name}? This cannot be undone.`)) return;
|
||||
await deleteCity.mutateAsync(cityIdRef.current);
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
async function handleLinksSave(links: { url: string; label: string }[]) {
|
||||
if (!cityId) return;
|
||||
const updated = await citiesApi.setLinks(cityId, { links });
|
||||
queryClient.setQueryData(['city', cityId], updated);
|
||||
queryClient.invalidateQueries({ queryKey: ['cities'] });
|
||||
}
|
||||
|
||||
const isEditing = cityId !== undefined;
|
||||
|
||||
return (
|
||||
<div className="city-detail-page">
|
||||
<h1>{isEditing ? form.name : 'Add a city'}</h1>
|
||||
<div className="city-form">
|
||||
{!isEditing && (
|
||||
<label>
|
||||
Search for a city
|
||||
<CitySearchInput onSelect={handleGeocodeSelect} />
|
||||
</label>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Country
|
||||
<input
|
||||
value={form.country}
|
||||
onChange={(e) => updateField('country', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{form.lat !== null && form.lng !== null && (
|
||||
<p className="coords-hint">
|
||||
Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)}
|
||||
</p>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Status
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => updateField('status', e.target.value as CityStatus, true)}
|
||||
>
|
||||
<option value="visited">Visited</option>
|
||||
<option value="planned">Planned</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="heart-field">
|
||||
<span>Liked</span>
|
||||
<HeartToggle liked={form.liked} onToggle={() => updateField('liked', !form.liked, true)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Visit start date
|
||||
<input
|
||||
type="date"
|
||||
value={form.visitDateStart}
|
||||
onChange={(e) => updateField('visitDateStart', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Visit end date
|
||||
<input
|
||||
type="date"
|
||||
value={form.visitDateEnd}
|
||||
onChange={(e) => updateField('visitDateEnd', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Notes
|
||||
<textarea
|
||||
rows={5}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField('notes', e.target.value)}
|
||||
placeholder="What did you like? Tips, restaurants, ..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<ParticipantPicker selectedIds={form.participantIds} onChange={handleParticipantsChange} />
|
||||
|
||||
<div className="form-actions">
|
||||
<span className="save-status">
|
||||
{saveStatus === 'saving' && 'Saving…'}
|
||||
{saveStatus === 'unsaved' && 'Saving…'}
|
||||
{saveStatus === 'saved' && 'All changes saved'}
|
||||
{saveStatus === 'error' && 'Could not save — check your connection'}
|
||||
{saveStatus === 'idle' &&
|
||||
!isEditing &&
|
||||
'Search for a city or click the map, then add a name and country to save'}
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => navigate('/')}>
|
||||
Back to map
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>
|
||||
Delete city
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && 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 key={cityId} links={city.links} onSave={handleLinksSave} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
55
client/src/pages/MapDashboardPage.tsx
Normal file
55
client/src/pages/MapDashboardPage.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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],
|
||||
);
|
||||
|
||||
// Cities are returned newest-first, so the first entry is the most recently added one.
|
||||
const latestCity = allCities?.[0];
|
||||
|
||||
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 ?? []} focusCity={latestCity} 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>
|
||||
);
|
||||
}
|
||||
634
client/src/styles/global.css
Normal file
634
client/src/styles/global.css
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
: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);
|
||||
}
|
||||
|
||||
.map-popup-photo {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.map-popup-links {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.heart-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.heart-toggle-small {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.heart-toggle-large {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heart-toggle-liked {
|
||||
color: #e64980;
|
||||
}
|
||||
|
||||
.coords-hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.form-actions .btn-danger {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.heart-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.link-row select {
|
||||
flex: 0 0 140px;
|
||||
padding: var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.link-row input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.users-table td .btn + .btn {
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
.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