1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-10 15:45:20 +02:00

feat: implement Wanderer integration with trail management and UI components; enhance settings for reauthentication

This commit is contained in:
Sean Morley 2025-08-05 11:42:56 -04:00
parent 9fd06911b2
commit 2af78e0a53
8 changed files with 597 additions and 81 deletions

View file

@ -2,6 +2,7 @@ import os
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from integrations.models import ImmichIntegration, StravaToken, WandererIntegration from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
from django.conf import settings from django.conf import settings
@ -17,6 +18,12 @@ class IntegrationView(viewsets.ViewSet):
strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != '' strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
strava_integration_user = StravaToken.objects.filter(user=request.user).exists() strava_integration_user = StravaToken.objects.filter(user=request.user).exists()
wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists() wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists()
is_wanderer_expired = False
if wanderer_integration:
token_expiry = WandererIntegration.objects.filter(user=request.user).first().token_expiry
if token_expiry and token_expiry < timezone.now():
is_wanderer_expired = True
return Response( return Response(
{ {
@ -26,7 +33,10 @@ class IntegrationView(viewsets.ViewSet):
'global': strava_integration_global, 'global': strava_integration_global,
'user': strava_integration_user 'user': strava_integration_user
}, },
'wanderer': wanderer_integration 'wanderer': {
'exists': wanderer_integration,
'expired': is_wanderer_expired
}
}, },
status=status.HTTP_200_OK status=status.HTTP_200_OK
) )

View file

@ -113,8 +113,8 @@ class WandererIntegrationViewSet(viewsets.ViewSet):
"is_connected": True, "is_connected": True,
}) })
@action(detail=False, methods=["get"], url_path='list') @action(detail=False, methods=["get"], url_path='trails')
def list_trails(self, request): def trails(self, request):
inst = self._get_obj() inst = self._get_obj()
# Check if we need to prompt for password # Check if we need to prompt for password
@ -131,9 +131,12 @@ class WandererIntegrationViewSet(viewsets.ViewSet):
}) })
raise ValidationError({"detail": str(e)}) raise ValidationError({"detail": str(e)})
url = f"{inst.server_url.rstrip('/')}/api/v1/list" # Pass along all query parameters except password
params = {k: v for k, v in request.query_params.items() if k != "password"}
url = f"{inst.server_url.rstrip('/')}/api/v1/trail"
try: try:
response = session.get(url, timeout=10) response = session.get(url, params=params, timeout=10)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as exc: except requests.RequestException as exc:
raise ValidationError({"detail": f"Error fetching trails: {exc}"}) raise ValidationError({"detail": f"Error fetching trails: {exc}"})

View file

