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:
parent
3555972bfd
commit
9b35f6222f
4 changed files with 167 additions and 0 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
95
client/src/pages/CityListPage.tsx
Normal file
95
client/src/pages/CityListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue