fix: stabilize post-MVP travel-agent and itinerary workflows

This commit is contained in:
2026-03-08 16:51:19 +00:00
parent fb2347345f
commit 2fd11dbd26
27 changed files with 2533 additions and 794 deletions

View File

@@ -6,6 +6,7 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import Calendar from '~icons/mdi/calendar';
import HelpCircle from '~icons/mdi/help-circle';
import AboutModal from './AboutModal.svelte';
import AccountMultiple from '~icons/mdi/account-multiple';
import MapMarker from '~icons/mdi/map-marker';
@@ -109,11 +110,24 @@
}
};
type NavigationItem = {
path: string;
icon: any;
label: string;
external?: boolean;
};
// Navigation items for better organization
const navigationItems = [
const navigationItems: NavigationItem[] = [
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
{
path: 'https://voyage.app/docs/usage/usage.html',
icon: HelpCircle,
label: 'navbar.documentation',
external: true
},
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
{ path: '/calendar', icon: Calendar, label: 'navbar.calendar' },
@@ -149,8 +163,10 @@
<li>
<a
href={item.path}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl"
class:btn-active={$page.url.pathname === item.path}
class:btn-active={!item.external && $page.url.pathname === item.path}
>
<svelte:component this={item.icon} class="w-5 h-5" />
{$t(item.label)}
@@ -218,9 +234,11 @@
<li>
<a
href={item.path}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="btn btn-ghost gap-2 rounded-xl transition-all duration-200 hover:bg-base-200"
class:bg-primary-10={$page.url.pathname === item.path}
class:text-primary={$page.url.pathname === item.path}
class:bg-primary-10={!item.external && $page.url.pathname === item.path}
class:text-primary={!item.external && $page.url.pathname === item.path}
>
<svelte:component this={item.icon} class="w-4 h-4" />
<span class="hidden xl:inline">{$t(item.label)}</span>

View File

@@ -1003,40 +1003,75 @@
return `${rounded}°C`;
}
function optimizeDayOrder(dayIndex: number) {
if (!canModify || isSavingOrder) return;
type HardAnchorTiming = {
primaryTimestamp: number;
secondaryTimestamp: number;
};
const day = days[dayIndex];
if (!day) return;
function parseAnchorDateTime(value: string | null | undefined): number | null {
if (!value) return null;
const sortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false;
return !!getCoordinatesFromItineraryItem(item);
});
const parsed = DateTime.fromISO(value);
if (!parsed.isValid) return null;
const nonSortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true;
return !getCoordinatesFromItineraryItem(item);
});
const millis = parsed.toMillis();
return Number.isFinite(millis) ? millis : null;
}
if (sortableItems.length < 2) {
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
return;
function getHardAnchorTiming(item: ResolvedItineraryItem): HardAnchorTiming | null {
const itemType = item.item?.type || '';
if (itemType === 'transportation') {
const transportation = item.resolvedObject as Transportation | null;
const startTimestamp = parseAnchorDateTime(transportation?.date);
if (startTimestamp === null) return null;
const endTimestamp = parseAnchorDateTime(transportation?.end_date);
return {
primaryTimestamp: startTimestamp,
secondaryTimestamp: endTimestamp ?? startTimestamp
};
}
const remaining = [...sortableItems];
if (itemType === 'lodging') {
const lodging = item.resolvedObject as Lodging | null;
const checkInTimestamp = parseAnchorDateTime(lodging?.check_in);
const checkOutTimestamp = parseAnchorDateTime(lodging?.check_out);
if (checkInTimestamp === null && checkOutTimestamp === null) return null;
const primaryTimestamp = checkInTimestamp ?? checkOutTimestamp;
if (primaryTimestamp === null) return null;
return {
primaryTimestamp,
secondaryTimestamp: checkOutTimestamp ?? checkInTimestamp ?? primaryTimestamp
};
}
return null;
}
function optimizeNearestNeighborSegment(
items: ResolvedItineraryItem[]
): ResolvedItineraryItem[] {
if (items.length < 2) return [...items];
const remaining = [...items];
const sorted: ResolvedItineraryItem[] = [];
const firstItem = remaining.shift();
if (!firstItem) return;
if (!firstItem) return items;
sorted.push(firstItem);
while (remaining.length > 0) {
const last = sorted[sorted.length - 1];
const lastCoords = getCoordinatesFromItineraryItem(last);
if (!lastCoords) break;
if (!lastCoords) {
sorted.push(...remaining);
break;
}
let nearestIndex = 0;
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < remaining.length; index += 1) {
@@ -1052,10 +1087,111 @@
}
}
if (nearestIndex < 0) {
sorted.push(...remaining);
break;
}
sorted.push(remaining.splice(nearestIndex, 1)[0]);
}
days[dayIndex].items = [...sorted, ...nonSortableItems];
return sorted;
}
function optimizeDayOrder(dayIndex: number) {
if (!canModify || isSavingOrder) return;
const day = days[dayIndex];
if (!day) return;
const nonShadowItems = day.items.filter((item) => !item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
const shadowItems = day.items.filter((item) => item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
const anchorEntries = nonShadowItems
.map((item, originalIndex) => {
const timing = getHardAnchorTiming(item);
if (!timing) return null;
return {
item,
originalIndex,
...timing
};
})
.filter(
(entry): entry is {
item: ResolvedItineraryItem;
originalIndex: number;
primaryTimestamp: number;
secondaryTimestamp: number;
} => !!entry
);
const anchorIndexSet = new Set(anchorEntries.map((entry) => entry.originalIndex));
const movableCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
if (anchorIndexSet.has(originalIndex)) return false;
return !!getCoordinatesFromItineraryItem(item);
});
if (movableCoordinateItems.length < 2) {
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
return;
}
const anchorsByPosition = [...anchorEntries].sort(
(a, b) => a.originalIndex - b.originalIndex
);
const chronologicalAnchors = [...anchorEntries]
.sort((a, b) => {
if (a.primaryTimestamp !== b.primaryTimestamp) {
return a.primaryTimestamp - b.primaryTimestamp;
}
if (a.secondaryTimestamp !== b.secondaryTimestamp) {
return a.secondaryTimestamp - b.secondaryTimestamp;
}
return a.originalIndex - b.originalIndex;
})
.map((entry) => entry.item);
const movableSegments: ResolvedItineraryItem[][] = Array.from(
{ length: anchorsByPosition.length + 1 },
() => []
);
let activeSegmentIndex = 0;
let nextAnchorPositionIndex = 0;
nonShadowItems.forEach((item, originalIndex) => {
const nextAnchor = anchorsByPosition[nextAnchorPositionIndex];
if (nextAnchor && nextAnchor.originalIndex === originalIndex) {
nextAnchorPositionIndex += 1;
activeSegmentIndex += 1;
return;
}
if (anchorIndexSet.has(originalIndex)) return;
if (!getCoordinatesFromItineraryItem(item)) return;
movableSegments[activeSegmentIndex].push(item);
});
const optimizedPath: ResolvedItineraryItem[] = [];
for (let segmentIndex = 0; segmentIndex < movableSegments.length; segmentIndex += 1) {
const optimizedSegment = optimizeNearestNeighborSegment(movableSegments[segmentIndex]);
optimizedPath.push(...optimizedSegment);
if (segmentIndex < chronologicalAnchors.length) {
optimizedPath.push(chronologicalAnchors[segmentIndex]);
}
}
const nonCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
if (anchorIndexSet.has(originalIndex)) return false;
return !getCoordinatesFromItineraryItem(item);
});
days[dayIndex].items = [...optimizedPath, ...nonCoordinateItems, ...shadowItems];
days = [...days];
isSavingOrder = true;