@ -0,0 +1,108 @@
<script lang="ts"></script>
<div class="relative group">
{#if trailToEdit?.id === trail.id}
<!-- Edit Mode -->
<div class="bg-warning/10 p-4 rounded-lg border border-warning/30">
<div class="flex items-center gap-2 mb-3">
<EditIcon class="w-4 h-4 text-warning" />
<span class="text-sm font-medium text-warning">Editing Trail</span>
</div>
<div class="grid gap-3">
<input
type="text"
bind:value={editingTrailName}
class="input input-bordered input-sm"
placeholder="Trail name"
/>
<input
type="url"
bind:value={editingTrailLink}
class="input input-bordered input-sm"
placeholder="External link"
disabled={editingTrailWandererId.trim() !== ''}
/>
<div class="text-center text-xs text-base-content/60">OR</div>
<input
type="text"
bind:value={editingTrailWandererId}
class="input input-bordered input-sm"
placeholder="Wanderer Trail ID"
disabled={editingTrailLink.trim() !== ''}
/>
</div>
<div class="flex gap-2 mt-3">
<button
class="btn btn-success btn-xs flex-1"
disabled={!validateEditTrailForm()}
on:click={saveTrailEdit}
>
<CheckIcon class="w-3 h-3" />
Save
</button>
<button class="btn btn-ghost btn-xs flex-1" on:click={cancelEditingTrail}>
<CloseIcon class="w-3 h-3" />
Cancel
</button>
</div>
</div>
{:else}
<!-- Normal Display -->
<div
class="bg-base-50 p-4 rounded-lg border border-base-200 hover:border-base-300 transition-colors"
>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 bg-accent/10 rounded">
{#if trail.wanderer_id}
<Star class="w-4 h-4 text-accent" />
{:else}
<LinkIcon class="w-4 h-4 text-accent" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{trail.name}</div>
<div class="text-xs text-accent/70 mt-1">
{trail.provider || 'External'}
</div>
</div>
</div>
{#if trail.link}
<a
href={trail.link}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-accent hover:text-accent-focus mb-3 break-all block underline"
>
{trail.link}
</a>
{:else if trail.wanderer_id}
<div class="text-xs text-base-content/60 mb-3 break-all">
Wanderer ID: {trail.wanderer_id}
</div>
{:else}
<div class="text-xs text-base-content/40 mb-3 italic">No external link available</div>
{/if}
<!-- Trail Controls -->
<div class="flex gap-2 justify-end">
<button
type="button"
class="btn btn-warning btn-xs btn-square tooltip tooltip-top"
data-tip="Edit Trail"
on:click={() => startEditingTrail(trail)}
>
<EditIcon class="w-3 h-3" />
</button>
<button
type="button"
class="btn btn-error btn-xs btn-square tooltip tooltip-top"
data-tip="Remove Trail"
on:click={() => removeTrail(trail.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,165 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import MountainIcon from '~icons/mdi/mountain';
import MapPinIcon from '~icons/mdi/map-marker';
import CalendarIcon from '~icons/mdi/calendar';
import CameraIcon from '~icons/mdi/camera';
import FileIcon from '~icons/mdi/file';
import LinkIcon from '~icons/mdi/link-variant';
import type { WandererTrail } from '$lib/types';
export let trail: WandererTrail;
// Helper functions
/**
* @param {number} distanceInMeters
*/
function formatDistance(distanceInMeters: number) {
const miles = (distanceInMeters * 0.000621371).toFixed(1);
return `${miles} mi`;
}
/**
* @param {number} elevationInMeters
*/
function formatElevation(elevationInMeters: number) {
const feet = Math.round(elevationInMeters * 3.28084);
return `${feet}ft`;
}
/**
* @param {string} difficulty
*/
function getDifficultyBadgeClass(difficulty: string) {
switch (difficulty?.toLowerCase()) {
case 'easy':
return 'badge-success';
case 'moderate':
return 'badge-warning';
case 'difficult':
return 'badge-error';
default:
return 'badge-outline';
}
}
/**
* @param {string | number | Date} dateString
*/
function formatDate(dateString: string | number | Date) {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString();
}
/**
* @param {string} html
*/
function stripHtml(html: string) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
</script>
<div class="bg-base-200/50 p-4 rounded-lg shadow-sm">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<!-- Header with trail name and difficulty -->
<div class="flex items-center gap-2 mb-2">
<MountainIcon class="w-4 h-4 text-primary flex-shrink-0" />
<h5 class="font-semibold text-base truncate">{trail.name}</h5>
<span class="badge {getDifficultyBadgeClass(trail.difficulty)} badge-sm">
{trail.difficulty}
</span>
</div>
<!-- Location -->
{#if trail.location}
<div class="flex items-center gap-1 mb-2">
<MapPinIcon class="w-3 h-3 text-base-content/60" />
<span class="text-sm text-base-content/70">{trail.location}</span>
</div>
{/if}
<!-- Trail stats -->
<div class="text-xs text-base-content/70 space-y-1">
{#if trail.distance}
<div class="flex items-center gap-4">
<span>Distance: {formatDistance(trail.distance)}</span>
{#if trail.duration > 0}
<span>Duration: {Math.round(trail.duration / 60)} min</span>
{/if}
</div>
{/if}
{#if trail.elevation_gain > 0 || trail.elevation_loss > 0}
<div class="flex items-center gap-4">
{#if trail.elevation_gain > 0}
<span class="text-success">{formatElevation(trail.elevation_gain)}</span>
{/if}
{#if trail.elevation_loss > 0}
<span class="text-error">{formatElevation(trail.elevation_loss)}</span>
{/if}
</div>
{/if}
{#if trail.waypoints && trail.waypoints.length > 0}
<div>
Waypoints: {trail.waypoints.length}
</div>
{/if}
{#if trail.created}
<div class="flex items-center gap-1">
<CalendarIcon class="w-3 h-3" />
<span>Created: {formatDate(trail.created)}</span>
</div>
{/if}
{#if trail.photos && trail.photos.length > 0}
<div class="flex items-center gap-1">
<CameraIcon class="w-3 h-3" />
<span>{trail.photos.length} photo{trail.photos.length !== 1 ? 's' : ''}</span>
</div>
{/if}
{#if trail.gpx}
<div class="flex items-center gap-1">
<FileIcon class="w-3 h-3" />
<a href={trail.gpx} target="_blank" class="link link-primary text-xs"> View GPX </a>
</div>
{/if}
</div>
<!-- Description preview -->
{#if trail.description}
<div class="mt-3 pt-2 border-t border-base-300">
<p class="text-xs text-base-content/60 line-clamp-2">
{stripHtml(trail.description).substring(0, 150)}...
</p>
</div>
{/if}
</div>
<!-- button to link trail to activity -->
<div class="flex-shrink-0 ml-4">
<button
class="btn btn-primary btn-xs tooltip tooltip-top"
on:click={() => dispatch('link', trail)}
aria-label="Link Trail to Activity"
>
<LinkIcon class="w-3 h-3" />
</button>
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Attachment, ContentImage, Trail } from '$lib/types'; import type { Attachment, ContentImage, Trail, WandererTrail } from '$lib/types';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms'; import { deserialize } from '$app/forms';
@ -23,6 +23,7 @@
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import ImmichSelect from '../ImmichSelect.svelte'; import ImmichSelect from '../ImmichSelect.svelte';
import WandererCard from '../WandererCard.svelte';
// Props // Props
export let images: ContentImage[] = []; export let images: ContentImage[] = [];
@ -60,6 +61,12 @@
let editingTrailLink: string = ''; let editingTrailLink: string = '';
let editingTrailWandererId: string = ''; let editingTrailWandererId: string = '';
let showAddTrailForm: boolean = false; let showAddTrailForm: boolean = false;
let showWandererForm: boolean = false;
let isWandererEnabled: boolean = false;
let searchQuery: string = '';
let isSearching: boolean = false;
let wandererFetchedTrails: WandererTrail[] = [];
// Allowed file types for attachments // Allowed file types for attachments
const allowedFileTypes = [ const allowedFileTypes = [
@ -395,35 +402,18 @@
} }
} }
// Trail event handlers
function validateTrailForm(): boolean {
if (!trailName.trim()) {
trailError = 'Trail name is required';
return false;
}
const hasLink = trailLink.trim() !== '';
const hasWandererId = trailWandererId.trim() !== '';
if (hasLink && hasWandererId) {
trailError = 'Cannot have both a link and a Wanderer ID. Provide only one.';
return false;
}
if (!hasLink && !hasWandererId) {
trailError = 'You must provide either a link or a Wanderer ID.';
return false;
}
trailError = '';
return true;
}
async function createTrail() { async function createTrail() {
if (!validateTrailForm()) return;
isTrailLoading = true; isTrailLoading = true;
// if wanderer ID is provided, use it and remove link
if (trailWandererId.trim()) {
trailLink = '';
} else if (!trailLink.trim()) {
trailError = $t('adventures.trail_link_required');
isTrailLoading = false;
return;
}
const trailData = { const trailData = {
name: trailName.trim(), name: trailName.trim(),
location: itemId, location: itemId,
@ -461,7 +451,6 @@
function resetTrailForm() { function resetTrailForm() {
trailName = ''; trailName = '';
trailLink = ''; trailLink = '';
trailWandererId = '';
trailError = ''; trailError = '';
showAddTrailForm = false; showAddTrailForm = false;
} }
@ -473,11 +462,79 @@
editingTrailWandererId = trail.wanderer_id || ''; editingTrailWandererId = trail.wanderer_id || '';
} }
async function fetchWandererTrails(filter = '') {
isSearching = true;
try {
const url = new URL('/api/integrations/wanderer/trails', window.location.origin);
if (filter) {
url.searchParams.append('filter', filter);
}
let res = await fetch(url, {
method: 'GET'
});
if (res.ok) {
let itemsResponse = await res.json();
wandererFetchedTrails = itemsResponse.items || [];
} else {
const errorData = await res.json();
addToast('error', errorData.message || 'Failed to fetch Wanderer trails');
}
} catch (error) {
addToast('error', 'Network error while fetching trails');
} finally {
isSearching = false;
}
}
// Updated function to show wanderer form
async function doShowWandererForm() {
showWandererForm = true;
showAddTrailForm = false;
await fetchWandererTrails(); // Initial load without filter
}
// Function to handle search
async function handleSearch() {
if (!searchQuery.trim()) {
// If search is empty, fetch all trails
await fetchWandererTrails();
return;
}
// Create filter string for name search (case-insensitive)
const filter = `name~"${searchQuery}"`;
await fetchWandererTrails(filter);
}
// Debounced search function (optional - for real-time search)
let searchTimeout: string | number | NodeJS.Timeout | undefined;
function debouncedSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(handleSearch, 300); // 300ms delay
}
// Function to clear search
async function clearSearch() {
searchQuery = '';
await fetchWandererTrails();
}
async function linkWandererTrail(event: CustomEvent<WandererTrail>) {
const trail = event.detail;
let trailId = trail.id;
trailName = trail.name;
trailLink = '';
trailWandererId = trailId;
trailError = '';
createTrail();
}
function cancelEditingTrail() { function cancelEditingTrail() {
trailToEdit = null; trailToEdit = null;
editingTrailName = ''; editingTrailName = '';
editingTrailLink = ''; editingTrailLink = '';
editingTrailWandererId = '';
} }
function validateEditTrailForm(): boolean { function validateEditTrailForm(): boolean {
@ -567,19 +624,28 @@
// Lifecycle // Lifecycle
onMount(async () => { onMount(async () => {
try { try {
const res = await fetch('/api/integrations/immich/'); const res = await fetch('/api/integrations');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.id) {
// Check Immich integration
if (data.immich) {
immichIntegration = true; immichIntegration = true;
copyImmichLocally = data.copy_locally || false; // For copyImmichLocally, we might need to fetch specific details if needed
// or set a default value since it's not in the new response structure
copyImmichLocally = false;
}
// Check Wanderer integration
if (data.wanderer && data.wanderer.exists && !data.wanderer.expired) {
isWandererEnabled = true;
} }
} else if (res.status !== 404) { } else if (res.status !== 404) {
addToast('error', $t('immich.integration_fetch_error')); addToast('error', $t('immich.integration_fetch_error'));
} }
} catch (error) { } catch (error) {
console.error('Error checking Immich integration:', error); console.error('Error checking integrations:', error);
} }
}); });
</script> </script>
@ -899,18 +965,32 @@
</div> </div>
<h2 class="text-xl font-bold">Trails Management</h2> <h2 class="text-xl font-bold">Trails Management</h2>
</div> </div>
<button <div class="flex items-center gap-2">
class="btn btn-accent btn-sm gap-2" <button
on:click={() => (showAddTrailForm = !showAddTrailForm)} class="btn btn-accent btn-sm gap-2"
> on:click={() => {
<PlusIcon class="w-4 h-4" /> showAddTrailForm = !showAddTrailForm;
Add Trail if (showAddTrailForm) showWandererForm = false;
</button> }}
>
<PlusIcon class="w-4 h-4" />
Add Trail
</button>
<button
class="btn btn-accent btn-sm gap-2"
on:click={() => {
doShowWandererForm();
}}
>
<PlusIcon class="w-4 h-4" />
Add Wanderer Trail
</button>
</div>
</div> </div>
<div class="text-sm text-base-content/60 mb-4"> <div class="text-sm text-base-content/60 mb-4">
Manage trails associated with this location. Trails can be linked to external services Manage trails associated with this location. Trails can be linked to external services
like AllTrails or referenced by Wanderer ID. like AllTrails or link to Wanderer trails.
</div> </div>
<!-- Add Trail Form --> <!-- Add Trail Form -->
@ -930,15 +1010,7 @@
bind:value={trailLink} bind:value={trailLink}
class="input input-bordered" class="input input-bordered"
placeholder="External link (e.g., AllTrails, Trailforks)" placeholder="External link (e.g., AllTrails, Trailforks)"
disabled={isTrailLoading || trailWandererId.trim() !== ''} disabled={isTrailLoading}
/>
<div class="text-center text-sm text-base-content/60">OR</div>
<input
type="text"
bind:value={trailWandererId}
class="input input-bordered"
placeholder="Wanderer Trail ID"
disabled={isTrailLoading || trailLink.trim() !== ''}
/> />
{#if trailError} {#if trailError}
<div class="alert alert-error py-2"> <div class="alert alert-error py-2">
@ -956,9 +1028,7 @@
<button <button
class="btn btn-accent btn-sm" class="btn btn-accent btn-sm"
class:loading={isTrailLoading} class:loading={isTrailLoading}
disabled={isTrailLoading || disabled={isTrailLoading || !trailName.trim() || !trailLink.trim()}
!trailName.trim() ||
(!trailLink.trim() && !trailWandererId.trim())}
on:click={createTrail} on:click={createTrail}
> >
Create Trail Create Trail
@ -968,6 +1038,98 @@
</div> </div>
{/if} {/if}
<!-- Wanderer Trails Form -->
{#if showWandererForm}
<div class="bg-accent/5 p-4 rounded-lg border border-accent/20 mb-6">
<h4 class="font-medium mb-3 text-accent">Add Wanderer Trail</h4>
<div class="grid gap-3">
{#if isWandererEnabled}
<p class="text-sm text-base-content/60 mb-2">
Select a trail from your Wanderer account:
</p>
<!-- Search Box -->
<div class="relative">
<input
type="text"
placeholder="Search trails by name..."
class="input input-bordered w-full pr-20"
bind:value={searchQuery}
on:input={debouncedSearch}
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{#if searchQuery}
<button
class="btn btn-ghost btn-xs btn-circle"
on:click={clearSearch}
disabled={isSearching}
title="Clear search"
>
</button>
{/if}
<button
class="btn btn-accent btn-xs"
class:loading={isSearching}
on:click={handleSearch}
disabled={isSearching}
title="Search"
>
{#if !isSearching}🔍{/if}
</button>
</div>
</div>
<!-- Search Status -->
{#if searchQuery && !isSearching}
<p class="text-xs text-base-content/50">
{wandererFetchedTrails.length} trail(s) found for "{searchQuery}"
</p>
{/if}
<!-- Trail Cards -->
<div class="max-h-96 overflow-y-auto">
{#if isSearching}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if wandererFetchedTrails.length === 0}
<div class="text-center py-8 text-base-content/60">
{#if searchQuery}
No trails found matching "{searchQuery}"
{:else}
No trails available
{/if}
</div>
{:else}
{#each wandererFetchedTrails as trail (trail.id)}
<WandererCard {trail} on:link={linkWandererTrail} />
{/each}
{/if}
</div>
{:else}
<p class="text-sm text-base-content/60">
Wanderer integration is not enabled or has expired.
</p>
{/if}
<div class="flex gap-2 justify-end">
<button
class="btn btn-ghost btn-sm"
on:click={() => {
showWandererForm = false;
showAddTrailForm = false;
searchQuery = ''; // Clear search when closing
}}
>
Close
</button>
</div>
</div>
</div>
{/if}
<!-- Trails Gallery --> <!-- Trails Gallery -->
{#if trails.length > 0} {#if trails.length > 0}
<div class="divider">Current Trails</div> <div class="divider">Current Trails</div>
@ -995,14 +1157,6 @@
placeholder="External link" placeholder="External link"
disabled={editingTrailWandererId.trim() !== ''} disabled={editingTrailWandererId.trim() !== ''}
/> />
<div class="text-center text-xs text-base-content/60">OR</div>
<input
type="text"
bind:value={editingTrailWandererId}
class="input input-bordered input-sm"
placeholder="Wanderer Trail ID"
disabled={editingTrailLink.trim() !== ''}
/>
</div> </div>
<div class="flex gap-2 mt-3"> <div class="flex gap-2 mt-3">
<button <button
@ -1049,11 +1203,7 @@
> >
{trail.link} {trail.link}
</a> </a>
{:else if trail.wanderer_id} {:else if !trail.wanderer_id}
<div class="text-xs text-base-content/60 mb-3 break-all">
Wanderer ID: {trail.wanderer_id}
</div>
{:else}
<div class="text-xs text-base-content/40 mb-3 italic"> <div class="text-xs text-base-content/40 mb-3 italic">
No external link available No external link available
</div> </div>
@ -1061,14 +1211,16 @@
<!-- Trail Controls --> <!-- Trail Controls -->
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<button {#if !trail.wanderer_id}
type="button" <button
class="btn btn-warning btn-xs btn-square tooltip tooltip-top" type="button"
data-tip="Edit Trail" class="btn btn-warning btn-xs btn-square tooltip tooltip-top"
on:click={() => startEditingTrail(trail)} data-tip="Edit Trail"
> on:click={() => startEditingTrail(trail)}
<EditIcon class="w-3 h-3" /> >
</button> <EditIcon class="w-3 h-3" />
</button>
{/if}
<button <button
type="button" type="button"
class="btn btn-error btn-xs btn-square tooltip tooltip-top" class="btn btn-error btn-xs btn-square tooltip tooltip-top"

View file

@ -433,3 +433,35 @@ export type TransportationVisit = {
end_timezone: string; end_timezone: string;
activities?: Activity[]; activities?: Activity[];
}; };
export type WandererTrail = {
id: string;
name: string;
distance: number;
duration: number;
elevation_gain: number;
elevation_loss: number;
author: string;
category: string;
collectionId: string;
collectionName: string;
created: string; // ISO 8601 date string
date: string;
description: string;
difficulty: string;
external_id: string;
external_provider: string;
gpx: string;
iri: string;
lat: number;
like_count: number;
location: string;
lon: number;
photos: string[];
public: boolean;
tags: string[];
thumbnail: number;
updated: string; // ISO 8601 date string
waypoints: string[];
expand: any;
};

View file

@ -82,7 +82,8 @@ export const load: PageServerLoad = async (event) => {
let googleMapsEnabled = integrations.google_maps as boolean; let googleMapsEnabled = integrations.google_maps as boolean;
let stravaGlobalEnabled = integrations.strava.global as boolean; let stravaGlobalEnabled = integrations.strava.global as boolean;
let stravaUserEnabled = integrations.strava.user as boolean; let stravaUserEnabled = integrations.strava.user as boolean;
let wandererEnabled = integrations.wanderer as boolean; let wandererEnabled = integrations.wanderer.exists as boolean;
let wandererExpired = integrations.wanderer.expired as boolean;
let publicUrlFetch = await fetch(`${endpoint}/public-url/`); let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = ''; let publicUrl = '';
@ -104,7 +105,8 @@ export const load: PageServerLoad = async (event) => {
googleMapsEnabled, googleMapsEnabled,
stravaGlobalEnabled, stravaGlobalEnabled,
stravaUserEnabled, stravaUserEnabled,
wandererEnabled wandererEnabled,
wandererExpired
} }
}; };
}; };

View file

@ -29,6 +29,7 @@
let stravaGlobalEnabled = data.props.stravaGlobalEnabled; let stravaGlobalEnabled = data.props.stravaGlobalEnabled;
let stravaUserEnabled = data.props.stravaUserEnabled; let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled; let wandererEnabled = data.props.wandererEnabled;
let wandererExpired = data.props.wandererExpired;
let activeSection: string = 'profile'; let activeSection: string = 'profile';
// Initialize activeSection from URL on mount // Initialize activeSection from URL on mount
@ -361,6 +362,28 @@
addToast('error', $t('wanderer.connection_error')); addToast('error', $t('wanderer.connection_error'));
} }
} }
async function wandererRefresh() {
if (wandererEnabled) {
const res = await fetch(`/api/integrations/wanderer/refresh/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: newWandererIntegration.password
})
});
if (res.ok) {
addToast('success', $t('wanderer.refreshed'));
newWandererIntegration.password = '';
wandererExpired = false;
} else {
addToast('error', $t('wanderer.refresh_error'));
}
newWandererIntegration.password = '';
}
}
</script> </script>
{#if isMFAModalOpen} {#if isMFAModalOpen}
@ -1104,6 +1127,27 @@
{/if} {/if}
</div> </div>
{#if wandererEnabled && wandererExpired}
<div class="space-y-4 mb-4">
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">Password</span>
</label>
<input
type="password"
class="input input-bordered input-primary focus:input-primary"
placeholder="Enter your password"
bind:value={newWandererIntegration.password}
/>
</div>
<button class="btn btn-primary w-full" on:click={wandererRefresh}>
🔗 Wanderer Reauth
</button>
</div>
{/if}
<!-- Content based on integration status --> <!-- Content based on integration status -->
{#if !wandererEnabled} {#if !wandererEnabled}
<!-- login form with server url username and password --> <!-- login form with server url username and password -->