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:
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.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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}"})
|
||||||
|
|
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">
|
<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"
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue