mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
5b4a7e82ff
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.
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
|
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'
|
|
|
|
/**
|
|
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
|
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
|
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
|
* a status badge. The key is stored encrypted and never prefilled.
|
|
*/
|
|
export default function AirTrailConnectionSection(): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
const toast = useToast()
|
|
|
|
const [url, setUrl] = useState('')
|
|
const [apiKey, setApiKey] = useState('')
|
|
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
|
const [writeEnabled, setWriteEnabled] = useState(false)
|
|
const [connected, setConnected] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [testing, setTesting] = useState(false)
|
|
|
|
useEffect(() => {
|
|
airtrailApi
|
|
.getSettings()
|
|
.then(d => {
|
|
setUrl(d.url || '')
|
|
setAllowInsecureTls(!!d.allowInsecureTls)
|
|
setWriteEnabled(!!d.writeEnabled)
|
|
setConnected(!!d.connected)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
// Send the key only when the user typed a new one — never prefilled, so a blank
|
|
// field means "keep the stored key".
|
|
const keyPayload = (): { apiKey?: string } => {
|
|
const k = apiKey.trim()
|
|
return k ? { apiKey: k } : {}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
|
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
|
setConnected(!!status.connected)
|
|
setApiKey('')
|
|
if (d?.warning) toast.warning(d.warning)
|
|
else toast.success(t('settings.airtrail.toast.saved'))
|
|
} catch (err: any) {
|
|
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleTest = async () => {
|
|
setTesting(true)
|
|
try {
|
|
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
|
setConnected(!!d.connected)
|
|
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
|
|
else toast.error(d.error || t('settings.airtrail.test.failed'))
|
|
} catch {
|
|
toast.error(t('settings.airtrail.test.failed'))
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
|
|
|
return (
|
|
<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>
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={e => setUrl(e.target.value)}
|
|
placeholder="https://airtrail.example.com"
|
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={e => setApiKey(e.target.value)}
|
|
autoComplete="off"
|
|
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
|
/>
|
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button type="button"
|
|
onClick={handleSave}
|
|
disabled={saving || loading || !canSave}
|
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
|
>
|
|
<Save className="w-4 h-4" /> {t('common.save')}
|
|
</button>
|
|
<button type="button"
|
|
onClick={handleTest}
|
|
disabled={testing || loading || !url.trim()}
|
|
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
|
>
|
|
{testing ? (
|
|
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
|
) : (
|
|
<Plane className="w-4 h-4" />
|
|
)}
|
|
{t('settings.airtrail.test.button')}
|
|
</button>
|
|
{connected ? (
|
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
|
{t('settings.airtrail.connected')}
|
|
</span>
|
|
) : (
|
|
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
|
{t('settings.airtrail.notConnected')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
|
</div>
|
|
</Section>
|
|
)
|
|
}
|