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:
parent
9fd06911b2
commit
2af78e0a53
8 changed files with 597 additions and 81 deletions
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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}"})
|
||||
|
|
108
frontend/src/lib/components/TrailCard.svelte
Normal file
108
frontend/src/lib/components/TrailCard.svelte
Normal 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>
|
165
frontend/src/lib/components/WandererCard.svelte
Normal file
165
frontend/src/lib/components/WandererCard.svelte
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 -->
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue