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 { LoginPage } from './pages/LoginPage';
|
||||||
import { SetupPage } from './pages/SetupPage';
|
import { SetupPage } from './pages/SetupPage';
|
||||||
import { MapDashboardPage } from './pages/MapDashboardPage';
|
import { MapDashboardPage } from './pages/MapDashboardPage';
|
||||||
|
import { CityListPage } from './pages/CityListPage';
|
||||||
import { CityDetailPage } from './pages/CityDetailPage';
|
import { CityDetailPage } from './pages/CityDetailPage';
|
||||||
import { AdminUsersPage } from './pages/AdminUsersPage';
|
import { AdminUsersPage } from './pages/AdminUsersPage';
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ function AppRoutes() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<MapDashboardPage />} />
|
<Route path="/" element={<MapDashboardPage />} />
|
||||||
|
<Route path="/cities" element={<CityListPage />} />
|
||||||
<Route path="/cities/new" element={<CityDetailPage mode="new" />} />
|
<Route path="/cities/new" element={<CityDetailPage mode="new" />} />
|
||||||
<Route path="/cities/:id" element={<CityDetailPage mode="edit" />} />
|
<Route path="/cities/:id" element={<CityDetailPage mode="edit" />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function AppShell() {
|
||||||
<NavLink to="/" end>
|
<NavLink to="/" end>
|
||||||
Map
|
Map
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/cities">List</NavLink>
|
||||||
{user?.isAdmin && <NavLink to="/admin/users">Users</NavLink>}
|
{user?.isAdmin && <NavLink to="/admin/users">Users</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="app-user">
|
<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);
|
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 */
|
||||||
|
|
||||||
.admin-users-page {
|
.admin-users-page {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue