Compare commits
No commits in common. "ce16de8504ad2c5e46fccce6471219ba9010afd2" and "65c41d594b0e88bc8d20964b2e6ea156ac0e7a16" have entirely different histories.
ce16de8504
...
65c41d594b
67 changed files with 0 additions and 10637 deletions
|
|
@ -1,13 +0,0 @@
|
||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
**/dist
|
|
||||||
**/*.tsbuildinfo
|
|
||||||
.git
|
|
||||||
.tools
|
|
||||||
data
|
|
||||||
data-dev
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
.env
|
|
||||||
16
.env.example
16
.env.example
|
|
@ -1,16 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{
|
|
||||||
"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
13
.gitignore
vendored
|
|
@ -1,13 +0,0 @@
|
||||||
node_modules/
|
|
||||||
.tools/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.tsbuildinfo
|
|
||||||
.env
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
data/
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 100,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
43
Dockerfile
43
Dockerfile
|
|
@ -1,43 +0,0 @@
|
||||||
# ---- 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,154 +1,2 @@
|
||||||
# CityTracker
|
# CityTracker
|
||||||
|
|
||||||
A self-hostable webapp for tracking cities you've visited (or plan to visit) on a
|
|
||||||
map, with ratings, notes, photos, and links — built for sharing between family
|
|
||||||
members or a partner, each with their own login.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Interactive world map (Leaflet + OpenStreetMap tiles, no API keys) with pins
|
|
||||||
colored by status (visited / planned) and a heart badge for favorites
|
|
||||||
- Add a city by searching its name (via OpenStreetMap Nominatim) or by clicking
|
|
||||||
directly on the map
|
|
||||||
- Per-city notes, a "liked" heart, one photo, a list of links, and the people
|
|
||||||
who were there (picked from your user list)
|
|
||||||
- Filter the map by visited/planned, favorites, year, or country
|
|
||||||
- Multiple user accounts; a city is visible/editable by everyone listed as a
|
|
||||||
participant on it
|
|
||||||
- Admin-managed user accounts (no public self-registration)
|
|
||||||
- Single Docker image, SQLite database, file-based photo storage — easy to back
|
|
||||||
up as one Docker volume
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- **Frontend:** React + TypeScript, Vite, React Router, TanStack Query, Leaflet
|
|
||||||
- **Backend:** Node.js + TypeScript, Express, better-sqlite3, argon2, multer
|
|
||||||
- **Database:** SQLite, single file, plain SQL migrations run automatically on
|
|
||||||
startup
|
|
||||||
- **Auth:** server-side sessions (httpOnly cookies), backed by a SQLite table so
|
|
||||||
logins survive container restarts
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```
|
|
||||||
CityTracker/
|
|
||||||
├── shared/ # TypeScript types + zod validation schemas, used by both client and server
|
|
||||||
├── server/ # Express API (auth, cities, uploads, geocoding proxy)
|
|
||||||
└── client/ # React app (Vite)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running with Docker (recommended)
|
|
||||||
|
|
||||||
This is the intended way to run CityTracker — one image, one volume.
|
|
||||||
|
|
||||||
1. Copy the example environment file and fill in real values:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
At minimum, set:
|
|
||||||
- `SESSION_SECRET` — a long random string (generate one with
|
|
||||||
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)
|
|
||||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — credentials for the first admin
|
|
||||||
account, created automatically the first time the server starts. If you
|
|
||||||
leave these unset, open the app once it's running and use the one-time
|
|
||||||
`/setup` screen instead.
|
|
||||||
|
|
||||||
2. Build and start the container:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open `http://<your-host>:<HOST_PORT>` (default port `3000`) and log in with
|
|
||||||
the admin account you configured.
|
|
||||||
|
|
||||||
All application data — the SQLite database and uploaded photos — lives under
|
|
||||||
`/app/data` inside the container, on the `citytracker-data` named volume. Back
|
|
||||||
up that volume (e.g. `docker run --rm -v citytracker_citytracker-data:/data -v $(pwd):/backup alpine tar czf /backup/citytracker-backup.tar.gz /data`)
|
|
||||||
to back up everything.
|
|
||||||
|
|
||||||
### Behind Nginx Proxy Manager (or another reverse proxy)
|
|
||||||
|
|
||||||
The container just needs to be reachable and listening on `HOST_PORT`. Point a
|
|
||||||
proxy host at `docker.covenant.lan:<HOST_PORT>` (or wherever the container
|
|
||||||
runs), enable SSL as usual in your proxy. If your proxy terminates TLS and
|
|
||||||
forwards plain HTTP to the container, set `COOKIE_SECURE=true` in `.env` so
|
|
||||||
session cookies are still marked `Secure` correctly; leave it `false` if the
|
|
||||||
container itself is only ever reached over plain HTTP inside your LAN.
|
|
||||||
|
|
||||||
### First admin account
|
|
||||||
|
|
||||||
On first startup, if no admin user exists yet:
|
|
||||||
|
|
||||||
- If `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set in `.env`, that account is
|
|
||||||
created automatically.
|
|
||||||
- Otherwise, visiting the app shows a one-time setup screen to create the
|
|
||||||
first admin account by hand.
|
|
||||||
|
|
||||||
After that, only an admin can create further user accounts, from the **Users**
|
|
||||||
page in the app.
|
|
||||||
|
|
||||||
## Adding more users
|
|
||||||
|
|
||||||
Log in as an admin, go to **Users**, and use the "Add a user" form. Admins can
|
|
||||||
also promote/demote other admins, deactivate/reactivate accounts, and reset
|
|
||||||
passwords from the same page. Deactivated accounts can no longer log in but
|
|
||||||
remain visible (grayed out) on cities they previously participated in, so
|
|
||||||
history isn't lost.
|
|
||||||
|
|
||||||
## Local development (without Docker)
|
|
||||||
|
|
||||||
Requires Node.js 20 (native modules `better-sqlite3` and `argon2` need a
|
|
||||||
recent Node build with prebuilt binaries available).
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install
|
|
||||||
npm run build -w shared # shared types must be built once before starting the server or client
|
|
||||||
|
|
||||||
# in one terminal
|
|
||||||
PORT=3000 SESSION_SECRET=dev-secret ADMIN_USERNAME=admin ADMIN_PASSWORD=devpassword123 npm run dev:server
|
|
||||||
|
|
||||||
# in another terminal
|
|
||||||
npm run dev:client
|
|
||||||
```
|
|
||||||
|
|
||||||
The Vite dev server (`http://localhost:5173`) proxies `/api` and `/uploads`
|
|
||||||
requests to the backend on port 3000.
|
|
||||||
|
|
||||||
To produce a production build of everything (used by the Docker image):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration reference
|
|
||||||
|
|
||||||
All configuration is via environment variables (see `.env.example`):
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `PORT` | Port the server listens on inside the container | `3000` |
|
|
||||||
| `HOST_PORT` | Port exposed on the Docker host (docker-compose only) | `3000` |
|
|
||||||
| `SESSION_SECRET` | Secret used to sign session cookies. **Required** in production. | — |
|
|
||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Credentials for the auto-created first admin account | — |
|
|
||||||
| `DB_PATH` | Path to the SQLite database file | `/app/data/citytracker.db` (Docker) |
|
|
||||||
| `UPLOAD_DIR` | Directory where uploaded photos are stored | `/app/data/uploads` (Docker) |
|
|
||||||
| `COOKIE_SECURE` | Mark session cookies `Secure` (only if TLS reaches the container or is terminated just in front of it) | `false` |
|
|
||||||
|
|
||||||
## Notes on scope / what's not included
|
|
||||||
|
|
||||||
- **Trips** (grouping cities into a named trip with its own date range) is
|
|
||||||
planned as a future addition. The database schema was deliberately kept
|
|
||||||
simple enough that adding `trips` / `trip_cities` tables later won't require
|
|
||||||
reworking existing data.
|
|
||||||
- The UI is English-only by design, to keep the app simple; phone/browser
|
|
||||||
auto-translate tools work fine over it if needed.
|
|
||||||
- Uploaded photos are served from `/uploads/<random-filename>` without a
|
|
||||||
per-request auth check — acceptable for a private LAN/home-use deployment
|
|
||||||
since filenames are non-guessable UUIDs, but worth knowing if you expose
|
|
||||||
this instance beyond your own network.
|
|
||||||
- There is no automated test suite; this is a personal/family-scale project
|
|
||||||
verified through manual end-to-end testing.
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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 }),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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}</>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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>,
|
|
||||||
);
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,634 +0,0 @@
|
||||||
: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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
6425
package-lock.json
generated
File diff suppressed because it is too large
Load diff
24
package.json
24
package.json
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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'),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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');
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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
7
server/src/types/express.d.ts
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
import 'express-session';
|
|
||||||
|
|
||||||
declare module 'express-session' {
|
|
||||||
interface SessionData {
|
|
||||||
userId: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './types';
|
|
||||||
export * from './schemas';
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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>;
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"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