View File

@@ -734,7 +734,27 @@
"activities": "Aktivitäten",
"trails": "Wanderwege",
"use_imperial": "Verwenden Sie imperiale Einheiten",
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten"
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten",
"ai_api_keys": "KI-API-Schlüssel",
"ai_api_keys_desc": "Verwalten Sie nur schreibbare API-Schlüssel für Reiseagenten-Empfehlungen.",
"travel_agent_help_title": "So verwenden Sie den Reiseagenten",
"travel_agent_help_body": "Öffnen Sie eine Sammlung und wechseln Sie zu Empfehlungen, um mit dem Reiseagenten nach Vorschlägen zu suchen.",
"travel_agent_help_open_collections": "Sammlungen öffnen",
"travel_agent_help_setup_guide": "Einrichtungsanleitung für Reiseagenten",
"saved_api_keys": "Gespeicherte API-Schlüssel",
"no_api_keys_saved": "Noch keine API-Schlüssel gespeichert.",
"add_api_key": "API-Schlüssel hinzufügen",
"provider": "Anbieter",
"api_key_value": "API-Schlüssel",
"api_key_value_placeholder": "Geben Sie Ihren API-Schlüssel ein",
"api_key_write_only_hint": "Aus Sicherheitsgründen ist Ihr Klartextschlüssel nur zum Schreiben verfügbar und wird nach dem Speichern nicht mehr angezeigt.",
"save_api_key": "API-Schlüssel speichern",
"api_keys_saved": "API-Schlüssel gespeichert.",
"api_keys_deleted": "API-Schlüssel gelöscht.",
"api_keys_generic_error": "API-Schlüssel können derzeit nicht aktualisiert werden.",
"api_keys_value_required": "Bitte geben Sie einen API-Schlüssel ein.",
"api_keys_config_unavailable": "API-Schlüsselspeicher ist nicht verfügbar",
"api_keys_config_guidance": "Bitten Sie Ihren Serveradministrator, FIELD_ENCRYPTION_KEY zu konfigurieren, und versuchen Sie es erneut."
},
"checklist": {
"checklist_delete_error": "Fehler beim Löschen der Checkliste",

View File

@@ -735,7 +735,27 @@
"use_imperial": "Use Imperial Units",
"use_imperial_desc": "Use imperial units (feet, inches, pounds) instead of metric units",
"trails": "Trails",
"activities": "Activities"
"activities": "Activities",
"ai_api_keys": "AI API Keys",
"ai_api_keys_desc": "Manage write-only API keys for travel-agent recommendations.",
"saved_api_keys": "Saved API Keys",
"no_api_keys_saved": "No API keys saved yet.",
"add_api_key": "Add API Key",
"provider": "Provider",
"api_key_value": "API Key",
"api_key_value_placeholder": "Enter your API key",
"api_key_write_only_hint": "For security, your plaintext key is write-only and is never shown after saving.",
"save_api_key": "Save API Key",
"api_keys_saved": "API key saved.",
"api_keys_deleted": "API key deleted.",
"api_keys_generic_error": "Unable to update API keys right now.",
"api_keys_value_required": "Please enter an API key.",
"api_keys_config_unavailable": "API key storage is unavailable",
"api_keys_config_guidance": "Ask your server administrator to configure FIELD_ENCRYPTION_KEY and try again.",
"travel_agent_help_title": "How to use the travel agent",
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
"travel_agent_help_open_collections": "Open Collections",
"travel_agent_help_setup_guide": "Travel agent setup guide"
},
"collection": {
"collection_created": "Collection created successfully!",

View File

@@ -734,7 +734,10 @@
"use_imperial": "İngiliz Ölçü Birimlerini Kullan",
"use_imperial_desc": "Metrik birimler yerine İngiliz birimlerini (fit, inç, pound) kullanın",
"trails": "Patikalar",
"activities": "Aktiviteler"
"activities": "Aktiviteler",
"ai_api_keys": "Yapay zeka API anahtarları",
"saved_api_keys": "Kaydedilen API anahtarları",
"add_api_key": "API anahtarı ekle"
},
"collection": {
"collection_created": "Koleksiyon başarıyla oluşturuldu!",

View File

@@ -51,9 +51,7 @@
if (browser) {
init({
fallbackLocale: locales.includes(navigator.language.split('-')[0])
? navigator.language.split('-')[0]
: 'en',
fallbackLocale: 'en',
initialLocale: data.locale
});
// get the locale cookie if it exists and set it as the initial locale if it exists

View File

@@ -16,6 +16,14 @@ type MFAAuthenticatorResponse = {
}[];
};
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
return redirect(302, '/');
@@ -85,6 +93,21 @@ export const load: PageServerLoad = async (event) => {
let wandererEnabled = integrations.wanderer.exists as boolean;
let wandererExpired = integrations.wanderer.expired as boolean;
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (apiKeysFetch.ok) {
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
} else if (apiKeysFetch.status === 503) {
const errorBody = (await apiKeysFetch.json()) as { detail?: string };
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
}
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
if (!publicUrlFetch.ok) {
@@ -101,10 +124,13 @@ export const load: PageServerLoad = async (event) => {
authenticators,
immichIntegration,
publicUrl,
mcpTokenHeaderFormat: 'Authorization: Token <token>',
socialProviders,
googleMapsEnabled,
stravaGlobalEnabled,
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
wandererEnabled,
wandererExpired
}

View File

@@ -16,7 +16,6 @@
import WandererLogoSrc from '$lib/assets/wanderer.svg';
export let data: PageData;
console.log(data);
let user: User;
let emails: typeof data.props.emails;
if (data.user) {
@@ -37,6 +36,21 @@
let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled;
let wandererExpired = data.props.wandererExpired;
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'google_maps';
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
let mcpToken: string | null = null;
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
// typed alias for social providers to satisfy TypeScript
@@ -82,6 +96,7 @@
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
@@ -401,6 +416,162 @@
newWandererIntegration.password = '';
}
}
function getApiKeysErrorMessage(errorBody: any): string {
if (errorBody?.detail) {
return errorBody.detail;
}
if (errorBody?.api_key?.[0]) {
return errorBody.api_key[0];
}
if (errorBody?.provider?.[0]) {
return errorBody.provider[0];
}
return $t('settings.api_keys_generic_error');
}
async function addUserApiKey(event: SubmitEvent) {
event.preventDefault();
if (!newApiKeyValue.trim()) {
addToast('error', $t('settings.api_keys_value_required'));
return;
}
isSavingApiKey = true;
try {
const res = await fetch('/api/integrations/api-keys/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
provider: newApiKeyProvider,
api_key: newApiKeyValue
})
});
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.ok && payload) {
const existingIndex = userApiKeys.findIndex((key) => key.provider === payload.provider);
if (existingIndex >= 0) {
const updated = [...userApiKeys];
updated[existingIndex] = payload;
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
} else {
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
}
newApiKeyValue = '';
apiKeysConfigError = null;
addToast('success', $t('settings.api_keys_saved'));
return;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
isSavingApiKey = false;
}
}
async function deleteUserApiKey(apiKey: UserAPIKey) {
deletingApiKeyId = apiKey.id;
try {
const res = await fetch(`/api/integrations/api-keys/${apiKey.id}/`, {
method: 'DELETE'
});
if (res.ok || res.status === 204) {
userApiKeys = userApiKeys.filter((key) => key.id !== apiKey.id);
addToast('success', $t('settings.api_keys_deleted'));
return;
}
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
deletingApiKeyId = null;
}
}
function getMaskedMcpToken(token: string): string {
if (token.length <= 8) {
return '••••••••';
}
return `${token.slice(0, 4)}••••••••${token.slice(-4)}`;
}
async function fetchOrCreateMcpToken() {
isLoadingMcpToken = true;
try {
const res = await fetch('/auth/mcp-token/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) {
addToast('error', $t('settings.generic_error'));
return;
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
addToast('error', $t('settings.generic_error'));
return;
}
mcpToken = payload.token;
addToast('success', 'MCP token ready.');
} catch {
addToast('error', $t('settings.generic_error'));
} finally {
isLoadingMcpToken = false;
}
}
async function copyMcpAuthHeader() {
if (!mcpToken) {
addToast('error', 'Generate token first.');
return;
}
const authHeader = `Authorization: Token ${mcpToken}`;
try {
await navigator.clipboard.writeText(authHeader);
addToast('success', $t('adventures.copied_to_clipboard'));
} catch {
addToast('error', $t('adventures.copy_failed'));
}
}
</script>
{#if isMFAModalOpen}
@@ -1292,6 +1463,189 @@
</div>
{/if}
<!-- AI API Keys Section -->
{#if activeSection === 'ai_api_keys'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-primary/10 rounded-xl">
<span class="text-2xl">🤖</span>
</div>
<div>
<h2 class="text-2xl font-bold">{$t('settings.ai_api_keys')}</h2>
<p class="text-base-content/70">
{$t('settings.ai_api_keys_desc')}
</p>
</div>
</div>
{#if apiKeysConfigError}
<div class="alert alert-warning mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.api_keys_config_unavailable')}</p>
<p class="text-sm">{apiKeysConfigError}</p>
<p class="text-sm mt-1">{$t('settings.api_keys_config_guidance')}</p>
</div>
</div>
{/if}
<div class="alert alert-info mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.travel_agent_help_title')}</p>
<p class="text-sm">{$t('settings.travel_agent_help_body')}</p>
<p class="text-sm mt-1 flex flex-wrap gap-3">
<a class="link link-primary" href="/collections"
>{$t('settings.travel_agent_help_open_collections')}</a
>
<a
class="link link-primary"
href="https://voyage.app/docs/usage/usage.html"
target="_blank"
rel="noopener noreferrer"
>{$t('settings.travel_agent_help_setup_guide')}</a
>
</p>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
Create or fetch your personal token for MCP clients. The same token is reused if one
already exists.
</p>
<div class="flex flex-wrap gap-3 mb-4">
<button
class="btn btn-primary"
on:click={fetchOrCreateMcpToken}
disabled={isLoadingMcpToken}
>
{#if isLoadingMcpToken}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{mcpToken ? 'Refresh token' : 'Get MCP token'}
</button>
<button
class="btn btn-outline"
on:click={copyMcpAuthHeader}
disabled={!mcpToken}
>
{$t('settings.copy')}
</button>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-base-content/60">Token</div>
<div class="font-mono text-sm p-3 rounded-lg bg-base-100 border border-base-300">
{mcpToken ? getMaskedMcpToken(mcpToken) : 'Not generated yet'}
</div>
</div>
<div class="mt-4 p-4 bg-base-100 rounded-lg border border-base-300">
<div class="text-sm font-medium mb-1">Use this exact auth header format</div>
<div class="font-mono text-sm">{data.props.mcpTokenHeaderFormat}</div>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-4">{$t('settings.saved_api_keys')}</h3>
{#if userApiKeys.length === 0}
<p class="text-base-content/70">{$t('settings.no_api_keys_saved')}</p>
{:else}
<div class="space-y-3">
{#each userApiKeys as apiKey}
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
<div>
<div class="font-medium">{apiKey.provider}</div>
<div class="text-sm text-base-content/70 font-mono">
{apiKey.masked_api_key}
</div>
</div>
<button
class="btn btn-sm btn-error"
on:click={() => deleteUserApiKey(apiKey)}
disabled={deletingApiKeyId === apiKey.id}
>
{#if deletingApiKeyId === apiKey.id}
<span class="loading loading-spinner loading-xs"></span>
{/if}
{$t('adventures.remove')}
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="p-6 bg-base-200 rounded-xl">
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
>
<option value="google_maps">Google Maps</option>
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
</label>
<input
id="api-key-value"
type="password"
class="input input-bordered input-primary focus:input-primary"
bind:value={newApiKeyValue}
placeholder={$t('settings.api_key_value_placeholder')}
required
autocomplete="off"
/>
<p class="text-sm text-base-content/70 mt-1">
{$t('settings.api_key_write_only_hint')}
</p>
</div>
<button class="btn btn-primary" type="submit" disabled={isSavingApiKey}>
{#if isSavingApiKey}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{$t('settings.save_api_key')}
</button>
</form>
</div>
</div>
{/if}
<!-- import export -->
{#if activeSection === 'import_export'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">