Compare commits

..

No commits in common. "ce16de8504ad2c5e46fccce6471219ba9010afd2" and "65c41d594b0e88bc8d20964b2e6ea156ac0e7a16" have entirely different histories.

67 changed files with 0 additions and 10637 deletions

View file

@ -1,13 +0,0 @@
node_modules
**/node_modules
**/dist
**/*.tsbuildinfo
.git
.tools
data
data-dev
*.db
*.db-journal
*.db-wal
*.db-shm
.env

View file

@ -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

View file

@ -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
View file

@ -1,13 +0,0 @@
node_modules/
.tools/
dist/
build/
*.tsbuildinfo
.env
*.log
.DS_Store
data/
*.db
*.db-journal
*.db-wal
*.db-shm

View file

@ -1,6 +0,0 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}

View file

@ -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
View file

@ -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.

View file

@ -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>

View file

@ -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"
}
}

View file

@ -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>
);
}

View file

@ -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 });
}

View file

@ -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`);
}

View file

@ -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 }),
};

View file

@ -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}`);
}

View file

@ -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}`);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}</>;
}

View file

@ -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='&copy; <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>
);
}

View file

@ -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],
});
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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),
});
}

View file

@ -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 }),
});
}

View file

@ -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>,
);

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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"]
}

View file

@ -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',
},
});

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -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'),
};

View file

@ -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');

View file

@ -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}`);
}
}

View 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);

View file

@ -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);
});

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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,
},
});

View file

@ -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);
});

View file

@ -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();
});

View file

@ -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 });
});

View file

@ -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();
});

View file

@ -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.',
);
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,7 +0,0 @@
import 'express-session';
declare module 'express-session' {
interface SessionData {
userId: number;
}
}

View file

@ -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"]
}

View file

@ -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"
}
}

View file

@ -1,2 +0,0 @@
export * from './types';
export * from './schemas';

View file

@ -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>;

View file

@ -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;
}

View file

@ -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"]
}