mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-04 12:45:17 +02:00
feat: add Strava integration with OAuth flow and activity management
- Implemented IntegrationView for listing integrations including Immich, Google Maps, and Strava. - Created StravaIntegrationView for handling OAuth authorization and token exchange. - Added functionality to refresh Strava access tokens when needed. - Implemented endpoints to fetch user activities from Strava and extract essential information. - Added Strava logo asset and integrated it into the frontend settings page. - Updated settings page to display Strava integration status. - Enhanced location management to include trails with create, edit, and delete functionalities. - Updated types and localization files to support new features.
This commit is contained in:
parent
f5110ed640
commit
9bcada21dd
28 changed files with 991 additions and 72 deletions
|
@ -13,6 +13,9 @@ FRONTEND_URL='http://localhost:3000'
|
||||||
|
|
||||||
EMAIL_BACKEND='console'
|
EMAIL_BACKEND='console'
|
||||||
|
|
||||||
|
# STRAVA_CLIENT_ID=''
|
||||||
|
# STRAVA_CLIENT_SECRET=''
|
||||||
|
|
||||||
# EMAIL_BACKEND='email'
|
# EMAIL_BACKEND='email'
|
||||||
# EMAIL_HOST='smtp.gmail.com'
|
# EMAIL_HOST='smtp.gmail.com'
|
||||||
# EMAIL_USE_TLS=False
|
# EMAIL_USE_TLS=False
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import mark_safe
|
from django.utils.html import mark_safe
|
||||||
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite
|
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
|
||||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||||
from allauth.account.decorators import secure_admin_login
|
from allauth.account.decorators import secure_admin_login
|
||||||
|
|
||||||
|
@ -124,6 +124,16 @@ class VisitAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
image_display.short_description = 'Image Preview'
|
image_display.short_description = 'Image Preview'
|
||||||
|
|
||||||
|
class CollectionInviteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('collection', 'invited_user', 'created_at')
|
||||||
|
search_fields = ('collection__name', 'invited_user__username')
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
def invited_user(self, obj):
|
||||||
|
return obj.invited_user.username if obj.invited_user else 'N/A'
|
||||||
|
|
||||||
|
invited_user.short_description = 'Invited User'
|
||||||
|
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'user', 'display_name', 'icon')
|
list_display = ('name', 'user', 'display_name', 'icon')
|
||||||
search_fields = ('name', 'display_name')
|
search_fields = ('name', 'display_name')
|
||||||
|
@ -153,7 +163,8 @@ admin.site.register(City, CityAdmin)
|
||||||
admin.site.register(VisitedCity)
|
admin.site.register(VisitedCity)
|
||||||
admin.site.register(ContentAttachment)
|
admin.site.register(ContentAttachment)
|
||||||
admin.site.register(Lodging)
|
admin.site.register(Lodging)
|
||||||
admin.site.register(CollectionInvite)
|
admin.site.register(CollectionInvite, CollectionInviteAdmin)
|
||||||
|
admin.site.register(Trail)
|
||||||
|
|
||||||
admin.site.site_header = 'AdventureLog Admin'
|
admin.site.site_header = 'AdventureLog Admin'
|
||||||
admin.site.site_title = 'AdventureLog Admin Site'
|
admin.site.site_title = 'AdventureLog Admin Site'
|
||||||
|
|
33
backend/server/adventures/migrations/0057_trail.py
Normal file
33
backend/server/adventures/migrations/0057_trail.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 5.2.2 on 2025-08-01 13:31
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('adventures', '0056_collectioninvite'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Trail',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('link', models.URLField(blank=True, max_length=2083, null=True, verbose_name='External Trail Link')),
|
||||||
|
('wanderer_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Wanderer Trail ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trails', to='adventures.location')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Trail',
|
||||||
|
'verbose_name_plural': 'Trails',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -267,6 +267,9 @@ class CollectionInvite(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Invite for {self.invited_user.username} to {self.collection.name}"
|
return f"Invite for {self.invited_user.username} to {self.collection.name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Collection Invite"
|
||||||
|
|
||||||
class Collection(models.Model):
|
class Collection(models.Model):
|
||||||
#id = models.AutoField(primary_key=True)
|
#id = models.AutoField(primary_key=True)
|
||||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
|
@ -580,3 +583,39 @@ class Lodging(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
class Trail(models.Model):
|
||||||
|
"""
|
||||||
|
Represents a trail associated with a user.
|
||||||
|
Supports referencing either a Wanderer trail ID or an external link (e.g., AllTrails).
|
||||||
|
"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name='trails')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
|
||||||
|
# Either an external link (e.g., AllTrails, Trailforks) or a Wanderer ID
|
||||||
|
link = models.URLField("External Trail Link", max_length=2083, blank=True, null=True)
|
||||||
|
wanderer_id = models.CharField("Wanderer Trail ID", max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Trail"
|
||||||
|
verbose_name_plural = "Trails"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
has_link = bool(self.link and str(self.link).strip())
|
||||||
|
has_wanderer_id = bool(self.wanderer_id and str(self.wanderer_id).strip())
|
||||||
|
|
||||||
|
if has_link and has_wanderer_id:
|
||||||
|
raise ValidationError("Cannot have both a link and a Wanderer ID. Provide only one.")
|
||||||
|
if not has_link and not has_wanderer_id:
|
||||||
|
raise ValidationError("You must provide either a link or a Wanderer ID.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.full_clean() # Ensure clean() is called on save
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({'Wanderer' if self.wanderer_id else 'External'})"
|
|
@ -108,6 +108,10 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||||
user = request.user
|
user = request.user
|
||||||
is_safe_method = request.method in permissions.SAFE_METHODS
|
is_safe_method = request.method in permissions.SAFE_METHODS
|
||||||
|
|
||||||
|
# If the object has a location field, get that location and continue checking with that object, basically from the location's perspective. I am very proud of this line of code and that's why I am writing this comment.
|
||||||
|
if type(obj).__name__ == 'Trail':
|
||||||
|
obj = obj.location
|
||||||
|
|
||||||
# Anonymous users only get read access to public objects
|
# Anonymous users only get read access to public objects
|
||||||
if not user or not user.is_authenticated:
|
if not user or not user.is_authenticated:
|
||||||
return is_safe_method and getattr(obj, 'is_public', False)
|
return is_safe_method and getattr(obj, 'is_public', False)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import os
|
import os
|
||||||
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite
|
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from main.utils import CustomModelSerializer
|
from main.utils import CustomModelSerializer
|
||||||
from users.serializers import CustomUserDetailsSerializer
|
from users.serializers import CustomUserDetailsSerializer
|
||||||
|
@ -84,6 +84,28 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||||
def get_num_locations(self, obj):
|
def get_num_locations(self, obj):
|
||||||
return Location.objects.filter(category=obj, user=obj.user).count()
|
return Location.objects.filter(category=obj, user=obj.user).count()
|
||||||
|
|
||||||
|
class TrailSerializer(CustomModelSerializer):
|
||||||
|
provider = serializers.SerializerMethodField()
|
||||||
|
class Meta:
|
||||||
|
model = Trail
|
||||||
|
fields = ['id', 'user', 'name', 'location', 'created_at','link','wanderer_id', 'provider']
|
||||||
|
read_only_fields = ['id', 'created_at', 'user', 'provider']
|
||||||
|
|
||||||
|
def get_provider(self, obj):
|
||||||
|
if obj.wanderer_id:
|
||||||
|
return 'Wanderer'
|
||||||
|
# check the link to get the provider such as Strava, AllTrails, etc.
|
||||||
|
if obj.link:
|
||||||
|
if 'strava' in obj.link:
|
||||||
|
return 'Strava'
|
||||||
|
elif 'alltrails' in obj.link:
|
||||||
|
return 'AllTrails'
|
||||||
|
elif 'komoot' in obj.link:
|
||||||
|
return 'Komoot'
|
||||||
|
elif 'outdooractive' in obj.link:
|
||||||
|
return 'Outdooractive'
|
||||||
|
return 'External Link'
|
||||||
|
|
||||||
class VisitSerializer(serializers.ModelSerializer):
|
class VisitSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -105,13 +127,14 @@ class LocationSerializer(CustomModelSerializer):
|
||||||
queryset=Collection.objects.all(),
|
queryset=Collection.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
trails = TrailSerializer(many=True, read_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'description', 'rating', 'tags', 'location',
|
'id', 'name', 'description', 'rating', 'tags', 'location',
|
||||||
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
|
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
|
||||||
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
|
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region', 'trails'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'is_visited']
|
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'is_visited']
|
||||||
|
|
||||||
|
@ -476,7 +499,11 @@ class CollectionSerializer(CustomModelSerializer):
|
||||||
|
|
||||||
class CollectionInviteSerializer(serializers.ModelSerializer):
|
class CollectionInviteSerializer(serializers.ModelSerializer):
|
||||||
name = serializers.CharField(source='collection.name', read_only=True)
|
name = serializers.CharField(source='collection.name', read_only=True)
|
||||||
|
collection_owner_username = serializers.CharField(source='collection.user.username', read_only=True)
|
||||||
|
collection_user_first_name = serializers.CharField(source='collection.user.first_name', read_only=True)
|
||||||
|
collection_user_last_name = serializers.CharField(source='collection.user.last_name', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CollectionInvite
|
model = CollectionInvite
|
||||||
fields = ['id', 'collection', 'created_at', 'name']
|
fields = ['id', 'collection', 'created_at', 'name', 'collection_owner_username', 'collection_user_first_name', 'collection_user_last_name']
|
||||||
read_only_fields = ['id', 'created_at']
|
read_only_fields = ['id', 'created_at']
|
|
@ -20,6 +20,7 @@ router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||||
router.register(r'lodging', LodgingViewSet, basename='lodging')
|
router.register(r'lodging', LodgingViewSet, basename='lodging')
|
||||||
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
|
||||||
router.register(r'backup', BackupViewSet, basename='backup')
|
router.register(r'backup', BackupViewSet, basename='backup')
|
||||||
|
router.register(r'trails', TrailViewSet, basename='trails')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Include the router under the 'api/' prefix
|
# Include the router under the 'api/' prefix
|
||||||
|
|
|
@ -15,3 +15,4 @@ from .attachment_view import *
|
||||||
from .lodging_view import *
|
from .lodging_view import *
|
||||||
from .recommendations_view import *
|
from .recommendations_view import *
|
||||||
from .import_export_view import *
|
from .import_export_view import *
|
||||||
|
from .trail_view import *
|
40
backend/server/adventures/views/trail_view.py
Normal file
40
backend/server/adventures/views/trail_view.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from django.db.models import Q
|
||||||
|
from adventures.models import Location, Trail
|
||||||
|
from adventures.serializers import TrailSerializer
|
||||||
|
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||||
|
|
||||||
|
class TrailViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = TrailSerializer
|
||||||
|
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Returns trails based on location permissions.
|
||||||
|
Users can only see trails in locations they have access to for editing/updating/deleting.
|
||||||
|
This means they are either:
|
||||||
|
- The owner of the location
|
||||||
|
- The location is in a collection that is shared with the user
|
||||||
|
- The location is in a collection that the user owns
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return Trail.objects.none()
|
||||||
|
|
||||||
|
# Build the filter for accessible locations
|
||||||
|
location_filter = Q(location__user=user) # User owns the location
|
||||||
|
|
||||||
|
# Location is in collections (many-to-many) that are shared with user
|
||||||
|
location_filter |= Q(location__collections__shared_with=user)
|
||||||
|
|
||||||
|
# Location is in collections (many-to-many) that user owns
|
||||||
|
location_filter |= Q(location__collections__user=user)
|
||||||
|
|
||||||
|
return Trail.objects.filter(location_filter).distinct()
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""
|
||||||
|
Set the user when creating a trail.
|
||||||
|
"""
|
||||||
|
serializer.save(user=self.request.user)
|
|
@ -1,9 +1,10 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from allauth.account.decorators import secure_admin_login
|
from allauth.account.decorators import secure_admin_login
|
||||||
|
|
||||||
from .models import ImmichIntegration
|
from .models import ImmichIntegration, StravaToken
|
||||||
|
|
||||||
admin.autodiscover()
|
admin.autodiscover()
|
||||||
admin.site.login = secure_admin_login(admin.site.login)
|
admin.site.login = secure_admin_login(admin.site.login)
|
||||||
|
|
||||||
admin.site.register(ImmichIntegration)
|
admin.site.register(ImmichIntegration)
|
||||||
|
admin.site.register(StravaToken)
|
29
backend/server/integrations/migrations/0003_stravatoken.py
Normal file
29
backend/server/integrations/migrations/0003_stravatoken.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 5.2.2 on 2025-08-01 00:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('integrations', '0002_immichintegration_copy_locally'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StravaToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('access_token', models.CharField(max_length=255)),
|
||||||
|
('refresh_token', models.CharField(max_length=255)),
|
||||||
|
('expires_at', models.BigIntegerField()),
|
||||||
|
('athlete_id', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('scope', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='strava_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -14,3 +14,13 @@ class ImmichIntegration(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.user.username + ' - ' + self.server_url
|
return self.user.username + ' - ' + self.server_url
|
||||||
|
|
||||||
|
class StravaToken(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, on_delete=models.CASCADE, related_name='strava_tokens')
|
||||||
|
access_token = models.CharField(max_length=255)
|
||||||
|
refresh_token = models.CharField(max_length=255)
|
||||||
|
expires_at = models.BigIntegerField() # Unix timestamp
|
||||||
|
athlete_id = models.BigIntegerField(null=True, blank=True)
|
||||||
|
scope = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
|
@ -1,12 +1,14 @@
|
||||||
|
from integrations.views import *
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
|
from integrations.views import IntegrationView, StravaIntegrationView
|
||||||
|
|
||||||
# Create the router and register the ViewSet
|
# Create the router and register the ViewSet
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'immich', ImmichIntegrationView, basename='immich')
|
router.register(r'immich', ImmichIntegrationView, basename='immich')
|
||||||
router.register(r'', IntegrationView, basename='integrations')
|
router.register(r'', IntegrationView, basename='integrations')
|
||||||
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
|
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
|
||||||
|
router.register(r'strava', StravaIntegrationView, basename='strava')
|
||||||
|
|
||||||
# Include the router URLs
|
# Include the router URLs
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
6
backend/server/integrations/utils.py
Normal file
6
backend/server/integrations/utils.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
class StandardResultsSetPagination(PageNumberPagination):
|
||||||
|
page_size = 25
|
||||||
|
page_size_query_param = 'page_size'
|
||||||
|
max_page_size = 1000
|
3
backend/server/integrations/views/__init__.py
Normal file
3
backend/server/integrations/views/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet
|
||||||
|
from .integration_view import IntegrationView
|
||||||
|
from .strava_view import StravaIntegrationView
|
|
@ -1,42 +1,19 @@
|
||||||
import os
|
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 .serializers import ImmichIntegrationSerializer
|
from integrations.serializers import ImmichIntegrationSerializer
|
||||||
from .models import ImmichIntegration
|
from integrations.models import ImmichIntegration
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
import requests
|
import requests
|
||||||
from rest_framework.pagination import PageNumberPagination
|
|
||||||
from django.conf import settings
|
|
||||||
from adventures.models import ContentImage
|
from adventures.models import ContentImage
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from integrations.utils import StandardResultsSetPagination
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class IntegrationView(viewsets.ViewSet):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
def list(self, request):
|
|
||||||
"""
|
|
||||||
RESTful GET method for listing all integrations.
|
|
||||||
"""
|
|
||||||
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
|
|
||||||
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
'immich': immich_integrations.exists(),
|
|
||||||
'google_maps': google_map_integration
|
|
||||||
},
|
|
||||||
status=status.HTTP_200_OK
|
|
||||||
)
|
|
||||||
|
|
||||||
class StandardResultsSetPagination(PageNumberPagination):
|
|
||||||
page_size = 25
|
|
||||||
page_size_query_param = 'page_size'
|
|
||||||
max_page_size = 1000
|
|
||||||
|
|
||||||
class ImmichIntegrationView(viewsets.ViewSet):
|
class ImmichIntegrationView(viewsets.ViewSet):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
pagination_class = StandardResultsSetPagination
|
pagination_class = StandardResultsSetPagination
|
26
backend/server/integrations/views/integration_view.py
Normal file
26
backend/server/integrations/views/integration_view.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import os
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from integrations.models import ImmichIntegration
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationView(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
def list(self, request):
|
||||||
|
"""
|
||||||
|
RESTful GET method for listing all integrations.
|
||||||
|
"""
|
||||||
|
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||||
|
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
|
||||||
|
strava_integration = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != ''
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'immich': immich_integrations.exists(),
|
||||||
|
'google_maps': google_map_integration,
|
||||||
|
'strava': strava_integration
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK
|
||||||
|
)
|
290
backend/server/integrations/views/strava_view.py
Normal file
290
backend/server/integrations/views/strava_view.py
Normal file
|
@ -0,0 +1,290 @@
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from django.conf import settings
|
||||||
|
from integrations.models import StravaToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class StravaIntegrationView(viewsets.ViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='authorize')
|
||||||
|
def authorize(self, request):
|
||||||
|
"""
|
||||||
|
Redirects the user to Strava's OAuth authorization page.
|
||||||
|
"""
|
||||||
|
client_id = settings.STRAVA_CLIENT_ID
|
||||||
|
redirect_uri = f"{settings.PUBLIC_URL}/api/integrations/strava/callback/"
|
||||||
|
scope = 'activity:read_all'
|
||||||
|
|
||||||
|
auth_url = (
|
||||||
|
f'https://www.strava.com/oauth/authorize?client_id={client_id}'
|
||||||
|
f'&response_type=code'
|
||||||
|
f'&redirect_uri={redirect_uri}'
|
||||||
|
f'&approval_prompt=auto'
|
||||||
|
f'&scope={scope}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'auth_url': auth_url}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='callback')
|
||||||
|
def callback(self, request):
|
||||||
|
"""
|
||||||
|
Handles the OAuth callback from Strava and exchanges the code for an access token.
|
||||||
|
Saves or updates the StravaToken model instance for the authenticated user.
|
||||||
|
"""
|
||||||
|
code = request.query_params.get('code')
|
||||||
|
if not code:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Missing authorization code from Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.missing_code'
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
token_url = 'https://www.strava.com/oauth/token'
|
||||||
|
payload = {
|
||||||
|
'client_id': int(settings.STRAVA_CLIENT_ID),
|
||||||
|
'client_secret': settings.STRAVA_CLIENT_SECRET,
|
||||||
|
'code': code,
|
||||||
|
'grant_type': 'authorization_code'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(token_url, data=payload)
|
||||||
|
response_data = response.json()
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning("Strava token exchange failed: %s", response_data)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Failed to exchange code for access token.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.exchange_failed',
|
||||||
|
'details': response_data.get('message', 'Unknown error')
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Strava token exchange successful for user %s", request.user.username)
|
||||||
|
|
||||||
|
# Save or update tokens in DB
|
||||||
|
strava_token, created = StravaToken.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
defaults={
|
||||||
|
'access_token': response_data.get('access_token'),
|
||||||
|
'refresh_token': response_data.get('refresh_token'),
|
||||||
|
'expires_at': response_data.get('expires_at'),
|
||||||
|
'athlete_id': response_data.get('athlete', {}).get('id'),
|
||||||
|
'scope': response_data.get('scope'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Strava access token obtained and saved successfully.',
|
||||||
|
'access_token': response_data.get('access_token'),
|
||||||
|
'expires_at': response_data.get('expires_at'),
|
||||||
|
'refresh_token': response_data.get('refresh_token'),
|
||||||
|
'athlete': response_data.get('athlete'),
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("Error during Strava OAuth token exchange: %s", str(e))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Failed to connect to Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.connection_failed',
|
||||||
|
'details': str(e)
|
||||||
|
},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_strava_token_if_needed(self, user):
|
||||||
|
strava_token = StravaToken.objects.filter(user=user).first()
|
||||||
|
if not strava_token:
|
||||||
|
return None, Response({
|
||||||
|
'message': 'You need to authorize Strava first.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.not_authorized'
|
||||||
|
}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
# If token expires in less than 5 minutes, refresh it
|
||||||
|
if strava_token.expires_at - now < 300:
|
||||||
|
logger.info(f"Refreshing Strava token for user {user.username}")
|
||||||
|
refresh_url = 'https://www.strava.com/oauth/token'
|
||||||
|
payload = {
|
||||||
|
'client_id': int(settings.STRAVA_CLIENT_ID),
|
||||||
|
'client_secret': settings.STRAVA_CLIENT_SECRET,
|
||||||
|
'grant_type': 'refresh_token',
|
||||||
|
'refresh_token': strava_token.refresh_token,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.post(refresh_url, data=payload)
|
||||||
|
data = response.json()
|
||||||
|
if response.status_code == 200:
|
||||||
|
# Update token info
|
||||||
|
strava_token.access_token = data['access_token']
|
||||||
|
strava_token.refresh_token = data['refresh_token']
|
||||||
|
strava_token.expires_at = data['expires_at']
|
||||||
|
strava_token.save()
|
||||||
|
return strava_token, None
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to refresh Strava token: {data}")
|
||||||
|
return None, Response({
|
||||||
|
'message': 'Failed to refresh Strava token.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.refresh_failed',
|
||||||
|
'details': data.get('message', 'Unknown error')
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error refreshing Strava token: {str(e)}")
|
||||||
|
return None, Response({
|
||||||
|
'message': 'Failed to connect to Strava for token refresh.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.connection_failed',
|
||||||
|
'details': str(e)
|
||||||
|
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
return strava_token, None
|
||||||
|
|
||||||
|
def extract_essential_activity_info(self,activity):
|
||||||
|
# Pick only essential fields from a single activity dict
|
||||||
|
return {
|
||||||
|
"id": activity.get("id"),
|
||||||
|
"name": activity.get("name"),
|
||||||
|
"type": activity.get("type"),
|
||||||
|
"distance": activity.get("distance"),
|
||||||
|
"moving_time": activity.get("moving_time"),
|
||||||
|
"elapsed_time": activity.get("elapsed_time"),
|
||||||
|
"total_elevation_gain": activity.get("total_elevation_gain"),
|
||||||
|
"start_date": activity.get("start_date"),
|
||||||
|
"start_date_local": activity.get("start_date_local"),
|
||||||
|
"timezone": activity.get("timezone"),
|
||||||
|
"average_speed": activity.get("average_speed"),
|
||||||
|
"max_speed": activity.get("max_speed"),
|
||||||
|
"average_cadence": activity.get("average_cadence"),
|
||||||
|
"average_watts": activity.get("average_watts"),
|
||||||
|
"max_watts": activity.get("max_watts"),
|
||||||
|
"kilojoules": activity.get("kilojoules"),
|
||||||
|
"calories": activity.get("calories"),
|
||||||
|
"gear_id": activity.get("gear_id"),
|
||||||
|
"device_name": activity.get("device_name"),
|
||||||
|
"start_latlng": activity.get("start_latlng"),
|
||||||
|
"end_latlng": activity.get("end_latlng"),
|
||||||
|
'export_original': f'https://www.strava.com/activities/{activity.get("id")}/export_original',
|
||||||
|
'export_gpx': f'https://www.strava.com/activities/{activity.get("id")}/export_gpx',
|
||||||
|
}
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='activities')
|
||||||
|
def activities(self, request):
|
||||||
|
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Get date parameters from query string
|
||||||
|
start_date = request.query_params.get('start_date')
|
||||||
|
end_date = request.query_params.get('end_date')
|
||||||
|
|
||||||
|
# Build query parameters for Strava API
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
try:
|
||||||
|
# Parse the date string and convert to Unix timestamp
|
||||||
|
from datetime import datetime
|
||||||
|
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||||
|
params['after'] = int(start_dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
return Response({
|
||||||
|
'message': 'Invalid start_date format. Use ISO format (e.g., 2024-01-01T00:00:00Z)',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.invalid_start_date'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
try:
|
||||||
|
# Parse the date string and convert to Unix timestamp
|
||||||
|
from datetime import datetime
|
||||||
|
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||||
|
params['before'] = int(end_dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
return Response({
|
||||||
|
'message': 'Invalid end_date format. Use ISO format (e.g., 2024-12-31T23:59:59Z)',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.invalid_end_date'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
|
||||||
|
try:
|
||||||
|
response = requests.get('https://www.strava.com/api/v3/athlete/activities',
|
||||||
|
headers=headers, params=params)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return Response({
|
||||||
|
'message': 'Failed to fetch activities from Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.fetch_failed',
|
||||||
|
'details': response.json().get('message', 'Unknown error')
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
activities = response.json()
|
||||||
|
essential_activities = [self.extract_essential_activity_info(act) for act in activities]
|
||||||
|
|
||||||
|
return Response(essential_activities, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error fetching Strava activities: {str(e)}")
|
||||||
|
return Response({
|
||||||
|
'message': 'Failed to connect to Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.connection_failed',
|
||||||
|
'details': str(e)
|
||||||
|
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='activities/(?P<activity_id>[^/.]+)')
|
||||||
|
def activity(self, request, activity_id=None):
|
||||||
|
if not activity_id:
|
||||||
|
return Response({
|
||||||
|
'message': 'Activity ID is required.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.activity_id_required'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
strava_token, error_response = self.refresh_strava_token_if_needed(request.user)
|
||||||
|
if error_response:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
headers = {'Authorization': f'Bearer {strava_token.access_token}'}
|
||||||
|
try:
|
||||||
|
response = requests.get(f'https://www.strava.com/api/v3/activities/{activity_id}', headers=headers)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return Response({
|
||||||
|
'message': 'Failed to fetch activity from Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.fetch_failed',
|
||||||
|
'details': response.json().get('message', 'Unknown error')
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
activity = response.json()
|
||||||
|
essential_activity = self.extract_essential_activity_info(activity)
|
||||||
|
return Response(essential_activity, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Error fetching Strava activity: {str(e)}")
|
||||||
|
return Response({
|
||||||
|
'message': 'Failed to connect to Strava.',
|
||||||
|
'error': True,
|
||||||
|
'code': 'strava.connection_failed',
|
||||||
|
'details': str(e)
|
||||||
|
}, status=status.HTTP_502_BAD_GATEWAY)
|
|
@ -324,6 +324,8 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
|
||||||
|
|
||||||
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
|
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
|
||||||
|
|
||||||
# Major release version of AdventureLog, not including the patch version date.
|
# Major release version of AdventureLog, not including the patch version date.
|
||||||
|
@ -333,3 +335,6 @@ ADVENTURELOG_RELEASE_VERSION = 'v0.10.0'
|
||||||
COUNTRY_REGION_JSON_VERSION = 'v2.6'
|
COUNTRY_REGION_JSON_VERSION = 'v2.6'
|
||||||
|
|
||||||
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
|
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
|
||||||
|
|
||||||
|
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
|
||||||
|
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
|
1
frontend/src/lib/assets/strava.svg
Normal file
1
frontend/src/lib/assets/strava.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="2500" height="2500"><path d="M0 0h16v16H0z" fill="#fc4c02"/><g fill="#fff" fill-rule="evenodd"><path d="M6.9 8.8l2.5 4.5 2.4-4.5h-1.5l-.9 1.7-1-1.7z" opacity=".6"/><path d="M7.2 2.5l3.1 6.3H4zm0 3.8l1.2 2.5H5.9z"/></g></svg>
|
After Width: | Height: | Size: 291 B |
|
@ -53,7 +53,8 @@
|
||||||
icon: '',
|
icon: '',
|
||||||
user: ''
|
user: ''
|
||||||
},
|
},
|
||||||
attachments: []
|
attachments: [],
|
||||||
|
trails: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export let locationToEdit: Location | null = null;
|
export let locationToEdit: Location | null = null;
|
||||||
|
@ -81,7 +82,7 @@
|
||||||
icon: '',
|
icon: '',
|
||||||
user: ''
|
user: ''
|
||||||
},
|
},
|
||||||
|
trails: locationToEdit?.trails || [],
|
||||||
attachments: locationToEdit?.attachments || []
|
attachments: locationToEdit?.attachments || []
|
||||||
};
|
};
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
@ -167,18 +168,24 @@
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5-5z"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
class="timeline-end timeline-box {step.selected
|
class="timeline-end timeline-box {step.selected
|
||||||
? 'bg-primary text-primary-content'
|
? 'bg-primary text-primary-content'
|
||||||
: 'bg-base-200'}"
|
: 'bg-base-200'} hover:bg-primary/80 transition-colors cursor-pointer"
|
||||||
|
on:click={() => {
|
||||||
|
// Reset all steps
|
||||||
|
steps.forEach((s) => (s.selected = false));
|
||||||
|
// Select clicked step
|
||||||
|
steps[index].selected = true;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{step.name}
|
{step.name}
|
||||||
</div>
|
</button>
|
||||||
{#if index < steps.length - 1}
|
{#if index < steps.length - 1}
|
||||||
<hr />
|
<hr />
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -263,6 +270,7 @@
|
||||||
<LocationMedia
|
<LocationMedia
|
||||||
bind:images={location.images}
|
bind:images={location.images}
|
||||||
bind:attachments={location.attachments}
|
bind:attachments={location.attachments}
|
||||||
|
bind:trails={location.trails}
|
||||||
on:back={() => {
|
on:back={() => {
|
||||||
steps[2].selected = false;
|
steps[2].selected = false;
|
||||||
steps[1].selected = true;
|
steps[1].selected = true;
|
||||||
|
|
|
@ -397,7 +397,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
|
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 pl-6 pr-6">
|
||||||
<div class="max-w-full mx-auto space-y-6">
|
<div class="max-w-full mx-auto space-y-6">
|
||||||
<!-- Basic Information Section -->
|
<!-- Basic Information Section -->
|
||||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Attachment, ContentImage } from '$lib/types';
|
import type { Attachment, ContentImage, Trail } 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';
|
||||||
|
@ -18,6 +18,8 @@
|
||||||
import ImageIcon from '~icons/mdi/image';
|
import ImageIcon from '~icons/mdi/image';
|
||||||
import AttachmentIcon from '~icons/mdi/attachment';
|
import AttachmentIcon from '~icons/mdi/attachment';
|
||||||
import SwapHorizontalVariantIcon from '~icons/mdi/swap-horizontal-variant';
|
import SwapHorizontalVariantIcon from '~icons/mdi/swap-horizontal-variant';
|
||||||
|
import LinkIcon from '~icons/mdi/link';
|
||||||
|
import PlusIcon from '~icons/mdi/plus';
|
||||||
|
|
||||||
import { addToast } from '$lib/toasts';
|
import { addToast } from '$lib/toasts';
|
||||||
import ImmichSelect from '../ImmichSelect.svelte';
|
import ImmichSelect from '../ImmichSelect.svelte';
|
||||||
|
@ -25,6 +27,7 @@
|
||||||
// Props
|
// Props
|
||||||
export let images: ContentImage[] = [];
|
export let images: ContentImage[] = [];
|
||||||
export let attachments: Attachment[] = [];
|
export let attachments: Attachment[] = [];
|
||||||
|
export let trails: Trail[] = [];
|
||||||
export let itemId: string = '';
|
export let itemId: string = '';
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
|
@ -46,6 +49,18 @@
|
||||||
let attachmentToEdit: Attachment | null = null;
|
let attachmentToEdit: Attachment | null = null;
|
||||||
let editingAttachmentName: string = '';
|
let editingAttachmentName: string = '';
|
||||||
|
|
||||||
|
// Trail state
|
||||||
|
let trailName: string = '';
|
||||||
|
let trailLink: string = '';
|
||||||
|
let trailWandererId: string = '';
|
||||||
|
let trailError: string = '';
|
||||||
|
let isTrailLoading: boolean = false;
|
||||||
|
let trailToEdit: Trail | null = null;
|
||||||
|
let editingTrailName: string = '';
|
||||||
|
let editingTrailLink: string = '';
|
||||||
|
let editingTrailWandererId: string = '';
|
||||||
|
let showAddTrailForm: boolean = false;
|
||||||
|
|
||||||
// Allowed file types for attachments
|
// Allowed file types for attachments
|
||||||
const allowedFileTypes = [
|
const allowedFileTypes = [
|
||||||
'.gpx',
|
'.gpx',
|
||||||
|
@ -86,6 +101,10 @@
|
||||||
attachments = [...attachments, newAttachment];
|
attachments = [...attachments, newAttachment];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTrailsList(newTrail: Trail) {
|
||||||
|
trails = [...trails, newTrail];
|
||||||
|
}
|
||||||
|
|
||||||
// API calls
|
// API calls
|
||||||
async function uploadImageToServer(file: File) {
|
async function uploadImageToServer(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
@ -376,6 +395,160 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
const trailData = {
|
||||||
|
name: trailName.trim(),
|
||||||
|
location: itemId,
|
||||||
|
link: trailLink.trim() || null,
|
||||||
|
wanderer_id: trailWandererId.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/trails/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(trailData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const newTrail = await res.json();
|
||||||
|
updateTrailsList(newTrail);
|
||||||
|
addToast('success', 'Trail created successfully');
|
||||||
|
resetTrailForm();
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to create trail');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Trail creation error:', error);
|
||||||
|
trailError = error instanceof Error ? error.message : 'Failed to create trail';
|
||||||
|
addToast('error', 'Failed to create trail');
|
||||||
|
} finally {
|
||||||
|
isTrailLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTrailForm() {
|
||||||
|
trailName = '';
|
||||||
|
trailLink = '';
|
||||||
|
trailWandererId = '';
|
||||||
|
trailError = '';
|
||||||
|
showAddTrailForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingTrail(trail: Trail) {
|
||||||
|
trailToEdit = trail;
|
||||||
|
editingTrailName = trail.name;
|
||||||
|
editingTrailLink = trail.link || '';
|
||||||
|
editingTrailWandererId = trail.wanderer_id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingTrail() {
|
||||||
|
trailToEdit = null;
|
||||||
|
editingTrailName = '';
|
||||||
|
editingTrailLink = '';
|
||||||
|
editingTrailWandererId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEditTrailForm(): boolean {
|
||||||
|
if (!editingTrailName.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLink = editingTrailLink.trim() !== '';
|
||||||
|
const hasWandererId = editingTrailWandererId.trim() !== '';
|
||||||
|
|
||||||
|
if (hasLink && hasWandererId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLink && !hasWandererId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTrailEdit() {
|
||||||
|
if (!trailToEdit || !validateEditTrailForm()) return;
|
||||||
|
|
||||||
|
const trailData = {
|
||||||
|
name: editingTrailName.trim(),
|
||||||
|
link: editingTrailLink.trim() || null,
|
||||||
|
wanderer_id: editingTrailWandererId.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trails/${trailToEdit.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(trailData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updatedTrail = await res.json();
|
||||||
|
trails = trails.map((trail) => (trail.id === trailToEdit!.id ? updatedTrail : trail));
|
||||||
|
addToast('success', 'Trail updated successfully');
|
||||||
|
cancelEditingTrail();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to update trail');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating trail:', error);
|
||||||
|
addToast('error', 'Failed to update trail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTrail(trailId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/trails/${trailId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
trails = trails.filter((trail) => trail.id !== trailId);
|
||||||
|
addToast('success', 'Trail removed successfully');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to remove trail');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing trail:', error);
|
||||||
|
addToast('error', 'Failed to remove trail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation handlers
|
// Navigation handlers
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
dispatch('back');
|
dispatch('back');
|
||||||
|
@ -629,17 +802,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Type Info -->
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<InfoIcon class="h-5 w-5" />
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-medium">Supported file types:</div>
|
|
||||||
<div class="text-xs text-base-content/70 mt-1">
|
|
||||||
GPX, KML, PDF, DOC, TXT, JSON, CSV, XLSX and more
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attachment Gallery -->
|
<!-- Attachment Gallery -->
|
||||||
|
@ -727,21 +889,208 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Trails Managment -->
|
<!-- Trails Management -->
|
||||||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-2 bg-accent/10 rounded-lg">
|
<div class="p-2 bg-accent/10 rounded-lg">
|
||||||
<SwapHorizontalVariantIcon class="w-5 h-5 text-accent" />
|
<SwapHorizontalVariantIcon class="w-5 h-5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-bold">Trails Management</h2>
|
<h2 class="text-xl font-bold">Trails Management</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-base-content/70 mb-4">
|
<button
|
||||||
You can manage trails associated with this location in the Trails section.
|
class="btn btn-accent btn-sm gap-2"
|
||||||
</p>
|
on:click={() => (showAddTrailForm = !showAddTrailForm)}
|
||||||
<p class="text-sm text-base-content/50">
|
>
|
||||||
Coming soon: Create, edit, and delete trails directly from this section.
|
<PlusIcon class="w-4 h-4" />
|
||||||
</p>
|
Add Trail
|
||||||
|
</button>
|
||||||
|
</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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Trail Form -->
|
||||||
|
{#if showAddTrailForm}
|
||||||
|
<div class="bg-accent/5 p-4 rounded-lg border border-accent/20 mb-6">
|
||||||
|
<h4 class="font-medium mb-3 text-accent">Add New Trail</h4>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={trailName}
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder="Trail name"
|
||||||
|
disabled={isTrailLoading}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
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() !== ''}
|
||||||
|
/>
|
||||||
|
{#if trailError}
|
||||||
|
<div class="alert alert-error py-2">
|
||||||
|
<span class="text-sm">{trailError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
disabled={isTrailLoading}
|
||||||
|
on:click={resetTrailForm}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-accent btn-sm"
|
||||||
|
class:loading={isTrailLoading}
|
||||||
|
disabled={isTrailLoading ||
|
||||||
|
!trailName.trim() ||
|
||||||
|
(!trailLink.trim() && !trailWandererId.trim())}
|
||||||
|
on:click={createTrail}
|
||||||
|
>
|
||||||
|
Create Trail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Trails Gallery -->
|
||||||
|
{#if trails.length > 0}
|
||||||
|
<div class="divider">Current Trails</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each trails as trail (trail.id)}
|
||||||
|
<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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="bg-base-200/50 rounded-lg p-8 text-center">
|
||||||
|
<div class="text-base-content/60 mb-2">No trails added yet</div>
|
||||||
|
<div class="text-sm text-base-content/40">
|
||||||
|
Add your first trail using the button above
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,7 @@ export type Location = {
|
||||||
city?: City | null;
|
city?: City | null;
|
||||||
region?: Region | null;
|
region?: Region | null;
|
||||||
country?: Country | null;
|
country?: Country | null;
|
||||||
|
trails: Trail[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdditionalLocation = Location & {
|
export type AdditionalLocation = Location & {
|
||||||
|
@ -312,4 +313,18 @@ export type CollectionInvite = {
|
||||||
collection: string; // UUID of the collection
|
collection: string; // UUID of the collection
|
||||||
name: string; // Name of the collection
|
name: string; // Name of the collection
|
||||||
created_at: string; // ISO 8601 date string
|
created_at: string; // ISO 8601 date string
|
||||||
|
collection_owner_username: string; // Username of the collection owner
|
||||||
|
collection_user_first_name: string; // First name of the collection user
|
||||||
|
collection_user_last_name: string; // Last name of the collection user
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Trail = {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
name: string;
|
||||||
|
location: string; // UUID of the location
|
||||||
|
created_at: string; // ISO 8601 date string
|
||||||
|
link?: string | null; // Optional link to the trail
|
||||||
|
wanderer_id?: string | null; // Optional ID for integration with Wanderer
|
||||||
|
provider: string; // Provider of the trail data, e.g., 'wanderer', 'external'
|
||||||
};
|
};
|
||||||
|
|
|
@ -316,7 +316,9 @@
|
||||||
"leave_collection": "Leave Collection",
|
"leave_collection": "Leave Collection",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"leave_collection_warning": "Are you sure you want to leave this collection? Any locations you added will be unlinked and remain in your account.",
|
"leave_collection_warning": "Are you sure you want to leave this collection? Any locations you added will be unlinked and remain in your account.",
|
||||||
"loading_collections": "Loading collections..."
|
"loading_collections": "Loading collections...",
|
||||||
|
"quick_start": "Quick Start",
|
||||||
|
"details": "Details"
|
||||||
},
|
},
|
||||||
"worldtravel": {
|
"worldtravel": {
|
||||||
"country_list": "Country List",
|
"country_list": "Country List",
|
||||||
|
@ -782,6 +784,7 @@
|
||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"invited_on": "Invited on",
|
"invited_on": "Invited on",
|
||||||
"no_invites_desc": "Make sure your profile is public so users can invite you."
|
"no_invites_desc": "Make sure your profile is public so users can invite you.",
|
||||||
|
"by": "by"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -415,6 +415,10 @@
|
||||||
<p class="text-xs text-base-content/50">
|
<p class="text-xs text-base-content/50">
|
||||||
{$t('invites.invited_on')}
|
{$t('invites.invited_on')}
|
||||||
{formatDate(invite.created_at)}
|
{formatDate(invite.created_at)}
|
||||||
|
{$t('invites.by')}
|
||||||
|
{invite.collection_owner_username || ''}
|
||||||
|
({invite.collection_user_first_name || ''}
|
||||||
|
{invite.collection_user_last_name || ''})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -80,6 +80,7 @@ export const load: PageServerLoad = async (event) => {
|
||||||
}
|
}
|
||||||
let integrations = await integrationsFetch.json();
|
let integrations = await integrationsFetch.json();
|
||||||
let googleMapsEnabled = integrations.google_maps as boolean;
|
let googleMapsEnabled = integrations.google_maps as boolean;
|
||||||
|
let stravaEnabled = integrations.strava as boolean;
|
||||||
|
|
||||||
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
||||||
let publicUrl = '';
|
let publicUrl = '';
|
||||||
|
@ -98,7 +99,8 @@ export const load: PageServerLoad = async (event) => {
|
||||||
immichIntegration,
|
immichIntegration,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
socialProviders,
|
socialProviders,
|
||||||
googleMapsEnabled
|
googleMapsEnabled,
|
||||||
|
stravaEnabled
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
import { appTitle, appVersion, copyrightYear } from '$lib/config.js';
|
import { appTitle, appVersion, copyrightYear } from '$lib/config.js';
|
||||||
import ImmichLogo from '$lib/assets/immich.svg';
|
import ImmichLogo from '$lib/assets/immich.svg';
|
||||||
import GoogleMapsLogo from '$lib/assets/google_maps.svg';
|
import GoogleMapsLogo from '$lib/assets/google_maps.svg';
|
||||||
|
import StravaLogo from '$lib/assets/strava.svg';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
console.log(data);
|
console.log(data);
|
||||||
|
@ -24,6 +25,7 @@
|
||||||
let public_url: string = data.props.publicUrl;
|
let public_url: string = data.props.publicUrl;
|
||||||
let immichIntegration = data.props.immichIntegration;
|
let immichIntegration = data.props.immichIntegration;
|
||||||
let googleMapsEnabled = data.props.googleMapsEnabled;
|
let googleMapsEnabled = data.props.googleMapsEnabled;
|
||||||
|
let stravaEnabled = data.props.stravaEnabled;
|
||||||
let activeSection: string = 'profile';
|
let activeSection: string = 'profile';
|
||||||
let acknowledgeRestoreOverride: boolean = false;
|
let acknowledgeRestoreOverride: boolean = false;
|
||||||
|
|
||||||
|
@ -896,7 +898,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Google maps integration - displayt only if its connected -->
|
<!-- Google maps integration - displayt only if its connected -->
|
||||||
<div class="p-6 bg-base-200 rounded-xl">
|
<div class="p-6 bg-base-200 rounded-xl mb-4">
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<img src={GoogleMapsLogo} alt="Google Maps" class="w-8 h-8" />
|
<img src={GoogleMapsLogo} alt="Google Maps" class="w-8 h-8" />
|
||||||
<div>
|
<div>
|
||||||
|
@ -922,6 +924,33 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-base-200 rounded-xl">
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<img src={StravaLogo} alt="Strava" class="w-8 h-8 rounded-md" />
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold">Strava</h3>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{$t('google_maps.google_maps_integration_desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if stravaEnabled}
|
||||||
|
<div class="badge badge-success ml-auto">{$t('settings.connected')}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="badge badge-error ml-auto">{$t('settings.disconnected')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 p-4 bg-info/10 rounded-lg">
|
||||||
|
<p class="text-sm">
|
||||||
|
📖 {$t('immich.need_help')}
|
||||||
|
<a
|
||||||
|
class="link link-primary"
|
||||||
|
href="https://adventurelog.app/docs/configuration/google_maps_integration.html"
|
||||||
|
target="_blank">{$t('navbar.documentation')}</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue