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
@@ -3,6 +3,7 @@ import { Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
import AirTrailIcon from '../shared/AirTrailIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
@@ -78,7 +79,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const canSave = !!url.trim() && (connected || !!apiKey.trim())
return (
<Section title={t('settings.airtrail.title')} icon={Plane}>
<Section title={t('settings.airtrail.title')} icon={AirTrailIcon}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
@@ -0,0 +1,193 @@
import React from 'react'
import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
import DawarichIcon from '../shared/DawarichIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
/**
* Settings → Integrations → Dawarich.
*
* All of the behaviour is in `useDawarichConnection`, shared with the phone
* twin; this file is markup. The layout follows the AirTrail and LLM sections
* so the three read as one shelf, but the classes are the semantic tokens
* rather than the raw `slate-*` those two still carry — a new surface has to
* follow the user's accent and colour scheme.
*
* The status block below the fields is the part that earns its space: a
* connection to somebody else's server fails in ways they can act on — a wrong
* key, a certificate, an instance that is simply off — and "not connected" with
* no reason is the version of this card that generates support questions.
*/
export default function DawarichConnectionSection(): React.ReactElement {
const { t, locale } = useTranslation()
const S = useDawarichConnection()
return (
<Section title={t('dawarich.title')} icon={DawarichIcon}>
<div className="space-y-3">
<p className="text-caption text-content-secondary">{t('dawarich.intro')}</p>
<div>
<label htmlFor="dawarich-url" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.url')}
</label>
<input
id="dawarich-url"
type="url"
value={S.url}
onChange={e => S.setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
</div>
<div>
<label htmlFor="dawarich-key" className="block text-caption font-medium mb-1.5 text-content-secondary">
{t('dawarich.apiKey')}
</label>
<input
id="dawarich-key"
type="password"
value={S.apiKey}
onChange={e => S.setApiKey(e.target.value)}
autoComplete="off"
placeholder={S.connected && !S.apiKey ? '••••••••' : t('dawarich.apiKeyPlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
/>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.apiKeyHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.syncEnabled} onToggle={S.toggleSync} label={t('dawarich.syncEnabled')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.syncEnabled')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.syncEnabledHint')}</p>
</div>
<div>
<div className="flex items-center gap-3">
<ToggleSwitch on={S.allowInsecureTls} onToggle={S.toggleInsecureTls} label={t('dawarich.allowInsecureTls')} />
<span className="text-body font-medium text-content-secondary">{t('dawarich.allowInsecureTls')}</span>
</div>
<p className="mt-1 text-caption text-content-muted">{t('dawarich.allowInsecureTlsHint')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={S.save}
disabled={S.saving || S.loading || !S.canSave}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
type="button"
onClick={S.test}
disabled={S.testing || S.loading || !S.url.trim()}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
{S.testing
? <span className="w-4 h-4 border-2 rounded-full animate-spin border-edge border-t-transparent" />
: <Plug className="w-4 h-4" />}
{t('dawarich.test.button')}
</button>
{S.connected && (
<button
type="button"
onClick={S.syncNow}
disabled={S.syncing}
className="flex items-center gap-2 px-4 py-2 border rounded-lg text-body border-edge text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${S.syncing ? 'animate-spin' : ''}`} />
{t('dawarich.syncNow')}
</button>
)}
<span className="basis-full sm:basis-auto text-caption font-medium flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${S.connected ? 'bg-success' : 'bg-surface-tertiary'}`} />
<span className={S.connected ? 'text-success' : 'text-content-muted'}>
{S.connected ? t('dawarich.connected') : t('dawarich.notConnected')}
</span>
</span>
{S.connected && (
<button
type="button"
onClick={S.disconnect}
disabled={S.saving}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-caption text-danger hover:bg-danger-soft disabled:opacity-50"
>
<Unplug className="w-3.5 h-3.5" /> {t('dawarich.disconnect')}
</button>
)}
</div>
<DawarichConnectionStatus state={S} locale={locale} />
</div>
</Section>
)
}
/**
* Where the connection stands, in the place where it helps someone decide what
* to do next: what the last sync did, and which parts of their instance TREK
* could actually reach.
*
* Its own component because the phone twin renders the same three facts in a
* different frame, and because it is the bit that keeps growing.
*/
function DawarichConnectionStatus({
state,
locale,
}: {
state: ReturnType<typeof useDawarichConnection>
locale: string
}): React.ReactElement | null {
const { t } = useTranslation()
if (!state.connected && !state.probeMessage) return null
const missing: string[] = []
if (state.capabilities) {
if (!state.capabilities.visits) missing.push(t('dawarich.capability.visits'))
if (!state.capabilities.tracks && !state.capabilities.points) missing.push(t('dawarich.capability.track'))
if (!state.capabilities.locations) missing.push(t('dawarich.capability.locations'))
if (!state.capabilities.visitedCities) missing.push(t('dawarich.capability.visitedCities'))
}
return (
<div className="rounded-lg border p-3 space-y-1.5 border-edge bg-surface-secondary">
{state.probeMessage && <p className="text-caption text-content">{state.probeMessage}</p>}
{state.connected && (
<p className="text-caption text-content-secondary">
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
{state.lastSyncState === 'partial' && ` · ${t('dawarich.syncPartial')}`}
</p>
)}
{state.lastSyncError && (
<p className="text-caption text-danger">{state.lastSyncError}</p>
)}
{state.capabilities?.serverVersion && (
<p className="text-caption text-content-muted">
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
</p>
)}
{missing.length > 0 && (
<p className="text-caption text-content-muted">
{t('dawarich.capability.missing', { features: missing.join(', ') })}
</p>
)}
</div>
)
}
@@ -7,6 +7,7 @@ import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import DawarichConnectionSection from './DawarichConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import ApiKeysSection from './ApiKeysSection'
import { PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from '../../api/oauthScopes'
@@ -106,6 +107,7 @@ export default function IntegrationsTab(): React.ReactElement {
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
{/* 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. */}
@@ -127,6 +129,7 @@ function useIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const dawarichEnabled = addonEnabled('dawarich')
useEffect(() => {
loadAddons()
@@ -307,7 +310,7 @@ function useIntegrations() {
return {
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
+5 -1
View File
@@ -3,7 +3,11 @@ import type { LucideIcon } from 'lucide-react'
interface SectionProps {
title: string
icon: LucideIcon
/**
* A lucide icon, or anything that takes the same `className` — a brand mark
* standing in for a glyph gets sized by the same utilities.
*/
icon: LucideIcon | React.ComponentType<{ className?: string }>
badge?: React.ReactNode
children: React.ReactNode
}