feat(client): show Dawarich stays, routes and marks where they belong

The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.

The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.

The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
This commit is contained in:
Maurice
2026-09-12 23:35:43 +02:00
parent 5678eed55c
commit 5b4a7e82ff
108 changed files with 8945 additions and 295 deletions
@@ -8,11 +8,14 @@ import {
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Check, Loader2,
} from 'lucide-react'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
import MToggle from '../../components/MToggle'
import { MAdminButton, MAdminCard, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -57,9 +60,16 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean
}
function AddonIcon({ name, size = 18 }: AddonIconProps) {
function AddonIcon({ name, size = 18, enabled = true }: AddonIconProps) {
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
@@ -304,8 +314,9 @@ function MAddonRow({ addon, onToggle, t, first }: MAddonRowProps) {
addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')
return (
<div className={`flex items-center gap-3 py-[11px] ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}>
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
<AddonIcon name={addon.icon} size={18} />
{/* overflow-hidden so a brand mark that fills the slot keeps its rounded corners. */}
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center overflow-hidden rounded-[11px] bg-[color:var(--m-ic)] text-m-ink">
<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
+31 -1
View File
@@ -10,6 +10,9 @@ import MAtlasSearch from './MAtlasSearch'
import MAtlasCountryPopup from './MAtlasCountryPopup'
import MAtlasBucketSheet from './MAtlasBucketSheet'
import MToggle from '../../components/MToggle'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import DawarichAtlasDialog from '../../../components/Dawarich/DawarichAtlasDialog'
import { useAddonStore } from '../../../store/addonStore'
import { countryStatus } from '../../../pages/atlas/atlasModel'
const removeBtnCls = 'mt-4 w-full rounded-full bg-[rgba(214,39,59,.12)] py-[11px] text-center text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]' // theme-lint-disable — fixed status-danger tint
@@ -44,6 +47,8 @@ export default function MAtlas() {
const plannedCount = stats.totalCountriesPlanned || 0
const [searchOpen, setSearchOpen] = useState(false)
const [bucketOpen, setBucketOpen] = useState(false)
const [dawarichOpen, setDawarichOpen] = useState(false)
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
const [detailOpen, setDetailOpen] = useState(false)
// The dock FAB is the search button on this screen (demo Z. 1099) and hands
@@ -115,10 +120,27 @@ export default function MAtlas() {
<button
type="button"
onClick={() => setBucketOpen(true)}
className="flex h-[38px] flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
className="flex h-[38px] min-w-0 flex-1 items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-4 text-[0.78125rem] font-bold text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
{t('atlas.bucketTab')}
</button>
{/* Dawarich answers two questions about this map, so it opens from the
map — not from inside the wishlist sheet, where it was one card deep
and looked like part of the list it is only asking about. Renders
nothing when the addon is off. */}
{dawarichEnabled && (
<button
type="button"
onClick={() => setDawarichOpen(true)}
aria-label={t('dawarich.title')}
className="flex h-[38px] min-w-0 flex-1 items-center justify-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]"
>
<span className="flex h-[26px] w-[26px] flex-none overflow-hidden rounded-full">
<DawarichIcon size={26} />
</span>
<span className="truncate text-[0.78125rem] font-bold text-m-ink">{t('dawarich.title')}</span>
</button>
)}
{/* Only worth the space once there is something planned to reveal. */}
{plannedCount > 0 && (
<div className="flex h-[38px] shrink-0 items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] px-3 shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]">
@@ -128,6 +150,14 @@ export default function MAtlas() {
)}
</div>
{dawarichOpen && (
<DawarichAtlasDialog
isOpen
onClose={() => setDawarichOpen(false)}
onChanged={atlas.reloadAfterDawarich}
/>
)}
<MAtlasStatsCard stats={stats} />
<MAtlasSearch
@@ -236,6 +236,7 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
{t('atlas.addPoi')}
</button>
)}
</div>
</MSheet>
)
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import { useSearchParams } from 'react-router'
import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
@@ -9,6 +10,10 @@ import { ProviderPicker } from '../../../components/Journey/JourneyDetailPagePro
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import DawarichSuggestionsPanel from '../../../components/Dawarich/DawarichSuggestionsPanel'
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome'
import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
import { useAuthStore } from '../../../store/authStore'
import { journeyApi, addonsApi, memoriesApi } from '../../../api/client'
@@ -43,6 +48,15 @@ export default function MJourneyDetail() {
loadJourney, updateEntry, deleteEntry, uploadPhotos,
} = useJourneyDetail()
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
// upload button, so it has to know which tab is open — and it stops knowing
// when this screen goes away.
const setMobileGalleryOpen = useJourneyStore(state => state.setMobileGalleryOpen)
useEffect(() => {
setMobileGalleryOpen(view === 'gallery')
return () => setMobileGalleryOpen(false)
}, [view, setMobileGalleryOpen])
const mapRef = useRef<JourneyMapAutoHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
@@ -129,11 +143,45 @@ export default function MJourneyDetail() {
// Gallery upload — device files plus the connected photo providers (Immich/Synology).
const galleryFileRef = useRef<HTMLInputElement>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
// Whether the probe below has finished — not whether it found anything.
const [providersReady, setProvidersReady] = useState(false)
const [showUploadMenu, setShowUploadMenu] = useState(false)
const [showActionMenu, setShowActionMenu] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const dawarichEnabled = useAddonStore(state => state.isEnabled)('dawarich')
const [dawarichOpen, setDawarichOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const openUpload = useCallback(() => {
if (availableProviders.length > 0) setShowUploadMenu(true)
else galleryFileRef.current?.click()
}, [availableProviders.length])
// The dock's FAB asks through the URL, the way every other "+" in the shell
// does. The parameter is cleared straight away so going back does not reopen
// the picker; the intent is held in state until the provider probe has
// answered, because "device or Immich?" cannot be asked before we know
// whether there is an Immich.
const [params, setParams] = useSearchParams()
const [pendingUpload, setPendingUpload] = useState(false)
useEffect(() => {
if (params.get('create') !== 'photo') return
setParams(prev => {
const next = new URLSearchParams(prev)
next.delete('create')
return next
}, { replace: true })
if (!canEditEntries) return
setView('gallery')
setPendingUpload(true)
}, [params, setParams, canEditEntries, setView])
useEffect(() => {
if (!pendingUpload || !providersReady) return
setPendingUpload(false)
openUpload()
}, [pendingUpload, providersReady, openUpload])
useEffect(() => {
let active = true
;(async () => {
@@ -155,6 +203,7 @@ export default function MJourneyDetail() {
}
if (active) setAvailableProviders(connected)
} catch { /* no providers */ }
finally { if (active) setProvidersReady(true) }
})()
return () => { active = false }
}, [])
@@ -314,20 +363,18 @@ export default function MJourneyDetail() {
</button>
</div>
<span className="ml-auto flex flex-none items-center gap-2">
{view === 'gallery' && canEditEntries && (
<button
type="button"
onClick={() => (availableProviders.length > 0 ? setShowUploadMenu(true) : galleryFileRef.current?.click())}
disabled={uploading}
{/* Uploading lives on the dock's FAB while the Gallery is open — the
one big action on the screen. A second button up here would be the
same thing twice, so it only appears while an upload is running,
as its progress. */}
{view === 'gallery' && canEditEntries && uploading && (
<span
role="status"
aria-label={t('common.upload')}
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)] disabled:opacity-60"
className="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-m-act text-m-actfg shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
>
{uploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Upload size={16} strokeWidth={2.2} />
)}
</button>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</span>
)}
<button
type="button"
@@ -340,6 +387,47 @@ export default function MJourneyDetail() {
</span>
</div>
{/* The stays Dawarich recorded for these dates, as candidate entries. Over
the map rather than in the header: it belongs to what is on the map,
and the header is already three controls wide on a phone. Sits above
the card rail so it never covers the card somebody is reading. */}
{dawarichEnabled && canEditEntries && view === 'timeline' && (
<button
type="button"
onClick={() => setDawarichOpen(true)}
aria-label={t('dawarich.suggestions.title')}
className="absolute right-4 z-[9] flex h-[46px] w-[46px] items-center justify-center overflow-hidden rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] shadow-[0_6px_18px_-8px_rgba(0,0,0,.35)] bottom-[calc(var(--bottom-nav-h,84px)+16px+128px)]"
>
<DawarichIcon size={46} />
</button>
)}
{dawarichOpen && current && (
<MSheet open onClose={() => setDawarichOpen(false)} variant="bottom" material="glass" ariaLabel={t('dawarich.suggestions.title')}>
<div className="flex max-h-[82dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
<DawarichIcon size={40} />
</span>
}
title={t('dawarich.suggestions.title')}
onClose={() => setDawarichOpen(false)}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-4">
{/* Journal entries and nothing else: a journey is for writing, and
the trip planner already offers the same stay as a place. */}
<DawarichSuggestionsPanel
bare
journals={[{ id: current.id, label: current.title }]}
onAccepted={() => { void loadJourney(current.id) }}
/>
</div>
</div>
</MSheet>
)}
{/* Horizontal card timeline */}
{view === 'timeline' && entries.length > 0 && (
<div
@@ -0,0 +1,120 @@
import React from 'react'
import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MToggle from '../../components/MToggle'
/**
* Phone twin of components/Settings/DawarichConnectionSection.
*
* Presentation only: every piece of behaviour lives in `useDawarichConnection`,
* which both shells share. That is not tidiness — SonarCloud allows 3%
* duplicated lines on a PR's new code, and a second copy of the connect / test /
* sync logic would spend the whole budget on its own.
*/
export default function MDawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const S = useDawarichConnection()
return (
<MSetCard title={t('dawarich.title')} icon={Plug} className="mt-3">
<MSetHint className="mt-0">{t('dawarich.intro')}</MSetHint>
<div className="mt-3 space-y-3">
<div>
<MSetInput
type="url"
inputMode="url"
value={S.url}
onChange={e => S.setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
aria-label={t('dawarich.url')}
/>
</div>
<div>
<MSetInput
type="password"
value={S.apiKey}
onChange={e => S.setApiKey(e.target.value)}
autoComplete="off"
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
aria-label={t('dawarich.apiKey')}
/>
<MSetHint>{t('dawarich.apiKeyHint')}</MSetHint>
</div>
</div>
<div className="mt-1">
<MSetRow
first
label={t('dawarich.syncEnabled')}
sub={t('dawarich.syncEnabledHint')}
trailing={
<MToggle checked={S.syncEnabled} onChange={S.toggleSync} ariaLabel={t('dawarich.syncEnabled')} />
}
/>
<MSetRow
label={t('dawarich.allowInsecureTls')}
sub={t('dawarich.allowInsecureTlsHint')}
trailing={
<MToggle
checked={S.allowInsecureTls}
onChange={S.toggleInsecureTls}
ariaLabel={t('dawarich.allowInsecureTls')}
/>
}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<MSetButton onClick={S.save} disabled={S.saving || S.loading || !S.canSave}>
<Save size={14} /> {t('common.save')}
</MSetButton>
<MSetButton variant="ghost" onClick={S.test} disabled={S.testing || S.loading || !S.url.trim()}>
<Plug size={14} /> {t('dawarich.test.button')}
</MSetButton>
{S.connected && (
<MSetButton variant="ghost" onClick={S.syncNow} disabled={S.syncing}>
<RefreshCw size={14} className={S.syncing ? 'animate-spin' : ''} /> {t('dawarich.syncNow')}
</MSetButton>
)}
</div>
<div className="mt-3 space-y-[4px]">
<p className="font-geist text-[0.6875rem] font-semibold text-m-ink">
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</p>
{S.probeMessage && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">{S.probeMessage}</p>
)}
{S.connected && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-muted">
{S.lastSyncAt
? t('dawarich.lastSync', { when: new Date(S.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
</p>
)}
{S.lastSyncError && (
<p className="font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-danger)]">
{S.lastSyncError}
</p>
)}
{S.capabilities?.serverVersion && (
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
{t('dawarich.serverVersion', { version: S.capabilities.serverVersion })}
</p>
)}
</div>
{S.connected && (
<div className="mt-3">
<MSetButton variant="danger" onClick={S.disconnect} disabled={S.saving}>
<Unplug size={14} /> {t('dawarich.disconnect')}
</MSetButton>
</div>
)}
</MSetCard>
)
}
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useAddonStore } from '../../../store/addonStore'
import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
import MDawarichConnectionSection from './MDawarichConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { useAuthStore } from '../../../store/authStore'
@@ -17,6 +18,7 @@ export default function MSettingsIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const dawarichEnabled = addonEnabled('dawarich')
const managed = useAuthStore((s) => s.managed)
useEffect(() => {
@@ -27,6 +29,9 @@ export default function MSettingsIntegrations() {
<>
<MPhotoProvidersSection />
{airtrailEnabled && <MAirTrailConnectionSection />}
{/* Reaches a server the reader runs themselves, which a managed install has
no route to — the same gate the desktop shell puts on AirTrail. */}
{dawarichEnabled && !managed && <MDawarichConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -44,6 +45,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]">
<MapViewAuto
tripId={planner.tripId}
dawarichTrack={planner.dawarichTrail.track}
places={planner.mapPlaces}
dayPlaces={planner.dayPlaces}
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
@@ -115,9 +117,9 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
Tailwind rather than inline because the compass band is identified by being
the one element with an inline --bottom-nav-h, and a second would make that
ambiguous. */}
{mapActive && !planner.roadtripActive && (
{mapActive && (!planner.roadtripActive || planner.dawarichEnabled) && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{planner.overviewActive && (
{!planner.roadtripActive && planner.overviewActive && (
<TripRouteOverviewPanel
overview={planner.tripOverview}
unit={distanceUnit}
@@ -128,7 +130,18 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
maxWidth={240}
/>
)}
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
{!planner.roadtripActive && (
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
)}
{/* Kept in road-trip mode: the recorded route beside the planned one is
exactly the comparison a drive invites. */}
{planner.dawarichEnabled && (
<DawarichTrailPill
active={planner.dawarichTrailShown}
status={planner.dawarichTrail.status}
onToggle={planner.toggleDawarichTrail}
/>
)}
</div>
)}
</div>
@@ -13,6 +13,10 @@ import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { MPlacesBrowserProps } from '../MTripShell'
import DawarichSuggestionsPanel from '../../../../components/Dawarich/DawarichSuggestionsPanel'
import { formatDayOption } from '../../../../components/Dawarich/dawarichSuggestionModel'
import { refreshTripAfterAccept } from '../../../../components/Dawarich/dawarichTripRefresh'
import { useTranslation } from '../../../../i18n'
import type { Place } from '../../../../types'
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
@@ -31,6 +35,8 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro
*/
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
const { t, places, categories, assignments, days, trip } = planner
// The planner hook carries `t` but not the locale; day labels need both.
const { locale } = useTranslation()
const canEditPlaces = planner.can('place_edit', trip)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
@@ -231,6 +237,24 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
)}
</div>
{/* Stays Dawarich recorded on these dates (#2279). Same component as the
desktop rail — the rows are the same rows, and the panel renders
nothing when there is nothing pending. */}
<div className="mt-3">
<DawarichSuggestionsPanel
tripId={planner.tripId}
trips={[{ id: planner.tripId, label: t('dawarich.accept.thisTrip') }]}
daysForTrip={() => days.map(day => ({
id: day.id,
...formatDayOption(day.day_number, day.date, locale, t),
}))}
// The place it just created belongs on the map and in the list
// now, not after a reload.
onAccepted={() => { void refreshTripAfterAccept(planner.tripId) }}
initiallyCollapsed
/>
</div>
{/* ── Selection toolbar ── */}
{selectMode && (
<div className="mt-2 flex items-center gap-2 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] py-[6px] pl-[14px] pr-[6px] backdrop-blur-[20px]">
@@ -1,4 +1,5 @@
import { useMemo, useRef, useState } from 'react'
import DawarichIcon from '../../../../components/shared/DawarichIcon'
import {
Bookmark, Camera, ChevronRight, ExternalLink, Loader2, Map as MapIcon, Navigation, Paperclip,
Pencil, Phone, Plus, Route, Trash2, Upload, X,
@@ -301,7 +302,16 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[1rem] font-bold leading-snug">{place.name}</div>
<div className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-[1rem] font-bold leading-snug">{place.name}</span>
{/* Accepted out of the traveller's own recordings — the same
mark the desktop inspector carries. */}
{place.source === 'dawarich' && (
<span className="flex-none overflow-hidden rounded-[5px]">
<DawarichIcon size={15} />
</span>
)}
</div>
{place.address && (
<div className="mt-[2px] font-geist text-[0.6875rem] leading-[1.4] text-m-muted">{place.address}</div>
)}