Add integration settings component and define built-in integrations

- Introduced a new `IntegrationsSettings` component for managing external service integrations.
- Created `integrationCatalog.ts` to define built-in integrations with their respective metadata.
- Updated English locale file to include translations for built-in services and their descriptions.
- Refactored settings page to incorporate the new integrations component, enhancing user experience for managing integrations.
This commit is contained in:
Sean Morley
2026-07-08 20:42:48 -04:00
parent bf063b0632
commit a14256f128
5 changed files with 625 additions and 475 deletions

View File

@@ -0,0 +1,510 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
import type { ImmichIntegration, User, WandererIntegration } from '$lib/types';
import ImmichLogo from '$lib/assets/immich.svg';
import GoogleMapsLogo from '$lib/assets/google_maps.svg';
import StravaLogo from '$lib/assets/strava.svg';
import WandererLogoSrc from '$lib/assets/wanderer.svg';
import { BUILTIN_INTEGRATIONS } from './integrationCatalog';
import MapIcon from '~icons/mdi/map';
import BookOpenPageVariant from '~icons/mdi/book-open-page-variant';
import WeatherSunset from '~icons/mdi/weather-sunset';
import MapSearch from '~icons/mdi/map-search';
export let user: User;
export let immichIntegration: ImmichIntegration | null = null;
export let googleMapsEnabled = false;
export let stravaGlobalEnabled = false;
export let stravaUserEnabled = false;
export let wandererEnabled = false;
export let wandererIntegration: WandererIntegration | null = null;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: '',
copy_locally: true
};
let newWandererIntegration: WandererIntegration = {
server_url: '',
api_key: '',
id: ''
};
const builtinIcons = {
osm: MapIcon,
wikipedia: BookOpenPageVariant,
sunrise: WeatherSunset,
overpass: MapSearch
} as const;
function handleImmichError(data: {
code?: string;
details?: string;
message?: string;
error?: string;
server_url?: string[];
api_key?: string[];
}) {
if (data.code === 'immich.connection_failed') {
return `${$t('immich.connection_error')}: ${data.details || data.message}`;
}
if (data.code === 'immich.integration_exists') {
return $t('immich.integration_already_exists');
}
if (data.code === 'immich.integration_not_found') {
return $t('immich.integration_not_found');
}
if (data.error && data.message) {
return data.message;
}
const errors: string[] = [];
if (data.server_url) errors.push(`Server URL: ${data.server_url.join(', ')}`);
if (data.api_key) errors.push(`API Key: ${data.api_key.join(', ')}`);
return errors.length > 0
? `${$t('immich.validation_error')}: ${errors.join('; ')}`
: $t('immich.immich_error');
}
async function enableImmichIntegration() {
const isUpdate = !!immichIntegration?.id;
const url = isUpdate
? `/api/integrations/immich/${immichIntegration?.id ?? ''}/`
: '/api/integrations/immich/';
const method = isUpdate ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newImmichIntegration)
});
const data = await res.json();
if (res.ok) {
addToast('success', $t(isUpdate ? 'immich.immich_updated' : 'immich.immich_enabled'));
immichIntegration = data;
newImmichIntegration = { server_url: '', api_key: '', id: '', copy_locally: true };
} else {
addToast('error', handleImmichError(data));
}
} catch {
addToast('error', $t('immich.network_error'));
}
}
async function disableImmichIntegration() {
if (!immichIntegration?.id) return;
const res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'DELETE'
});
if (res.ok) {
addToast('success', $t('immich.immich_disabled'));
immichIntegration = null;
} else {
addToast('error', $t('immich.immich_error'));
}
}
async function stravaAuthorizeRedirect() {
const res = await fetch('/api/integrations/strava/authorize/', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
window.location.href = data.auth_url;
} else {
addToast('error', $t('strava.authorization_error'));
}
}
async function stravaDisconnect() {
const res = await fetch('/api/integrations/strava/disable/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
addToast('success', $t('strava.disconnected'));
stravaUserEnabled = false;
} else {
addToast('error', $t('strava.disconnect_error'));
}
}
async function wandererDisconnect() {
const res = await fetch('/api/integrations/wanderer/disable/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
addToast('success', $t('wanderer.disconnected'));
wandererEnabled = false;
wandererIntegration = null;
} else {
addToast('error', $t('wanderer.disconnect_error'));
}
}
async function enableWandererIntegration() {
const integrationId = newWandererIntegration.id || wandererIntegration?.id;
const isUpdate = !!integrationId;
const url = isUpdate
? `/api/integrations/wanderer/${integrationId}/`
: '/api/integrations/wanderer/';
const method = isUpdate ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newWandererIntegration)
});
const responseData = await res.json();
if (res.ok) {
addToast('success', $t(isUpdate ? 'wanderer.updated' : 'wanderer.connected'));
wandererIntegration = responseData;
wandererEnabled = true;
newWandererIntegration = { server_url: '', api_key: '', id: '' };
} else {
const message =
responseData.error ||
responseData.detail ||
(Array.isArray(responseData) ? responseData.join(', ') : null) ||
$t('wanderer.connection_error');
addToast('error', message);
}
} catch {
addToast('error', $t('wanderer.connection_error'));
}
}
</script>
<!-- Immich -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={ImmichLogo} alt="Immich" class="w-8 h-8 shrink-0" />
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold">Immich</h3>
<p class="text-sm text-base-content/70">{$t('immich.immich_integration_desc')}</p>
</div>
{#if immichIntegration}
<div class="badge badge-success ml-auto shrink-0">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto shrink-0">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if immichIntegration && !newImmichIntegration.id}
<div class="flex gap-4 justify-center mb-4">
<button
type="button"
class="btn btn-warning"
on:click={() => {
if (immichIntegration) newImmichIntegration = { ...immichIntegration, api_key: '' };
}}
>
✏️ {$t('lodging.edit')}
</button>
<button type="button" class="btn btn-error" on:click={disableImmichIntegration}>
{$t('immich.disable')}
</button>
</div>
{/if}
{#if !immichIntegration || newImmichIntegration.id}
<div class="space-y-4">
<div class="form-control">
<label class="label" for="immich-server-url">
<span class="label-text font-medium">{$t('immich.server_url')}</span>
</label>
<input
id="immich-server-url"
type="url"
bind:value={newImmichIntegration.server_url}
class="input input-bordered input-primary focus:input-primary w-full"
placeholder="https://immich.example.com/api"
/>
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
<div class="label">
<span class="label-text-alt text-warning">{$t('immich.api_note')}</span>
</div>
{/if}
{#if newImmichIntegration.server_url && (newImmichIntegration.server_url.includes('localhost') || newImmichIntegration.server_url.includes('127.0.0.1'))}
<div class="label">
<span class="label-text-alt text-warning">{$t('immich.localhost_note')}</span>
</div>
{/if}
</div>
<div class="form-control">
<label class="label" for="immich-api-key">
<span class="label-text font-medium">{$t('immich.api_key')}</span>
</label>
<input
id="immich-api-key"
type="password"
bind:value={newImmichIntegration.api_key}
class="input input-bordered input-primary focus:input-primary w-full"
placeholder={$t('immich.api_key_placeholder')}
/>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={newImmichIntegration.copy_locally}
class="toggle toggle-primary"
/>
<div>
<span class="label-text font-medium">
{$t('immich.copy_locally') || 'Copy Locally'}
</span>
<p class="text-sm text-base-content/70">
{$t('immich.copy_locally_desc') || 'If enabled, files will be copied locally.'}
</p>
</div>
</label>
</div>
<button type="button" on:click={enableImmichIntegration} class="btn btn-primary w-full">
{!immichIntegration?.id
? `🔗 ${$t('immich.enable_integration')}`
: `💾 ${$t('immich.update_integration')}`}
</button>
</div>
{/if}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/immich_integration.html"
target="_blank"
rel="noopener noreferrer"
>
{$t('navbar.documentation')}
</a>
</p>
</div>
</div>
<!-- Google Maps -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={GoogleMapsLogo} alt="Google Maps" class="w-8 h-8 shrink-0" />
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold">Google Maps</h3>
<p class="text-sm text-base-content/70">{$t('google_maps.google_maps_integration_desc')}</p>
</div>
{#if googleMapsEnabled}
<div class="badge badge-success ml-auto shrink-0">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto shrink-0">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if user.is_staff || !googleMapsEnabled}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/google_maps_integration.html"
target="_blank"
rel="noopener noreferrer"
>
{$t('navbar.documentation')}
</a>
</p>
{:else if !googleMapsEnabled}
<p class="text-sm"> {$t('google_maps.google_maps_integration_desc_no_staff')}</p>
{/if}
</div>
{/if}
</div>
<!-- Strava -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={StravaLogo} alt="Strava" class="w-8 h-8 rounded-md shrink-0" />
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold">Strava</h3>
<p class="text-sm text-base-content/70">{$t('strava.strava_integration_desc')}</p>
</div>
{#if stravaGlobalEnabled && stravaUserEnabled}
<div class="badge badge-success ml-auto shrink-0">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto shrink-0">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if !stravaGlobalEnabled}
<div class="text-center">
<p class="text-base-content/70 mb-4">
{$t('strava.not_enabled') || 'Strava integration is not enabled on this instance.'}
</p>
</div>
{:else if !stravaUserEnabled}
<div class="text-center">
<button type="button" class="btn btn-primary" on:click={stravaAuthorizeRedirect}>
🔗 {$t('strava.connect_account')}
</button>
</div>
{:else}
<div class="text-center">
<button type="button" class="btn btn-error" on:click={stravaDisconnect}>
❌ {$t('strava.disconnect')}
</button>
</div>
{/if}
{#if user.is_staff || !stravaGlobalEnabled}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/strava_integration.html"
target="_blank"
rel="noopener noreferrer"
>
{$t('navbar.documentation')}
</a>
</p>
{:else if !stravaGlobalEnabled}
<p class="text-sm"> {$t('google_maps.google_maps_integration_desc_no_staff')}</p>
{/if}
</div>
{/if}
</div>
<!-- Wanderer -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={WandererLogoSrc} alt="Wanderer" class="w-8 h-8 shrink-0" />
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold">Wanderer</h3>
<p class="text-sm text-base-content/70">{$t('wanderer.wanderer_integration_desc')}</p>
</div>
{#if wandererEnabled}
<div class="badge badge-success ml-auto shrink-0">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto shrink-0">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if wandererIntegration && !newWandererIntegration.id}
<div class="flex gap-4 justify-center mb-4">
<button
type="button"
class="btn btn-warning"
on:click={() => {
if (wandererIntegration) {
newWandererIntegration = { ...wandererIntegration, api_key: '' };
}
}}
>
✏️ {$t('lodging.edit')}
</button>
<button type="button" class="btn btn-error" on:click={wandererDisconnect}>
❌ {$t('strava.disconnect')}
</button>
</div>
{/if}
{#if !wandererIntegration || newWandererIntegration.id}
<div class="space-y-4">
<div class="form-control">
<label class="label" for="wanderer-server-url">
<span class="label-text font-medium">{$t('wanderer.server_url')}</span>
</label>
<input
id="wanderer-server-url"
type="url"
class="input input-bordered input-primary focus:input-primary w-full"
placeholder="https://wanderer.example.com"
bind:value={newWandererIntegration.server_url}
/>
{#if newWandererIntegration.server_url && (newWandererIntegration.server_url.includes('localhost') || newWandererIntegration.server_url.includes('127.0.0.1'))}
<div class="label">
<span class="label-text-alt text-warning">{$t('wanderer.localhost_note')}</span>
</div>
{/if}
</div>
<div class="form-control">
<label class="label" for="wanderer-api-key">
<span class="label-text font-medium">{$t('wanderer.api_key')}</span>
</label>
<input
id="wanderer-api-key"
type="password"
class="input input-bordered input-primary focus:input-primary w-full"
placeholder={$t('wanderer.api_key_placeholder')}
bind:value={newWandererIntegration.api_key}
/>
</div>
<button type="button" class="btn btn-primary w-full" on:click={enableWandererIntegration}>
{!wandererIntegration?.id
? `🔗 ${$t('adventures.connect_to_wanderer')}`
: `💾 ${$t('wanderer.update_integration')}`}
</button>
</div>
{/if}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/wanderer_integration.html"
target="_blank"
rel="noopener noreferrer"
>
{$t('navbar.documentation')}
</a>
</p>
</div>
</div>
<!-- Included by default -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<div class="p-2 bg-base-100 rounded-lg text-xl leading-none"></div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-bold">{$t('settings.integrations_hub.builtin_title')}</h3>
<p class="text-sm text-base-content/70">
{$t('settings.integrations_hub.builtin_desc')}
</p>
</div>
<div class="badge badge-success ml-auto shrink-0">
{$t('settings.integrations_hub.included')}
</div>
</div>
<ul class="divide-y divide-base-300 rounded-lg border border-base-300 bg-base-100/60">
{#each BUILTIN_INTEGRATIONS as integration (integration.id)}
{@const Icon = builtinIcons[integration.icon]}
<li class="flex items-start gap-3 p-4">
<div class="p-1.5 text-primary shrink-0">
<Icon class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<p class="font-medium">{$t(integration.nameKey)}</p>
<p class="text-sm text-base-content/70 mt-0.5">{$t(integration.descriptionKey)}</p>
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,33 @@
export type BuiltinIntegrationDef = {
id: string;
nameKey: string;
descriptionKey: string;
icon: 'osm' | 'wikipedia' | 'sunrise' | 'overpass';
};
export const BUILTIN_INTEGRATIONS: BuiltinIntegrationDef[] = [
{
id: 'nominatim',
nameKey: 'settings.integrations_hub.builtin.nominatim_name',
descriptionKey: 'settings.integrations_hub.builtin.nominatim_desc',
icon: 'osm'
},
{
id: 'wikipedia',
nameKey: 'settings.integrations_hub.builtin.wikipedia_name',
descriptionKey: 'settings.integrations_hub.builtin.wikipedia_desc',
icon: 'wikipedia'
},
{
id: 'sunrise_sunset',
nameKey: 'settings.integrations_hub.builtin.sunrise_name',
descriptionKey: 'settings.integrations_hub.builtin.sunrise_desc',
icon: 'sunrise'
},
{
id: 'overpass',
nameKey: 'settings.integrations_hub.builtin.overpass_name',
descriptionKey: 'settings.integrations_hub.builtin.overpass_desc',
icon: 'overpass'
}
];

View File

@@ -714,6 +714,38 @@
"emails": "Emails",
"integrations": "Integrations",
"integrations_desc": "Connect external services to enhance your experience",
"integrations_hub": {
"summary_builtin": "Built-in services",
"summary_builtin_desc": "Always available on every instance",
"summary_instance": "Instance services",
"summary_instance_desc": "Enabled by your server admin",
"summary_personal": "Your connections",
"summary_personal_desc": "Linked to your account",
"builtin_title": "Built-in services",
"builtin_desc": "These data sources power AdventureLog out of the box. No setup is required.",
"included": "Included",
"learn_more": "Learn more",
"instance_title": "Instance services",
"instance_desc": "Optional services configured by your AdventureLog administrator.",
"personal_title": "Your connections",
"personal_desc": "Link your own accounts and self-hosted services.",
"google_maps_features": "Place search, map details, quick-add enrichment, and nearby recommendations.",
"google_maps_active_hint": "Google Maps is active on this instance. Place search and enrichment use it when available.",
"builtin": {
"nominatim_name": "OpenStreetMap Nominatim",
"nominatim_desc": "Fallback geocoding and place search when Google Maps is unavailable.",
"nominatim_features": "Used for location search, reverse geocoding, and coordinate lookups.",
"wikipedia_name": "Wikipedia",
"wikipedia_desc": "Enriches locations with background descriptions and reference images.",
"wikipedia_features": "Used when generating descriptions and importing Wikipedia photos.",
"sunrise_name": "SunriseSunset.io",
"sunrise_desc": "Adds sunrise and sunset times to your location visits.",
"sunrise_features": "Shown on location detail pages for each visit date.",
"overpass_name": "OpenStreetMap Overpass",
"overpass_desc": "Powers nearby recommendations from OpenStreetMap data.",
"overpass_features": "Used for map recommendations and as a fallback when Google is unavailable."
}
},
"admin": "Admin",
"advanced": "Advanced",
"profile_info": "Profile Information",

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import type { Collection, ContentImage, Location, Collaborator, Lodging } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -49,8 +48,14 @@
export let data: PageData;
// Handle both 'collection' and 'adventure' properties for backward compatibility
let collection: Collection = (data.props as any).collection || (data.props as any).adventure;
function getCollectionFromPageData(pageData: PageData): Collection | null | undefined {
return (
(pageData.props as { collection?: Collection; adventure?: Collection }).collection ??
(pageData.props as { collection?: Collection; adventure?: Collection }).adventure
);
}
let collection: Collection | undefined;
let currentSlide = 0;
let notFound: boolean = false;
let isLocationModalOpen: boolean = false;
@@ -475,11 +480,40 @@
selectedCalendarEvent = null;
}
onMount(async () => {
if (!collection) {
function resetCollectionPageUiState() {
currentSlide = 0;
isLocationModalOpen = false;
isLodgingModalOpen = false;
isTransportationModalOpen = false;
isChecklistModalOpen = false;
isNoteModalOpen = false;
adventureToEdit = null;
transportationToEdit = null;
noteToEdit = null;
checklistToEdit = null;
lodgingToEdit = null;
isImageModalOpen = false;
isLocationLinkModalOpen = false;
showCalendarModal = false;
selectedCalendarEvent = null;
isSocialShareModalOpen = false;
calendarInitialDate = null;
}
function applyCollectionPageData(collectionData: Collection | null | undefined) {
if (!collectionData) {
notFound = true;
collection = undefined;
resetCollectionPageUiState();
return;
}
});
notFound = false;
collection = collectionData;
resetCollectionPageUiState();
}
$: applyCollectionPageData(getCollectionFromPageData(data));
function goToSlide(index: number) {
currentSlide = index;

View File

@@ -17,13 +17,9 @@
import TotpModal from '$lib/components/TOTPModal.svelte';
import { copyrightYear } from '$lib/config.js';
import AppVersionDisplay from '$lib/components/shared/AppVersionDisplay.svelte';
import ImmichLogo from '$lib/assets/immich.svg';
import GoogleMapsLogo from '$lib/assets/google_maps.svg';
import StravaLogo from '$lib/assets/strava.svg';
import WandererLogoSrc from '$lib/assets/wanderer.svg';
import IntegrationsSettings from '$lib/components/settings/IntegrationsSettings.svelte';
export let data;
console.log(data);
let user: User;
let emails: typeof data.props.emails;
if (data.user) {
@@ -70,19 +66,6 @@
// Indicates restore operation in progress to disable button and show loader
let isRestoring: boolean = false;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: '',
copy_locally: true
};
let newWandererIntegration: WandererIntegration = {
server_url: '',
api_key: '',
id: ''
};
let isMFAModalOpen: boolean = false;
let apiKeys: APIKey[] = data.props.apiKeys ?? [];
@@ -345,74 +328,6 @@
}
}
function handleImmichError(data: {
code: string;
details: any;
message: any;
error: any;
server_url: any[];
api_key: any[];
}) {
if (data.code === 'immich.connection_failed') {
return `${$t('immich.connection_error')}: ${data.details || data.message}`;
} else if (data.code === 'immich.integration_exists') {
return $t('immich.integration_already_exists');
} else if (data.code === 'immich.integration_not_found') {
return $t('immich.integration_not_found');
} else if (data.error && data.message) {
return data.message;
} else {
// Handle validation errors
const errors = [];
if (data.server_url) errors.push(`Server URL: ${data.server_url.join(', ')}`);
if (data.api_key) errors.push(`API Key: ${data.api_key.join(', ')}`);
return errors.length > 0
? `${$t('immich.validation_error')}: ${errors.join('; ')}`
: $t('immich.immich_error');
}
}
async function enableImmichIntegration() {
const isUpdate = !!immichIntegration?.id;
const url = isUpdate
? `/api/integrations/immich/${immichIntegration?.id ?? ''}/`
: '/api/integrations/immich/';
const method = isUpdate ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newImmichIntegration)
});
const data = await res.json();
if (res.ok) {
addToast('success', $t(isUpdate ? 'immich.immich_updated' : 'immich.immich_enabled'));
immichIntegration = data;
} else {
addToast('error', handleImmichError(data));
}
} catch (error) {
addToast('error', $t('immich.network_error'));
}
}
async function disableImmichIntegration() {
if (immichIntegration && immichIntegration.id) {
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'DELETE'
});
if (res.ok) {
addToast('success', $t('immich.immich_disabled'));
immichIntegration = null;
} else {
addToast('error', $t('immich.immich_error'));
}
}
}
async function disableMfa() {
const res = await fetch('/auth/browser/v1/account/authenticators/totp', {
method: 'DELETE'
@@ -428,86 +343,6 @@
}
}
async function stravaAuthorizeRedirect() {
const res = await fetch('/api/integrations/strava/authorize/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
const data = await res.json();
window.location.href = data.auth_url;
} else {
addToast('error', $t('strava.authorization_error'));
}
}
async function stravaDisconnect() {
const res = await fetch('/api/integrations/strava/disable/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
addToast('success', $t('strava.disconnected'));
stravaUserEnabled = false;
} else {
addToast('error', $t('strava.disconnect_error'));
}
}
async function wandererDisconnect() {
const res = await fetch('/api/integrations/wanderer/disable/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
addToast('success', $t('wanderer.disconnected'));
wandererEnabled = false;
wandererIntegration = null;
} else {
addToast('error', $t('wanderer.disconnect_error'));
}
}
async function enableWandererIntegration() {
const integrationId = newWandererIntegration.id || wandererIntegration?.id;
const isUpdate = !!integrationId;
const url = isUpdate
? `/api/integrations/wanderer/${integrationId}/`
: '/api/integrations/wanderer/';
const method = isUpdate ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newWandererIntegration)
});
const responseData = await res.json();
if (res.ok) {
addToast('success', $t(isUpdate ? 'wanderer.updated' : 'wanderer.connected'));
wandererIntegration = responseData;
wandererEnabled = true;
newWandererIntegration = { server_url: '', api_key: '', id: '' };
} else {
const message =
responseData.error ||
responseData.detail ||
(Array.isArray(responseData) ? responseData.join(', ') : null) ||
$t('wanderer.connection_error');
addToast('error', message);
}
} catch {
addToast('error', $t('wanderer.connection_error'));
}
}
async function createApiKey() {
if (!newApiKeyName.trim()) return;
const res = await fetch('/auth/api-keys/', {
@@ -1339,309 +1174,15 @@
</div>
</div>
<!-- Immich Integration -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={ImmichLogo} alt="Immich" class="w-8 h-8" />
<div>
<h3 class="text-xl font-bold">Immich</h3>
<p class="text-sm text-base-content/70">
{$t('immich.immich_integration_desc')}
</p>
</div>
{#if immichIntegration}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if immichIntegration && !newImmichIntegration.id}
<div class="flex gap-4 justify-center mb-4">
<button
class="btn btn-warning"
on:click={() => {
if (immichIntegration) newImmichIntegration = immichIntegration;
}}
>
✏️ {$t('lodging.edit')}
</button>
<button class="btn btn-error" on:click={disableImmichIntegration}>
{$t('immich.disable')}
</button>
</div>
{/if}
{#if !immichIntegration || newImmichIntegration.id}
<div class="space-y-4">
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('immich.server_url')}</span>
</label>
<input
type="url"
bind:value={newImmichIntegration.server_url}
class="input input-bordered input-primary focus:input-primary"
placeholder="https://immich.example.com/api"
/>
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
<div class="label">
<span class="label-text-alt text-warning">{$t('immich.api_note')}</span>
</div>
{/if}
{#if newImmichIntegration.server_url && (newImmichIntegration.server_url.indexOf('localhost') !== -1 || newImmichIntegration.server_url.indexOf('127.0.0.1') !== -1)}
<div class="label">
<span class="label-text-alt text-warning"
>{$t('immich.localhost_note')}</span
>
</div>
{/if}
</div>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('immich.api_key')}</span>
</label>
<input
type="password"
bind:value={newImmichIntegration.api_key}
class="input input-bordered input-primary focus:input-primary"
placeholder={$t('immich.api_key_placeholder')}
/>
</div>
<!-- Toggle for copy_locally -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={newImmichIntegration.copy_locally}
class="toggle toggle-primary"
/>
<div>
<span class="label-text font-medium">
{$t('immich.copy_locally') || 'Copy Locally'}
</span>
<p class="text-sm text-base-content/70">
{$t('immich.copy_locally_desc') ||
'If enabled, files will be copied locally.'}
</p>
</div>
</label>
</div>
<button on:click={enableImmichIntegration} class="btn btn-primary w-full">
{!immichIntegration?.id
? `🔗 ${$t('immich.enable_integration')}`
: `💾 ${$t('immich.update_integration')}`}
</button>
</div>
{/if}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/immich_integration.html"
target="_blank">{$t('navbar.documentation')}</a
>
</p>
</div>
</div>
<!-- Google maps integration - displayt only if its connected -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={GoogleMapsLogo} alt="Google Maps" class="w-8 h-8" />
<div>
<h3 class="text-xl font-bold">Google Maps</h3>
<p class="text-sm text-base-content/70">
{$t('google_maps.google_maps_integration_desc')}
</p>
</div>
{#if googleMapsEnabled}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if user.is_staff || !googleMapsEnabled}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/google_maps_integration.html"
target="_blank">{$t('navbar.documentation')}</a
>
</p>
{:else if !googleMapsEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
{/if}
</div>
{/if}
</div>
<!-- Strava Integration Section -->
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={StravaLogo} alt="Strava" class="w-8 h-8 rounded-md" />
<div>
<h3 class="text-xl font-bold">Strava</h3>
<p class="text-sm text-base-content/70">
{$t('strava.strava_integration_desc')}
</p>
</div>
{#if stravaGlobalEnabled && stravaUserEnabled}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
{/if}
</div>
<!-- Content based on integration status -->
{#if !stravaGlobalEnabled}
<!-- Strava not enabled globally -->
<div class="text-center">
<p class="text-base-content/70 mb-4">
{$t('strava.not_enabled') ||
'Strava integration is not enabled on this instance.'}
</p>
</div>
{:else if !stravaUserEnabled && stravaGlobalEnabled}
<!-- Globally enabled but user not connected -->
<div class="text-center">
<button class="btn btn-primary" on:click={stravaAuthorizeRedirect}>
🔗 {$t('strava.connect_account')}
</button>
</div>
{:else if stravaGlobalEnabled && stravaUserEnabled}
<!-- User connected - show management options -->
<div class="text-center">
<button class="btn btn-error" on:click={stravaDisconnect}>
❌ {$t('strava.disconnect')}
</button>
</div>
{/if}
<!-- Help documentation link -->
{#if user.is_staff || !stravaGlobalEnabled}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
{#if user.is_staff}
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/strava_integration.html"
target="_blank">{$t('navbar.documentation')}</a
>
</p>
{:else if !stravaGlobalEnabled}
<p class="text-sm">
{$t('google_maps.google_maps_integration_desc_no_staff')}
</p>
{/if}
</div>
{/if}
</div>
<div class="p-6 bg-base-200 rounded-xl mb-4">
<div class="flex items-center gap-4 mb-4">
<img src={WandererLogoSrc} alt="Wanderer" class="w-8 h-8" />
<div>
<h3 class="text-xl font-bold">Wanderer</h3>
<p class="text-sm text-base-content/70">
{$t('wanderer.wanderer_integration_desc')}
</p>
</div>
{#if wandererEnabled}
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
{:else}
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
{/if}
</div>
{#if wandererIntegration && !newWandererIntegration.id}
<div class="flex gap-4 justify-center mb-4">
<button
class="btn btn-warning"
on:click={() => {
if (wandererIntegration) {
newWandererIntegration = {
...wandererIntegration,
api_key: ''
};
}
}}
>
✏️ {$t('lodging.edit')}
</button>
<button class="btn btn-error" on:click={wandererDisconnect}>
❌ {$t('strava.disconnect')}
</button>
</div>
{/if}
{#if !wandererIntegration || newWandererIntegration.id}
<div class="space-y-4">
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('wanderer.server_url')}</span>
</label>
<input
type="url"
class="input input-bordered input-primary focus:input-primary"
placeholder="https://wanderer.example.com"
bind:value={newWandererIntegration.server_url}
/>
{#if newWandererIntegration.server_url && (newWandererIntegration.server_url.indexOf('localhost') !== -1 || newWandererIntegration.server_url.indexOf('127.0.0.1') !== -1)}
<div class="label">
<span class="label-text-alt text-warning"
>{$t('wanderer.localhost_note')}</span
>
</div>
{/if}
</div>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">{$t('wanderer.api_key')}</span>
</label>
<input
type="password"
class="input input-bordered input-primary focus:input-primary"
placeholder={$t('wanderer.api_key_placeholder')}
bind:value={newWandererIntegration.api_key}
/>
</div>
<button class="btn btn-primary w-full" on:click={enableWandererIntegration}>
{!wandererIntegration?.id
? `🔗 ${$t('adventures.connect_to_wanderer')}`
: `💾 ${$t('wanderer.update_integration')}`}
</button>
</div>
{/if}
<div class="mt-4 p-4 bg-info/10 rounded-lg">
<p class="text-sm">
📖 {$t('immich.need_help')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/wanderer_integration.html"
target="_blank">{$t('navbar.documentation')}</a
>
</p>
</div>
</div>
<IntegrationsSettings
{user}
bind:immichIntegration
bind:googleMapsEnabled
bind:stravaGlobalEnabled
bind:stravaUserEnabled
bind:wandererEnabled
bind:wandererIntegration
/>
</div>
{/if}