Add a city list view

New nav item between Map and Users shows every tracked city as a
table (photo, name, country, status, visit dates, co-travellers),
ordered with planned trips first and newest visits on top within
each group.
This commit is contained in:
Claudio Schaad 2026-07-21 20:23:14 +02:00
parent 3555972bfd
commit 9b35f6222f
4 changed files with 167 additions and 0 deletions

View file

@ -6,6 +6,7 @@ import { AppShell } from './components/layout/AppShell';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
import { MapDashboardPage } from './pages/MapDashboardPage';
import { CityListPage } from './pages/CityListPage';
import { CityDetailPage } from './pages/CityDetailPage';
import { AdminUsersPage } from './pages/AdminUsersPage';
@ -30,6 +31,7 @@ function AppRoutes() {
}
>
<Route path="/" element={<MapDashboardPage />} />
<Route path="/cities" element={<CityListPage />} />
<Route path="/cities/new" element={<CityDetailPage mode="new" />} />
<Route path="/cities/:id" element={<CityDetailPage mode="edit" />} />
<Route path="/admin/users" element={<AdminUsersPage />} />

View file

@ -18,6 +18,7 @@ export function AppShell() {
<NavLink to="/" end>
Map
</NavLink>
<NavLink to="/cities">List</NavLink>
{user?.isAdmin && <NavLink to="/admin/users">Users</NavLink>}
</nav>
<div className="app-user">

View file

@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCities } from '../hooks/useCities';
export function CityListPage() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: cities, isLoading } = useCities({});
const sortedCities = useMemo(
() =>
[...(cities ?? [])].sort((a, b) => {
if (a.status !== b.status) return a.status === 'planned' ? -1 : 1;
if (!a.visitDateStart && !b.visitDateStart) return 0;
if (!a.visitDateStart) return 1;
if (!b.visitDateStart) return -1;
return b.visitDateStart.localeCompare(a.visitDateStart);
}),
[cities],
);
return (
<div className="city-list-page">
<h1>Cities</h1>
{isLoading && <div className="page-loading">Loading cities…</div>}
{!isLoading && sortedCities.length === 0 && <p>No cities yet.</p>}
{!isLoading && sortedCities.length > 0 && (
<table className="cities-table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Country</th>
<th>Status</th>
<th>Visited</th>
<th>With</th>
</tr>
</thead>
<tbody>
{sortedCities.map((city) => (
<tr
key={city.id}
className="cities-table-row"
onClick={() => navigate(`/cities/${city.id}`)}
>
<td>
{city.photoFilename ? (
<img
className="cities-table-photo"
src={`/uploads/${city.photoFilename}`}
alt=""
/>
) : (
<div className="cities-table-photo cities-table-photo-empty" />
)}
</td>
<td>
{city.name}
{city.liked && <span className="cities-table-heart"> ♥</span>}
</td>
<td>{city.country}</td>
<td>
<span className={`badge badge-${city.status}`}>
{city.status === 'visited' ? 'Visited' : 'Planned'}
</span>
</td>
<td>
{city.visitDateStart
? `${city.visitDateStart}${
city.visitDateEnd && city.visitDateEnd !== city.visitDateStart
? ` – ${city.visitDateEnd}`
: ''
}`
: '—'}
</td>
<td>
{(() => {
const others = city.participants.filter((p) => p.id !== user?.id);
return others.length > 0
? others.map((p) => p.displayName || p.username).join(', ')
: '—';
})()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -572,6 +572,75 @@ a {
margin-bottom: var(--spacing-2);
}
/* City list */
.city-list-page {
max-width: 900px;
margin: 0 auto;
padding: var(--spacing-4);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.cities-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.cities-table th,
.cities-table td {
text-align: left;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.cities-table-row {
cursor: pointer;
}
.cities-table-row:hover {
background: var(--color-bg);
}
.cities-table-photo {
width: 40px;
height: 40px;
border-radius: var(--radius);
object-fit: cover;
display: block;
}
.cities-table-photo-empty {
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.cities-table-heart {
color: #e64980;
}
.badge {
display: inline-block;
padding: 2px var(--spacing-2);
border-radius: 999px;
font-size: 0.8rem;
color: #fff;
}
.badge-visited {
background: var(--color-success);
}
.badge-planned {
background: var(--color-primary);
}
/* Admin users */
.admin-users-page {