fix(places): centre the empty states, and give the details column one too

Five states, one shape. EmptyState grows a `fill` option: the stack already
centres what it holds, but with no height of its own there was nothing to centre
inside, so every one of them sat pinned under the header of its column.

Optically centred rather than geometrically. The extra room goes underneath,
which puts the mascot slightly above the middle: in a column you can scroll,
dead centre reads as having fallen to the bottom the moment the list is short.

The details column's own placeholder joins them, with the idle look rather than
the sad one, because nothing being picked yet is a state and not a fault. Its
line is shortened to fit on one: "Pick a result to see more", in all 23
languages.

The corridor panel, the saved-places list and the place details all dropped
`compact`, so the five now read at the same size as the road trip rail.
This commit is contained in:
Maurice
2026-09-08 17:16:48 +02:00
committed by Maurice
parent 4a88dbcbcf
commit 900e4806ad
29 changed files with 79 additions and 37 deletions
@@ -215,8 +215,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
scene="search"
title={t('collections.picker.empty')}
size={84}
fill
surface="var(--bg-secondary)"
compact
/>
) : (
<div className="flex flex-col gap-1">
@@ -205,7 +205,17 @@ export default function PlaceDetailsColumn({
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. */}
{!selection && (
<EmptyState
scene="idle"
title={t('places.details.empty')}
size={92}
fill
surface="var(--bg-secondary)"
/>
)}
{selection && state === 'loading' && (
<div className="flex items-center gap-2 text-caption text-content-muted">
@@ -231,8 +241,8 @@ export default function PlaceDetailsColumn({
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
compact
/>
)}
@@ -1,4 +1,5 @@
import { Fragment } from 'react'
import EmptyState from '../shared/EmptyState'
import { MemoPlaceRow } from './PlacesSidebarRow'
import type { SidebarState } from './usePlacesSidebar'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
@@ -14,14 +15,26 @@ export function PlacesList(S: SidebarState) {
return (
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
{canEditPlaces && <button type="button" onClick={onAddPlace} className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>}
</div>
/* The mascot and one line, the shape every other empty state in TREK has.
The add link stays as the state's action: an empty list of places is
one of the few that has an obvious next step. */
<EmptyState
scene="search"
mood="sad"
size={92}
fill
surface="var(--bg-secondary)"
title={filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
action={canEditPlaces ? (
<button
type="button"
onClick={onAddPlace}
className="text-caption text-content underline underline-offset-2 hover:text-accent"
>
{t('places.addPlace')}
</button>
) : undefined}
/>
) : (
filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id)
@@ -509,7 +509,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
scene={search.loading ? 'search' : 'idle'}
mood={filteredToNothing ? 'confused' : undefined}
size={88}
compact
fill
surface="var(--bg-secondary)"
title={
search.loading
@@ -1044,8 +1044,11 @@ export default function RoadtripSidebar({
// Nothing to total up, so nothing pretends to: no "0 km" standing above "No route yet".
if (routes.days.length === 0) {
// Centred in the rail rather than sitting at its top: the state is about the
// whole column being empty, and a mascot pinned under the header reads as a
// header decoration.
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto">
{/* The mascot rather than a pin and two lines of instructions. The rail
is empty because the trip has no days with places yet, which is a
state, not a task list: the same look every other empty state in TREK
+17 -1
View File
@@ -23,6 +23,7 @@ export default function EmptyState({
surface = 'var(--bg-card)',
layout = 'stack',
compact = false,
fill = false,
className = '',
action,
}: {
@@ -40,13 +41,28 @@ export default function EmptyState({
* about why the list below is blank.
*/
compact?: boolean
/**
* Fill the parent and sit in the middle of it.
*
* The stack already centres what it contains, but with no height of its own
* `justify-center` has nothing to centre inside, so the state lands under
* whatever sits above it. A state that stands for a whole empty column wants
* the middle of that column; one that sits inline in a page does not, which is
* why this is a choice rather than the default. The parent has to be the one
* with the height, which every scroll container here already is.
*
* Optically centred rather than geometrically: the extra room goes underneath,
* which lands the mascot slightly above the middle. In a column you can scroll,
* dead centre reads as "fell to the bottom" the moment the list is short.
*/
fill?: boolean
className?: string
/** Optional call to action under the title, for states that have an obvious next step. */
action?: ReactNode
}) {
const layoutClasses = layout === 'row'
? 'flex flex-row items-center justify-center gap-3 px-6 py-3'
: 'flex flex-col items-center justify-center gap-3 px-6 py-12 text-center'
: `flex flex-col items-center justify-center gap-3 px-6 text-center ${fill ? 'min-h-full pt-4 pb-14' : 'py-12'}`
return (
<div
className={`${layoutClasses} ${className}`}
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
'places.details.title': 'تفاصيل المكان',
'places.details.empty': 'اختر نتيجة بحث لعرض الصور والوصف.',
'places.details.empty': 'اختر نتيجة لعرض التفاصيل',
'places.details.loading': 'جارٍ تحميل التفاصيل...',
'places.details.error': 'تعذّر تحميل التفاصيل.',
'places.details.disabled': 'الإثراء معطّل من قِبل المسؤول.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.details.title': 'Detalhes do lugar',
'places.details.empty': 'Escolha um resultado para ver imagens e uma descrição.',
'places.details.empty': 'Escolha um resultado para ver mais',
'places.details.loading': 'Carregando detalhes...',
'places.details.error': 'Não foi possível carregar os detalhes.',
'places.details.disabled': 'O enriquecimento está desativado pelo administrador.',
+1 -1
View File
@@ -86,7 +86,7 @@ const places: TranslationStrings = {
'places.osmHint':
"S'està utilitzant la cerca amb OpenStreetMap (sense fotos, horaris ni valoracions). Afegeix una clau API de Google a Configuració per obtenir tots els detalls.",
'places.details.title': 'Detalls del lloc',
'places.details.empty': 'Tria un resultat per veure imatges i una descripció.',
'places.details.empty': 'Tria un resultat per veure’n més',
'places.details.loading': "S'estan carregant els detalls...",
'places.details.error': "No s'han pogut carregar els detalls.",
'places.details.disabled': "L'enriquiment està desactivat per l'administrador.",
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
'places.details.title': 'Podrobnosti místa',
'places.details.empty': 'Vyber výsledek hledání a zobrazí se obrázky a popis.',
'places.details.empty': 'Vyber výsledek a zobraz detaily',
'places.details.loading': 'Načítání podrobností...',
'places.details.error': 'Podrobnosti se nepodařilo načíst.',
'places.details.disabled': 'Obohacení je vypnuté správcem.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
'places.details.title': 'Ortsdetails',
'places.details.empty': 'Wähle ein Suchergebnis, um Bilder und eine Beschreibung zu sehen.',
'places.details.empty': 'Wähle ein Ergebnis für Details',
'places.details.loading': 'Details werden geladen...',
'places.details.error': 'Details konnten nicht geladen werden.',
'places.details.disabled': 'Die Anreicherung ist vom Administrator deaktiviert.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
'places.details.title': 'Place details',
'places.details.empty': 'Pick a search result to see pictures and a description.',
'places.details.empty': 'Pick a result to see more',
'places.details.loading': 'Loading details...',
'places.details.error': 'Details could not be loaded.',
'places.details.disabled': 'Enrichment is switched off by the administrator.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
'places.details.title': 'Detalles del lugar',
'places.details.empty': 'Elige un resultado para ver imágenes y una descripción.',
'places.details.empty': 'Elige un resultado para ver más',
'places.details.loading': 'Cargando detalles...',
'places.details.error': 'No se pudieron cargar los detalles.',
'places.details.disabled': 'El administrador ha desactivado el enriquecimiento.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.details.title': 'Détails du lieu',
'places.details.empty': 'Choisis un résultat pour voir des images et une description.',
'places.details.empty': 'Choisis un résultat pour en voir plus',
'places.details.loading': 'Chargement des détails...',
'places.details.error': 'Impossible de charger les détails.',
'places.details.disabled': "L'enrichissement est désactivé par l'administrateur.",
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Χρήση αναζήτησης OpenStreetMap (χωρίς φωτογραφίες, ώρες λειτουργίας ή αξιολογήσεις). Προσθέστε ένα κλειδί Google API στις ρυθμίσεις για πλήρεις λεπτομέρειες.',
'places.details.title': 'Λεπτομέρειες τοποθεσίας',
'places.details.empty': 'Διάλεξε ένα αποτέλεσμα για να δεις εικόνες και περιγραφή.',
'places.details.empty': 'Επίλεξε ένα αποτέλεσμα για περισσότερα',
'places.details.loading': 'Φόρτωση λεπτομερειών...',
'places.details.error': 'Δεν ήταν δυνατή η φόρτωση των λεπτομερειών.',
'places.details.disabled': 'Ο εμπλουτισμός είναι απενεργοποιημένος από τον διαχειριστή.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
'places.details.title': 'Hely részletei',
'places.details.empty': 'Válassz egy találatot a képekhez és a leíráshoz.',
'places.details.empty': 'Válassz egy találatot a részletekhez',
'places.details.loading': 'Részletek betöltése...',
'places.details.error': 'A részleteket nem sikerült betölteni.',
'places.details.disabled': 'A gazdagítást a rendszergazda kikapcsolta.',
+1 -1
View File
@@ -87,7 +87,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Menggunakan pencarian OpenStreetMap (tanpa foto, jam buka, atau penilaian). Tambahkan Google API key di pengaturan untuk detail lengkap.',
'places.details.title': 'Detail tempat',
'places.details.empty': 'Pilih hasil pencarian untuk melihat gambar dan deskripsi.',
'places.details.empty': 'Pilih hasil untuk melihat detail',
'places.details.loading': 'Memuat detail...',
'places.details.error': 'Detail tidak dapat dimuat.',
'places.details.disabled': 'Pengayaan dimatikan oleh administrator.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
'places.details.title': 'Dettagli del luogo',
'places.details.empty': 'Scegli un risultato per vedere immagini e una descrizione.',
'places.details.empty': 'Scegli un risultato per saperne di più',
'places.details.loading': 'Caricamento dettagli...',
'places.details.error': 'Impossibile caricare i dettagli.',
'places.details.disabled': "L'arricchimento è disattivato dall'amministratore.",
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'OpenStreetMapで検索しています(写真・営業時間・評価なし)。設定でGoogle APIキーを追加すると詳細が表示されます。',
'places.details.title': '場所の詳細',
'places.details.empty': '検索結果を選ぶと写真と説明が表示されます。',
'places.details.empty': '検索結果を選ぶと詳細が表示されます',
'places.details.loading': '詳細を読み込み中...',
'places.details.error': '詳細を読み込めませんでした。',
'places.details.disabled': '情報補完は管理者によって無効になっています。',
+1 -1
View File
@@ -87,7 +87,7 @@ const places: TranslationStrings = {
'places.osmHint':
'OpenStreetMap 검색 사용 중 (사진, 영업 시간, 평점 없음). 전체 정보를 위해 설정에서 Google API 키를 추가하세요.',
'places.details.title': '장소 세부정보',
'places.details.empty': '검색 결과를 선택하면 사진과 설명이 표시됩니다.',
'places.details.empty': '결과를 선택하면 자세히 볼 수 있어요',
'places.details.loading': '세부정보를 불러오는 중...',
'places.details.error': '세부정보를 불러오지 못했습니다.',
'places.details.disabled': '관리자가 정보 보강을 껐습니다.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
"Zoeken via OpenStreetMap (geen foto's, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.",
'places.details.title': 'Plaatsdetails',
'places.details.empty': 'Kies een zoekresultaat om afbeeldingen en een beschrijving te zien.',
'places.details.empty': 'Kies een resultaat voor meer',
'places.details.loading': 'Details laden...',
'places.details.error': 'Details konden niet worden geladen.',
'places.details.disabled': 'Verrijking is uitgeschakeld door de beheerder.',
+1 -1
View File
@@ -78,7 +78,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
'places.details.title': 'Szczegóły miejsca',
'places.details.empty': 'Wybierz wynik wyszukiwania, aby zobaczyć zdjęcia i opis.',
'places.details.empty': 'Wybierz wynik, aby zobaczyć więcej',
'places.details.loading': 'Wczytywanie szczegółów...',
'places.details.error': 'Nie udało się wczytać szczegółów.',
'places.details.disabled': 'Wzbogacanie jest wyłączone przez administratora.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
'places.details.title': 'Сведения о месте',
'places.details.empty': 'Выберите результат поиска, чтобы увидеть изображения и описание.',
'places.details.empty': 'Выберите результат, чтобы узнать больше',
'places.details.loading': 'Загрузка сведений...',
'places.details.error': 'Не удалось загрузить сведения.',
'places.details.disabled': 'Обогащение отключено администратором.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Använder OpenStreetMap-sökningen (inga bilder, öppettider eller betyg). Lägg till en Google API-nyckel i inställningarna för fullständig information.',
'places.details.title': 'Platsdetaljer',
'places.details.empty': 'Välj ett sökresultat för att se bilder och en beskrivning.',
'places.details.empty': 'Välj ett resultat för att se mer',
'places.details.loading': 'Laddar detaljer...',
'places.details.error': 'Detaljerna kunde inte laddas.',
'places.details.disabled': 'Berikning är avstängd av administratören.',
+1 -1
View File
@@ -90,7 +90,7 @@ const places: TranslationStrings = {
'places.osmHint':
'OpenStreetMap aramasını kullanma (fotoğraf, açılış saatleri veya derecelendirme yok). Tüm ayrıntılar için ayarlara bir Google API anahtarı ekleyin.',
'places.details.title': 'Yer ayrıntıları',
'places.details.empty': 'Görseller ve açıklama için bir arama sonucu seç.',
'places.details.empty': 'Ayrıntılar için bir sonuç seç',
'places.details.loading': 'Ayrıntılar yükleniyor...',
'places.details.error': 'Ayrıntılar yüklenemedi.',
'places.details.disabled': 'Zenginleştirme yönetici tarafından kapatıldı.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Пошук через OpenStreetMap (без фото, годин роботи та рейтингів). Додайте API-ключ Google у налаштуваннях для повної інформації.',
'places.details.title': 'Відомості про місце',
'places.details.empty': 'Виберіть результат пошуку, щоб побачити зображення та опис.',
'places.details.empty': 'Виберіть результат, щоб побачити більше',
'places.details.loading': 'Завантаження відомостей...',
'places.details.error': 'Не вдалося завантажити відомості.',
'places.details.disabled': 'Збагачення вимкнено адміністратором.',
+1 -1
View File
@@ -88,7 +88,7 @@ const places: TranslationStrings = {
'places.osmHint':
'Sử dụng tìm kiếm OpenStreetMap (không có ảnh, giờ mở cửa hoặc xếp hạng). Thêm khóa Google API trong cài đặt để biết đầy đủ chi tiết.',
'places.details.title': 'Chi tiết địa điểm',
'places.details.empty': 'Chọn một kết quả tìm kiếm để xem hình ảnh và mô tả.',
'places.details.empty': 'Chọn một kết quả để xem thêm',
'places.details.loading': 'Đang tải chi tiết...',
'places.details.error': 'Không tải được chi tiết.',
'places.details.disabled': 'Quản trị viên đã tắt tính năng làm giàu.',
+1 -1
View File
@@ -86,7 +86,7 @@ const places: TranslationStrings = {
'places.loadingDetails': '正在載入地點詳情…',
'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。',
'places.details.title': '地點詳細資料',
'places.details.empty': '選擇一筆搜尋結果即可查看圖片與描述。',
'places.details.empty': '選擇一個結果查看詳細資訊',
'places.details.loading': '正在載入詳細資料...',
'places.details.error': '無法載入詳細資料。',
'places.details.disabled': '管理員已關閉資訊補充。',
+1 -1
View File
@@ -86,7 +86,7 @@ const places: TranslationStrings = {
'places.loadingDetails': '正在加载地点详情…',
'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
'places.details.title': '地点详情',
'places.details.empty': '选择一条搜索结果即可查看图片和描述。',
'places.details.empty': '选择一个结果查看详情',
'places.details.loading': '正在加载详情...',
'places.details.error': '无法加载详情。',
'places.details.disabled': '管理员已关闭信息补充。',