1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-07 06:05:19 +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 import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from integrations.models import ImmichIntegration, StravaToken, WandererIntegration
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_user = StravaToken.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(
{
@ -26,7 +33,10 @@ class IntegrationView(viewsets.ViewSet):
'global': strava_integration_global,
'user': strava_integration_user
},
'wanderer': wanderer_integration
'wanderer': {
'exists': wanderer_integration,
'expired': is_wanderer_expired
}
},
status=status.HTTP_200_OK
)

View file

@ -113,8 +113,8 @@ class WandererIntegrationViewSet(viewsets.ViewSet):
"is_connected": True,
})
@action(detail=False, methods=["get"], url_path='list')
def list_trails(self, request):
@action(detail=False, methods=["get"], url_path='trails')
def trails(self, request):
inst = self._get_obj()
# Check if we need to prompt for password
@ -131,9 +131,12 @@ class WandererIntegrationViewSet(viewsets.ViewSet):
})
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:
response = session.get(url, timeout=10)
response = session.get(url, params=params, timeout=10)
response.raise_for_status()
except requests.RequestException as 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">
import type { Attachment, ContentImage, Trail } from '$lib/types';
import type { Attachment, ContentImage, Trail, WandererTrail } from '$lib/types';
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms';
@ -23,6 +23,7 @@
import { addToast } from '$lib/toasts';
import ImmichSelect from '../ImmichSelect.svelte';
import WandererCard from '../WandererCard.svelte';
// Props
export let images: ContentImage[] = [];
@ -60,6 +61,12 @@
let editingTrailLink: string = '';
let editingTrailWandererId: string = '';
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
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() {
if (!validateTrailForm()) return;
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 = {
name: trailName.trim(),
location: itemId,
@ -461,7 +451,6 @@
function resetTrailForm() {
trailName = '';
trailLink = '';
trailWandererId = '';
trailError = '';
showAddTrailForm = false;
}
@ -473,11 +462,79 @@
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() {
trailToEdit = null;
editingTrailName = '';
editingTrailLink = '';
editingTrailWandererId = '';
}
function validateEditTrailForm(): boolean {
@ -567,19 +624,28 @@
// Lifecycle
onMount(async () => {
try {
const res = await fetch('/api/integrations/immich/');
const res = await fetch('/api/integrations');
if (res.ok) {
const data = await res.json();
if (data.id) {
// Check Immich integration
if (data.immich) {
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) {
addToast('error', $t('immich.integration_fetch_error'));
}
} catch (error) {
console.error('Error checking Immich integration:', error);
console.error('Error checking integrations:', error);
}
});
</script>
@ -899,18 +965,32 @@
</div>
<h2 class="text-xl font-bold">Trails Management</h2>
</div>
<button
class="btn btn-accent btn-sm gap-2"
on:click={() => (showAddTrailForm = !showAddTrailForm)}
>
<PlusIcon class="w-4 h-4" />
Add Trail
</button>
<div class="flex items-center gap-2">
<button
class="btn btn-accent btn-sm gap-2"
on:click={() => {
showAddTrailForm = !showAddTrailForm;
if (showAddTrailForm) showWandererForm = false;
}}
>
<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 class="text-sm text-base-content/60 mb-4">
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>
<!-- Add Trail Form -->
@ -930,15 +1010,7 @@
bind:value={trailLink}
class="input input-bordered"
placeholder="External link (e.g., AllTrails, Trailforks)"
disabled={isTrailLoading || trailWandererId.trim() !== ''}
/>
<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() !== ''}
disabled={isTrailLoading}
/>
{#if trailError}
<div class="alert alert-error py-2">
@ -956,9 +1028,7 @@
<button
class="btn btn-accent btn-sm"
class:loading={isTrailLoading}
disabled={isTrailLoading ||
!trailName.trim() ||
(!trailLink.trim() && !trailWandererId.trim())}
disabled={isTrailLoading || !trailName.trim() || !trailLink.trim()}
on:click={createTrail}
>
Create Trail
@ -968,6 +1038,98 @@
</div>
{/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 -->
{#if trails.length > 0}
<div class="divider">Current Trails</div>
@ -995,14 +1157,6 @@
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
@ -1049,11 +1203,7 @@
>
{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}
{:else if !trail.wanderer_id}
<div class="text-xs text-base-content/40 mb-3 italic">
No external link available
</div>
@ -1061,14 +1211,16 @@
<!-- 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>
{#if !trail.wanderer_id}
<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>
{/if}
<button
type="button"
class="btn btn-error btn-xs btn-square tooltip tooltip-top"

View file

@ -433,3 +433,35 @@ export type TransportationVisit = {
end_timezone: string;
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 stravaGlobalEnabled = integrations.strava.global 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 publicUrl = '';
@ -104,7 +105,8 @@ export const load: PageServerLoad = async (event) => {
googleMapsEnabled,
stravaGlobalEnabled,
stravaUserEnabled,
wandererEnabled
wandererEnabled,
wandererExpired
}
};
};

View file

@ -29,6 +29,7 @@
let stravaGlobalEnabled = data.props.stravaGlobalEnabled;
let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled;
let wandererExpired = data.props.wandererExpired;
let activeSection: string = 'profile';
// Initialize activeSection from URL on mount
@ -361,6 +362,28 @@
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>
{#if isMFAModalOpen}
@ -1104,6 +1127,27 @@
{/if}
</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 -->
{#if !wandererEnabled}
<!-- login form with server url username and password -->