1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 04:49:37 +02:00

Immich Integration

This commit is contained in:
Sean Morley 2025-01-05 19:08:37 -05:00 committed by GitHub
commit a9c2af9649
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 2042 additions and 254 deletions

View file

@ -83,22 +83,28 @@ Enjoy AdventureLog! 🎉
# Screenshots # Screenshots
![Adventure Page](screenshots/adventures.png) ![Adventure Page](brand/screenshots/adventures.png)
Displaying the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures. Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
![Detail Page](screenshots/details.png) ![Detail Page](brand/screenshots/details.png)
Shows specific details about an adventure, including the name, date, location, description, and rating. Shows specific details about an adventure, including the name, date, location, description, and rating.
![Edit](screenshots/edit.png) ![Edit](brand/screenshots/edit.png)
![Map Page](screenshots/map.png) ![Map Page](brand/screenshots/map.png)
View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map. View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map.
![Itinerary Page](screenshots/itinerary.png) ![Dashboard Page](brand/screenshots/dashboard.png)
Displays a summary of your adventures, including your world travel stats.
![Country Page](screenshots/countries.png) ![Itinerary Page](brand/screenshots/itinerary.png)
Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
![Region Page](screenshots/regions.png) ![Country Page](brand/screenshots/countries.png)
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
![Region Page](brand/screenshots/regions.png)
Displays the regions for a specific country, includes a map view to visually select regions.
# About AdventureLog # About AdventureLog

View file

@ -20,4 +20,15 @@ EMAIL_BACKEND='console'
# EMAIL_USE_SSL=True # EMAIL_USE_SSL=True
# EMAIL_HOST_USER='user' # EMAIL_HOST_USER='user'
# EMAIL_HOST_PASSWORD='password' # EMAIL_HOST_PASSWORD='password'
# DEFAULT_FROM_EMAIL='user@example.com' # DEFAULT_FROM_EMAIL='user@example.com'
# ------------------- #
# For Developers to start a Demo Database
# docker run --name postgres-admin -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=admin -p 5432:5432 -d postgis/postgis:15-3.3
# PGHOST='localhost'
# PGDATABASE='admin'
# PGUSER='admin'
# PGPASSWORD='admin'
# ------------------- #

View file

@ -8,8 +8,6 @@ from allauth.account.decorators import secure_admin_login
admin.autodiscover() admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login) admin.site.login = secure_admin_login(admin.site.login)
class AdventureAdmin(admin.ModelAdmin): class AdventureAdmin(admin.ModelAdmin):
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
list_filter = ( 'user_id', 'is_public') list_filter = ( 'user_id', 'is_public')

View file

@ -0,0 +1,20 @@
# Generated by Django 5.0.8 on 2025-01-01 21:40
import adventures.models
import django_resized.forms
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('adventures', '0015_transportation_destination_latitude_and_more'),
]
operations = [
migrations.AlterField(
model_name='adventureimage',
name='image',
field=django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.8 on 2025-01-03 04:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0016_alter_adventureimage_image'),
]
operations = [
migrations.AddField(
model_name='adventureimage',
name='is_primary',
field=models.BooleanField(default=False),
),
]

View file

@ -1,7 +1,9 @@
from collections.abc import Collection from collections.abc import Collection
import os
from typing import Iterable from typing import Iterable
import uuid import uuid
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
@ -257,12 +259,28 @@ class ChecklistItem(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@deconstructible
class PathAndRename:
def __init__(self, path):
self.path = path
def __call__(self, instance, filename):
ext = filename.split('.')[-1]
# Generate a new UUID for the filename
filename = f"{uuid.uuid4()}.{ext}"
return os.path.join(self.path, filename)
class AdventureImage(models.Model): class AdventureImage(models.Model):
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)
user_id = models.ForeignKey( user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id) User, on_delete=models.CASCADE, default=default_user_id)
image = ResizedImageField(force_format="WEBP", quality=75, upload_to='images/') image = ResizedImageField(
force_format="WEBP",
quality=75,
upload_to=PathAndRename('images/') # Use the callable class here
)
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
is_primary = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.image.url return self.image.url

View file

@ -8,7 +8,7 @@ from main.utils import CustomModelSerializer
class AdventureImageSerializer(CustomModelSerializer): class AdventureImageSerializer(CustomModelSerializer):
class Meta: class Meta:
model = AdventureImage model = AdventureImage
fields = ['id', 'image', 'adventure'] fields = ['id', 'image', 'adventure', 'is_primary']
read_only_fields = ['id'] read_only_fields = ['id']
def to_representation(self, instance): def to_representation(self, instance):
@ -116,7 +116,7 @@ class AdventureSerializer(CustomModelSerializer):
return False return False
def create(self, validated_data): def create(self, validated_data):
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', None)
category_data = validated_data.pop('category', None) category_data = validated_data.pop('category', None)
print(category_data) print(category_data)
adventure = Adventure.objects.create(**validated_data) adventure = Adventure.objects.create(**validated_data)
@ -131,6 +131,7 @@ class AdventureSerializer(CustomModelSerializer):
return adventure return adventure
def update(self, instance, validated_data): def update(self, instance, validated_data):
has_visits = 'visits' in validated_data
visits_data = validated_data.pop('visits', []) visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None) category_data = validated_data.pop('category', None)
@ -142,24 +143,25 @@ class AdventureSerializer(CustomModelSerializer):
instance.category = category instance.category = category
instance.save() instance.save()
current_visits = instance.visits.all() if has_visits:
current_visit_ids = set(current_visits.values_list('id', flat=True)) current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
updated_visit_ids = set() updated_visit_ids = set()
for visit_data in visits_data: for visit_data in visits_data:
visit_id = visit_data.get('id') visit_id = visit_data.get('id')
if visit_id and visit_id in current_visit_ids: if visit_id and visit_id in current_visit_ids:
visit = current_visits.get(id=visit_id) visit = current_visits.get(id=visit_id)
for attr, value in visit_data.items(): for attr, value in visit_data.items():
setattr(visit, attr, value) setattr(visit, attr, value)
visit.save() visit.save()
updated_visit_ids.add(visit_id) updated_visit_ids.add(visit_id)
else: else:
new_visit = Visit.objects.create(adventure=instance, **visit_data) new_visit = Visit.objects.create(adventure=instance, **visit_data)
updated_visit_ids.add(new_visit.id) updated_visit_ids.add(new_visit.id)
visits_to_delete = current_visit_ids - updated_visit_ids visits_to_delete = current_visit_ids - updated_visit_ids
instance.visits.filter(id__in=visits_to_delete).delete() instance.visits.filter(id__in=visits_to_delete).delete()
return instance return instance

View file

@ -1032,7 +1032,29 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def image_delete(self, request, *args, **kwargs): def image_delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs) return self.destroy(request, *args, **kwargs)
@action(detail=True, methods=['post'])
def toggle_primary(self, request, *args, **kwargs):
# Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED)
instance = self.get_object()
adventure = instance.adventure
if adventure.user_id != request.user:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
# Check if the image is already the primary image
if instance.is_primary:
return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST)
# Set the current primary image to false
AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False)
# Set the new image to true
instance.is_primary = True
instance.save()
return Response({"success": "Image set as primary image"})
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:

View file

View file

@ -0,0 +1,9 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from .models import ImmichIntegration
admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
admin.site.register(ImmichIntegration)

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class IntegrationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'integrations'

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.8 on 2025-01-02 23:16
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ImmichIntegration',
fields=[
('server_url', models.CharField(max_length=255)),
('api_key', models.CharField(max_length=255)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,15 @@
from django.db import models
from django.contrib.auth import get_user_model
import uuid
User = get_user_model()
class ImmichIntegration(models.Model):
server_url = models.CharField(max_length=255)
api_key = models.CharField(max_length=255)
user = models.ForeignKey(
User, on_delete=models.CASCADE)
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
def __str__(self):
return self.user.username + ' - ' + self.server_url

View file

@ -0,0 +1,13 @@
from .models import ImmichIntegration
from rest_framework import serializers
class ImmichIntegrationSerializer(serializers.ModelSerializer):
class Meta:
model = ImmichIntegration
fields = '__all__'
read_only_fields = ['id', 'user']
def to_representation(self, instance):
representation = super().to_representation(instance)
representation.pop('user', None)
return representation

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet
# Create the router and register the ViewSet
router = DefaultRouter()
router.register(r'immich', ImmichIntegrationView, basename='immich')
router.register(r'', IntegrationView, basename='integrations')
router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset')
# Include the router URLs
urlpatterns = [
path("", include(router.urls)), # Includes /immich/ routes
]

View file

@ -0,0 +1,314 @@
import os
from rest_framework.response import Response
from rest_framework import viewsets, status
from .serializers import ImmichIntegrationSerializer
from .models import ImmichIntegration
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
import requests
from rest_framework.pagination import PageNumberPagination
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)
return Response(
{
'immich': immich_integrations.exists()
},
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):
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
def check_integration(self, request):
"""
Checks if the user has an active Immich integration.
Returns:
- None if the integration exists.
- A Response with an error message if the integration is missing.
"""
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if not user_integrations.exists():
return Response(
{
'message': 'You need to have an active Immich integration to use this feature.',
'error': True,
'code': 'immich.integration_missing'
},
status=status.HTTP_403_FORBIDDEN
)
return ImmichIntegration.objects.first()
@action(detail=False, methods=['get'], url_path='search')
def search(self, request):
"""
Handles the logic for searching Immich images.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
query = request.query_params.get('query', '')
if not query:
return Response(
{
'message': 'Query is required.',
'error': True,
'code': 'immich.query_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.post(f'{integration.server_url}/search/smart', headers={
'x-api-key': integration.api_key
},
json = {
'query': query
}
)
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res and 'items' in res['assets']:
paginator = self.pagination_class()
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
for item in res['assets']['items']:
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets']['items'], request)
return paginator.get_paginated_response(result_page)
else:
return Response(
{
'message': 'No items found.',
'error': True,
'code': 'immich.no_items_found'
},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)')
def get(self, request, imageid=None):
"""
RESTful GET method for retrieving a specific Immich image by ID.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
if not imageid:
return Response(
{
'message': 'Image ID is required.',
'error': True,
'code': 'immich.imageid_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={
'x-api-key': integration.api_key
})
# should return the image file
from django.http import HttpResponse
return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK)
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
@action(detail=False, methods=['get'])
def albums(self, request):
"""
RESTful GET method for retrieving all Immich albums.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/albums', headers={
'x-api-key': integration.api_key
})
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response(
res,
status=status.HTTP_200_OK
)
@action(detail=False, methods=['get'], url_path='albums/(?P<albumid>[^/.]+)')
def album(self, request, albumid=None):
"""
RESTful GET method for retrieving a specific Immich album by ID.
"""
# Check for integration before proceeding
integration = self.check_integration(request)
if isinstance(integration, Response):
return integration
if not albumid:
return Response(
{
'message': 'Album ID is required.',
'error': True,
'code': 'immich.albumid_required'
},
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
immich_fetch = requests.get(f'{integration.server_url}/albums/{albumid}', headers={
'x-api-key': integration.api_key
})
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res:
return Response(
res['assets'],
status=status.HTTP_200_OK
)
else:
return Response(
{
'message': 'No assets found in this album.',
'error': True,
'code': 'immich.no_assets_found'
},
status=status.HTTP_404_NOT_FOUND
)
class ImmichIntegrationViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = ImmichIntegrationSerializer
queryset = ImmichIntegration.objects.all()
def get_queryset(self):
return ImmichIntegration.objects.filter(user=self.request.user)
def create(self, request):
"""
RESTful POST method for creating a new Immich integration.
"""
# Check if the user already has an integration
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if user_integrations.exists():
return Response(
{
'message': 'You already have an active Immich integration.',
'error': True,
'code': 'immich.integration_exists'
},
status=status.HTTP_400_BAD_REQUEST
)
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user)
return Response(
serializer.data,
status=status.HTTP_201_CREATED
)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
def destroy(self, request, pk=None):
"""
RESTful DELETE method for deleting an existing Immich integration.
"""
integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first()
if not integration:
return Response(
{
'message': 'Integration not found.',
'error': True,
'code': 'immich.integration_not_found'
},
status=status.HTTP_404_NOT_FOUND
)
integration.delete()
return Response(
{
'message': 'Integration deleted successfully.'
},
status=status.HTTP_200_OK
)
def list(self, request, *args, **kwargs):
# If the user has an integration, we only want to return that integration
user_integrations = ImmichIntegration.objects.filter(user=request.user)
if user_integrations.exists():
integration = user_integrations.first()
serializer = self.serializer_class(integration)
return Response(
serializer.data,
status=status.HTTP_200_OK
)
else:
return Response(
{
'message': 'No integration found.',
'error': True,
'code': 'immich.integration_not_found'
},
status=status.HTTP_404_NOT_FOUND
)

View file

@ -56,6 +56,7 @@ INSTALLED_APPS = (
'adventures', 'adventures',
'worldtravel', 'worldtravel',
'users', 'users',
'integrations',
'django.contrib.gis', 'django.contrib.gis',
) )
@ -164,9 +165,6 @@ TEMPLATES = [
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True' DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True'
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.') DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
ALLAUTH_UI_THEME = "dark"
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
AUTH_USER_MODEL = 'users.CustomUser' AUTH_USER_MODEL = 'users.CustomUser'
ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter' ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter'
@ -222,10 +220,16 @@ REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
} }
SWAGGER_SETTINGS = { if DEBUG:
'LOGIN_URL': 'login', REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'LOGOUT_URL': 'logout', 'rest_framework.renderers.JSONRenderer',
} 'rest_framework.renderers.BrowsableAPIRenderer',
)
else:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = (
'rest_framework.renderers.JSONRenderer',
)
CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()]

View file

@ -39,6 +39,8 @@ urlpatterns = [
# path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'), # path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("api/integrations/", include("integrations.urls")),
# Include the API endpoints: # Include the API endpoints:
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -53,6 +53,7 @@
>Documentation</a >Documentation</a
> >
</li> </li>
<li> <li>
<a <a
target="_blank" target="_blank"
@ -60,6 +61,7 @@
>Source Code</a >Source Code</a
> >
</li> </li>
<li><a href="/docs">API Docs</a></li>
</ul> </ul>
</div> </div>
<!--/.nav-collapse --> <!--/.nav-collapse -->

View file

@ -68,6 +68,8 @@ class Command(BaseCommand):
country_name = country['name'] country_name = country['name']
country_subregion = country['subregion'] country_subregion = country['subregion']
country_capital = country['capital'] country_capital = country['capital']
longitude = round(float(country['longitude']), 6) if country['longitude'] else None
latitude = round(float(country['latitude']), 6) if country['latitude'] else None
processed_country_codes.add(country_code) processed_country_codes.add(country_code)
@ -76,13 +78,17 @@ class Command(BaseCommand):
country_obj.name = country_name country_obj.name = country_name
country_obj.subregion = country_subregion country_obj.subregion = country_subregion
country_obj.capital = country_capital country_obj.capital = country_capital
country_obj.longitude = longitude
country_obj.latitude = latitude
countries_to_update.append(country_obj) countries_to_update.append(country_obj)
else: else:
country_obj = Country( country_obj = Country(
name=country_name, name=country_name,
country_code=country_code, country_code=country_code,
subregion=country_subregion, subregion=country_subregion,
capital=country_capital capital=country_capital,
longitude=longitude,
latitude=latitude
) )
countries_to_create.append(country_obj) countries_to_create.append(country_obj)

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.8 on 2025-01-02 00:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0010_country_capital'),
]
operations = [
migrations.AddField(
model_name='country',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='country',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View file

@ -15,6 +15,8 @@ class Country(models.Model):
country_code = models.CharField(max_length=2, unique=True) #iso2 code country_code = models.CharField(max_length=2, unique=True) #iso2 code
subregion = models.CharField(max_length=100, blank=True, null=True) subregion = models.CharField(max_length=100, blank=True, null=True)
capital = models.CharField(max_length=100, blank=True, null=True) capital = models.CharField(max_length=100, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
class Meta: class Meta:
verbose_name = "Country" verbose_name = "Country"

View file

@ -29,7 +29,7 @@ class CountrySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Country model = Country
fields = '__all__' fields = '__all__'
read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits'] read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital']
class RegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer):

BIN
brand/adventurelog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

313
brand/adventurelog.svg Normal file
View file

@ -0,0 +1,313 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="2000"
height="2000"
viewBox="0 0 529.16665 529.16666"
version="1.1"
id="svg1"
sodipodi:docname="AdventureLog.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
inkscape:export-filename="AdventureLog.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.35190083"
inkscape:cx="1140.9464"
inkscape:cy="1112.5293"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter389"
x="-1.0282219"
y="-0.73747355"
width="3.0639595"
height="2.4803376">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood388" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur388" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset388" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite388" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite389" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter391"
x="-0.59415994"
y="-1.3323052"
width="2.1926628"
height="3.6743487">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood389" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur389" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset389" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite390" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite391" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter393"
x="-1.1482379"
y="-0.96121423"
width="3.3048687"
height="2.9294544">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood391" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur391" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset391" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite392" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite393" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter395"
x="-1.3398814"
y="-1.1275613"
width="3.6895566"
height="3.2633644">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood393" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur393" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset393" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite394" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite395" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter397"
x="-1.8571666"
y="-0.84804253"
width="4.7279079"
height="2.7022837">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood395" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="57.004227"
id="feGaussianBlur395" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset395" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite396" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite397" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter427"
x="-0.096054554"
y="-0.10772674"
width="1.1947073"
height="1.2117496">
<feFlood
result="flood"
in="SourceGraphic"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
id="feFlood426" />
<feGaussianBlur
result="blur"
in="SourceGraphic"
stdDeviation="13.320559"
id="feGaussianBlur426" />
<feOffset
result="offset"
in="blur"
dx="1.000000"
dy="1.000000"
id="feOffset426" />
<feComposite
result="comp1"
operator="in"
in="flood"
in2="offset"
id="feComposite426" />
<feComposite
result="comp2"
operator="over"
in="SourceGraphic"
in2="comp1"
id="feComposite427" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g379">
<path
style="display:inline;fill:#48b5ff;fill-opacity:1;stroke-width:94.45;stroke-dasharray:none"
d="m 971.77794,1568.1491 -215.24252,-108.5775 -232.07676,98.4805 -232.07678,98.4806 -0.63034,-550.1231 c -0.34668,-302.56774 0.21559,-550.94143 1.2495,-551.94157 1.03391,-1.00014 105.33804,-45.69109 231.78696,-99.31323 L 754.69512,357.65999 970.68644,466.2139 c 118.79516,59.70465 217.23796,108.5539 218.76156,108.5539 1.5236,0 108.4326,-50.70974 237.5755,-112.68831 129.1428,-61.97858 245.2097,-117.568 257.9264,-123.53205 l 23.1212,-10.84372 -0.6303,551.00102 -0.6303,551.00106 -257.396,123.4976 c -141.5678,67.9237 -258.5206,123.5034 -259.895,123.5104 -1.3745,0.01 -99.3582,-48.8471 -217.74156,-108.5647 z"
id="path1"
transform="scale(0.26458333)" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter389)"
d="m 154.50783,108.88503 c -2.85572,6.51915 -3.99705,17.36183 -0.2277,23.7036 3.00168,5.05017 8.28922,6.62967 13.3295,9.04742 3.90851,1.87485 7.96149,3.93177 11.47307,6.51256 13.62566,10.01398 23.98335,27.67115 20.06743,44.94435 -0.12449,0.54916 -1.48104,7.01695 -1.85641,7.87642 -2.34196,5.36214 -12.56252,15.09064 -18.05999,17.60459 -0.31647,0.14472 -6.14257,1.6119 -6.77744,1.77975 -5.74767,1.51955 -11.84,3.00805 -16.77513,6.51256 -2.81536,1.99923 -13.27557,11.47452 -14.84205,14.54 -0.76687,1.5007 -1.22537,3.14442 -1.97026,4.65615 -2.34545,4.75997 -5.79169,9.60118 -9.20077,13.63154 -3.11382,3.68129 -2.36218,2.17313 -5.86897,5.3764 -6.0653,5.54035 -12.38233,10.68303 -18.66873,15.97822 -2.95625,2.4901 -1.77292,2.02049 -4.80717,4.24024 -4.376145,3.20143 -19.485134,11.83259 -25.104617,8.25513 -5.798267,-3.69128 -1.637855,-18.91136 -2.537182,-24.27052 -0.665342,-3.96483 -2.868842,-7.73278 -3.824359,-11.66126 -1.060926,-4.36186 0.244798,-8.61424 0.415894,-12.95078 0.166198,-4.2124 0.437509,-8.63608 -0.03717,-12.8346 -0.54496,-4.82011 -2.197963,-8.2219 -2.197963,-13.32717 0,-3.83658 -0.26317,-7.9553 0.0395,-11.77513 0.113016,-1.42634 0.682535,-2.78477 0.871283,-4.20307 0.705311,-5.2999 1.237988,-11.08737 0.831787,-16.4336 -0.205095,-2.69936 5.511498,-10.74899 5.093496,-13.38624 -0.980816,-6.18814 -7.14978,-6.25695 -6.304002,-12.32247 0.451585,-3.23855 0.187248,-7.10749 1.740246,-10.07205 0.835928,-1.59571 1.639732,-4.10023 2.915902,-5.3764 3.741116,-3.74112 13.330719,-6.06402 18.250511,-7.60923 3.127833,-0.98238 6.027592,-2.45779 8.975394,-3.86385 3.27336,-1.56136 5.87838,-3.71819 8.93589,-5.60178 3.52017,-2.16861 7.75174,-3.29655 11.51025,-4.96052 11.45567,-5.07163 22.44821,-10.89093 34.60976,-14.01026 z"
id="path2"
sodipodi:nodetypes="csssscssssssssssssssssssssssssssc" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter393)"
d="m 282.71911,236.75014 c -1.07341,-0.51813 -2.0389,-1.39597 -3.22027,-1.55438 -1.88367,-0.25258 -5.13392,0.85934 -7.00513,1.44053 -8.45275,2.62538 -18.44379,6.81757 -22.49075,15.37179 -3.20748,6.77976 -1.80841,13.94405 -1.21283,21.05255 0.70345,8.39597 0.60913,17.64626 3.06924,25.78307 3.80766,12.59377 15.78781,28.09023 29.11717,31.23845 5.76255,1.36104 8.68662,1.0038 15.10925,0.26487 11.5788,-1.33212 23.20626,-7.9298 31.04795,-16.39408 3.10414,-3.3506 5.50955,-7.21715 8.59666,-10.6018 3.18743,-3.49465 5.51775,-7.04064 8.06463,-10.9805 3.48445,-5.39025 5.91,-9.43047 3.44564,-16.12924 -1.0297,-2.79895 -5.0392,-5.98461 -7.08181,-8.10411 -4.91808,-5.10316 -8.81666,-9.96675 -9.42845,-17.30255 -0.51679,-6.19651 0.806,-12.46011 0.11382,-18.62923 -0.87048,-7.75843 -3.35968,-15.22014 -5.56458,-22.67895 -1.97014,-6.66463 -5.2514,-14.24288 -11.70078,-17.79745 -15.70897,-8.65796 -36.07811,2.92981 -49.03591,11.73795 -1.87759,1.2763 -4.03614,1.97474 -5.86898,3.29462 -1.50247,1.08197 -2.65518,2.55672 -4.05205,3.74768 -2.7825,2.37234 -5.73488,4.72293 -8.59435,7.00513 -6.38056,5.09245 -15.28401,9.78925 -16.88899,18.59206 -0.67926,3.72553 7.14966,3.49307 9.04975,3.44332 9.16411,-0.23998 18.38306,-4.78561 26.08975,-9.42615 2.57984,-1.55343 5.60029,-3.28025 8.59434,-3.90103 3.15601,-0.65434 6.73357,-0.98782 9.69333,0.56924 1.40962,0.74156 2.32511,2.61628 3.3713,3.74769 3.81595,4.12676 4.11615,7.5098 -3.21795,6.21052 z"
id="path5" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter391)"
d="m 99.110381,433.18186 c 4.670059,-2.86644 7.566889,-7.59439 11.398729,-11.3964 11.22457,-11.13721 20.23699,-24.24948 28.43641,-37.74871 5.53049,-9.10519 9.71389,-19.38771 16.16872,-27.90433 3.11752,-4.11332 7.50709,-7.12695 11.43358,-10.41361 4.20791,-3.52221 7.6504,-6.81593 12.8741,-8.67103 15.36185,-5.45544 26.73636,1.95538 38.47129,11.2454 3.5267,2.79191 7.05706,4.28564 10.90616,6.47539 4.29758,2.44485 7.73021,6.21292 12.19102,8.44333 8.94937,4.47469 19.38222,5.65478 29.15668,6.89126 7.14631,0.90405 14.16066,2.50237 21.1664,4.12641 16.46849,3.81768 33.64484,8.74959 32.67668,29.34489 -0.28171,5.99241 -3.32624,12.60742 -8.02513,16.39408 -3.91306,3.15339 -9.22134,3.33169 -13.89873,4.20307 -5.87557,1.09461 -11.90458,2.75058 -17.94615,2.91592 -3.19683,0.0875 -11.4417,-2.50979 -14.9954,-3.33179 -3.80158,-0.87937 -8.26721,-0.9415 -11.73793,-2.84158 -3.87055,-2.11894 -6.90769,-5.47743 -10.45078,-8.0251 -4.87127,-3.50271 -1.08518,-0.58992 -4.96051,-2.91589 -3.30897,-1.98607 -6.204,-4.669 -9.57948,-6.54974 -5.1211,-2.8534 -13.86293,-3.58071 -19.69104,-4.77231 -5.67771,-1.16089 -11.01578,-3.30923 -16.81231,-4.01257 -13.91552,-1.68849 -29.45142,5.70987 -40.9318,13.09947 -2.56659,1.65206 -4.97173,3.56039 -7.42102,5.33924 -2.67583,1.94339 -5.80257,3.32094 -8.7082,4.88384 -7.53479,4.05288 -15.4307,7.2287 -22.90898,11.35922 -2.00201,1.1058 -11.46055,6.02861 -13.17615,5.68079 -1.32827,-0.26929 -2.33944,-2.21337 -3.636159,-1.81925 -2.267678,0.68921 -3.219347,3.63569 -5.339231,4.69564"
id="path6" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter395)"
d="m 450.19631,298.04907 c -5.5282,0.50496 -11.31189,-0.22132 -16.58461,1.51487 -12.17369,4.0086 -28.70549,15.28393 -34.1172,28.28309 -2.07438,4.98277 -2.95732,10.25334 -3.37129,15.59946 -0.22418,2.89552 -0.0933,5.87015 -0.83177,8.70821 -1.64349,6.31634 -4.7022,13.0625 -8.78488,18.17616 -2.91534,3.65154 -6.67846,6.51549 -10.14873,9.54 -8.24569,7.18651 -23.60925,23.91071 -21.96103,36.31049 0.19262,1.44907 0.77642,2.27965 2.1213,2.87872 2.17652,0.96954 6.3614,-0.53234 8.63153,-0.8341 7.76113,-1.03164 12.12755,-1.31003 19.57718,-5.03486 1.44111,-0.72054 2.84964,-1.3653 4.31694,-2.04462 6.05637,-2.80398 11.89083,-6.01507 17.83461,-9.04973 2.26536,-1.15663 4.74779,-1.77562 7.04231,-2.87642 2.15358,-1.03317 3.83749,-2.63954 5.98281,-3.67334 1.5544,-0.74904 3.25289,-1.02836 4.80949,-1.70307 1.86055,-0.80645 3.54978,-1.97313 5.33924,-2.87872 2.17898,-1.10271 4.61735,-1.2749 6.92846,-1.8936 1.4836,-0.39716 2.68676,-1.23536 4.08921,-1.81692 1.65156,-0.68485 3.50653,-0.57332 5.22539,-0.98512 1.56427,-0.37476 2.48695,-2.11201 3.74769,-2.99024 0.6309,-0.4395 1.52495,-0.5375 2.00745,-1.13618 0.48395,-0.60047 0.25164,-1.54802 0.6064,-2.23279 0.46074,-0.88932 1.51323,-1.21002 1.96794,-2.1213 1.8632,-3.73398 0.31491,-12.51823 0.41823,-16.62178 0.11186,-4.44304 0.41844,-8.86217 0.71795,-13.29 0.23315,-3.44704 -0.22538,-6.93523 -0.22538,-10.3741 0,-1.49648 0.38465,-2.89922 0.30203,-4.39359 -0.0821,-1.48571 -0.45538,-2.97958 -0.45538,-4.46796 0,-3.04234 0.0308,0.34052 0.49258,-2.53484 0.34938,-2.17554 0.005,-4.54488 0.0767,-6.74026 0.0808,-2.47037 0.58761,-4.89522 0.37872,-7.38386 -0.13973,-1.66495 -1.12795,-2.77178 -1.32667,-4.39127 -0.18376,-1.49751 0.63254,-5.63655 0,-6.74026 -0.3973,-0.69326 -1.71445,-0.36851 -2.23282,-0.72027 -0.91319,-0.61968 -1.71622,-1.38785 -2.57435,-2.0818 z"
id="path7" />
<path
style="fill:#00ff00;fill-opacity:1;stroke-width:10;stroke-dasharray:none;filter:url(#filter397)"
d="m 375.33553,121.34324 c 3.39913,22.93503 -2.23867,43.81133 -8.17846,65.50203 -3.10168,11.32658 -4.27915,22.46486 -4.96051,34.11486 -0.32861,5.61878 -0.89162,6.02837 -0.26487,12.41872 0.34464,3.51408 1.85081,7.80185 3.29461,11.01768 1.13398,2.52573 4.32978,4.06396 6.85411,4.73282 14.37217,3.80815 26.65789,-2.23088 33.69898,-15.18127 6.74126,-12.399 4.57229,-24.42084 3.86151,-37.75102 -0.38232,-7.17036 -0.76689,-14.97137 -0.26487,-22.11205 0.6106,-8.68483 5.02068,-16.55987 8.71053,-24.231 2.27978,-4.73962 3.62913,-9.80406 5.52744,-14.69103 1.30437,-3.35796 2.65044,-5.86766 3.82436,-9.39129 1.51609,-4.55069 0.62532,-9.15948 1.17333,-13.78023 0.47889,-4.03804 2.7718,-7.5475 3.82436,-11.39873 1.04624,-3.828179 1.90934,-7.787484 2.87872,-11.661277"
id="path8" />
<path
style="fill:none;fill-opacity:1;stroke:#afafaf;stroke-width:10;stroke-dasharray:none;stroke-opacity:1;filter:url(#filter427)"
d="M 456.91785,381.08869 314.6716,449.29929 199.88458,391.55258 72.03927,445.77735 72.039268,143.494 199.88458,89.269234 314.6716,147.01594 456.91785,78.805342 Z"
id="path2-2"
sodipodi:nodetypes="ccccccccc" />
</g>
<path
id="rect378"
style="fill:#6d6d6d;fill-opacity:0.31908;stroke-width:16.7412"
d="m 200.16234,83.744919 114.47572,57.762111 0,313.26052 -114.47572,-57.8148 z"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

BIN
brand/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

BIN
brand/screenshots/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

BIN
brand/screenshots/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View file

@ -87,6 +87,10 @@ export default defineConfig({
text: "Configuration", text: "Configuration",
collapsed: false, collapsed: false,
items: [ items: [
{
text: "Immich Integration",
link: "/docs/configuration/immich_integration",
},
{ {
text: "Update App", text: "Update App",
link: "/docs/configuration/updating", link: "/docs/configuration/updating",
@ -131,6 +135,10 @@ export default defineConfig({
text: "Changelogs", text: "Changelogs",
collapsed: false, collapsed: false,
items: [ items: [
{
text: "v0.8.0",
link: "/docs/changelogs/v0-8-0",
},
{ {
text: "v0.7.1", text: "v0.7.1",
link: "/docs/changelogs/v0-7-1", link: "/docs/changelogs/v0-7-1",

View file

@ -0,0 +1,105 @@
# AdventureLog v0.8.0 - Immich Integration, Calendar and Customization
Released 01-08-2025
Hi everyone! 🚀
Im thrilled to announce the release of **AdventureLog v0.8.0**, a huge update packed with new features, improvements, and enhancements. This release focuses on delivering a better user experience, improved functionality, and expanded customization options. Lets dive into whats new!
---
## What's New ✨
### Immich Integration
- AdventureLog now integrates seamlessly with [Immich](https://github.com/immich-app), the amazing self-hostable photo library.
- Import your photos from Immich directly into AdventureLog adventures and collections.
- Use Immich Smart Search to search images to import based on natural queries.
- Sort by photo album to easily import your trips photos to an adventure.
### 🚗 Transportation
- **New Transportation Edit Modal**: Includes detailed origin and destination location information for better trip planning.
- **Autocomplete for Airport Codes**: Quickly find and add airport codes while planning transportations.
- **New Transportation Card Design**: Redesigned for better clarity and aesthetics.
---
### 📝 Notes and Checklists
- **New Modals for Notes and Checklists**: Simplified creation and editing of your notes and checklists.
- **Delete Confirmation**: Added a confirmation step when deleting notes, checklists, or transportations to prevent accidental deletions.
---
### 📍Adventures
- **Markdown Editor and Preview**: Write and format adventure descriptions with a markdown editor.
- **Custom Categories**: Organize your adventures with personalized categories and icons.
- **Primary Images**: Adventure images can now be marked as the "primary image" and will be the first one to be displayed in adventure views.
---
### 🗓️ Calendar
- **Calendar View**: View your adventures and transportations in a calendar layout.
- **ICS File Export**: Export your calendar as an ICS file for use with external apps like Google Calendar or Outlook.
---
### 🌐 Localization
- Added support for **Polish** language (@dymek37).
- Improved Swedish language data (@nordtechtiger)
---
### 🔒 Authentication
- **New Authentication System**: Includes MFA for added security.
- **Admin Page Authentication**: Enhanced protection for admin operations.
> [!IMPORTANT]
> Ensure you know your credentials as you will be signed out after updating!
---
### 🖌️ UI & Theming
- **Nord Theme**: A sleek new theme option for a modern and clean interface.
- **New Home Dashboard**: A revamped dashboard experience to access everything you need quickly and view your travel stats.
---
### ⚙️ Settings
- **Overhauled Settings Page**: Redesigned for better navigation and usability.
---
### 🐛 Bug Fixes and Improvements
- Fixed the **NGINX Upload Size Bug**: Upload larger files without issues.
- **Prevents Duplicate Emails**: Improved account management; users can now add multiple emails to a single account.
- General **code cleanliness** for better performance and stability.
- Fixes Django Admin access through Traefik (@PascalBru)
---
### 🌐 Infrastructure
- Added **Kubernetes Configurations** for scalable deployments (@MaximUltimatum).
- Launched a **New [Documentation Site](https://adventurelog.app)** for better guidance and support.
---
## Sponsorship 💖
[![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15)
As always, AdventureLog continues to grow thanks to your incredible support and feedback. If you love using the app and want to help shape its future, consider supporting me on **Buy Me A Coffee**. Your contributions go a long way in allowing for AdventureLog to continue to improve and thrive 😊
---
Enjoy the update! 🎉
Feel free to share your feedback, ideas, or questions in the discussion below or on the official [discord server](https://discord.gg/wRbQ9Egr8C)!
Happy travels,
**Sean Morley** (@seanmorley15)

View file

@ -22,3 +22,13 @@ environment:
- EMAIL_HOST_PASSWORD='password' - EMAIL_HOST_PASSWORD='password'
- DEFAULT_FROM_EMAIL='user@example.com' - DEFAULT_FROM_EMAIL='user@example.com'
``` ```
## Customizing Emails
By default, the email will display `[example.com]` in the subject. You can customize this in the admin site.
1. Go to the admin site (serverurl/admin)
2. Click on `Sites`
3. Click on first site, it will probably be `example.com`
4. Change the `Domain name` and `Display name` to your desired values
5. Click `Save`

View file

@ -0,0 +1,28 @@
# Immich Integration
### What is Immich?
<!-- immich banner -->
![Immich Banner](https://repository-images.githubusercontent.com/455229168/ebba3238-9ef5-4891-ad58-a3b0223b12bd)
Immich is a self-hosted, open-source platform that allows users to backup and manage their photos and videos similar to Google Photos, but with the advantage of storing data on their own private server, ensuring greater privacy and control over their media.
- [Immich Website and Documentation](https://immich.app/)
- [GitHub Repository](https://github.com/immich-app/immich)
### How to integrate Immich with AdventureLog?
To integrate Immich with AdventureLog, you need to have an Immich server running and accessible from where AdventureLog is running.
1. Obtain the Immich API Key from the Immich server.
- In the Immich web interface, click on your user profile picture, go to `Account Settings` > `API Keys`.
- Click `New API Key` and name it something like `AdventureLog`.
- Copy the generated API Key, you will need it in the next step.
2. Go to the AdventureLog web interface, click on your user profile picture, go to `Settings` and scroll down to the `Immich Integration` section.
- Enter the URL of your Immich server, e.g. `https://immich.example.com`. Note that `localhost` or `127.0.0.1` will probably not work because Immich and AdventureLog are running on different docker networks. It is recommended to use the IP address of the server where Immich is running ex `http://my-server-ip:port` or a domain name.
- Paste the API Key you obtained in the previous step.
- Click `Enable Immich` to save the settings.
3. Now, when you are adding images to an adventure, you will see an option to search for images in Immich or upload from an album.
Enjoy the privacy and control of managing your travel media with Immich and AdventureLog! 🎉

View file

@ -1,6 +1,6 @@
{ {
"name": "adventurelog-frontend", "name": "adventurelog-frontend",
"version": "0.7.1", "version": "0.8.0",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"django": "cd .. && cd backend/server && python3 manage.py runserver", "django": "cd .. && cd backend/server && python3 manage.py runserver",

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#fa2921" d="M22.384 14.575c3.143 2.782 5.675 5.764 7.305 8.574 2.8-5.007 4.67-10.957 4.694-14.746V8.33c0-5.607-5.593-7.79-10.411-7.79S13.56 2.723 13.56 8.33v.303c2.686 1.194 5.87 3.327 8.824 5.943"/><path fill="#ed79b5" d="M5.24 29.865c1.965-2.185 4.978-4.554 8.379-6.556 3.618-2.13 7.236-3.617 10.412-4.298-3.897-4.21-8.977-7.827-12.574-9.02l-.07-.023C6.054 8.236 2.25 12.88.762 17.463S-.38 28.039 4.953 29.77z"/><path fill="#ffb400" d="M47.238 17.385c-1.488-4.582-5.292-9.227-10.625-7.494l-.288.093c-.305 2.922-1.35 6.61-2.925 10.229-1.674 3.848-3.728 7.179-5.897 9.597 5.627 1.116 11.863 1.055 15.474-.093l.07-.023c5.333-1.733 5.68-7.727 4.191-12.309"/><path fill="#1e83f7" d="M19.217 34.345c-.907-4.099-1.204-8-.87-11.23-5.208 2.404-10.218 6.118-12.465 9.17l-.043.06c-3.296 4.537-.054 9.59 3.844 12.42 3.898 2.833 9.706 4.355 13.002-.181l.178-.245c-1.471-2.543-2.794-6.142-3.646-9.994"/><path fill="#18c249" d="M42.074 32.052c-2.874.613-6.704.759-10.632.379-4.178-.403-7.98-1.327-10.95-2.643.678 5.695 2.662 11.608 4.87 14.688l.044.06c3.295 4.536 9.103 3.014 13.001.182s7.14-7.885 3.845-12.421z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -191,7 +191,7 @@
<!-- action options dropdown --> <!-- action options dropdown -->
{#if type != 'link'} {#if type != 'link'}
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))} {#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-neutral-200"> <div tabindex="0" role="button" class="btn btn-neutral-200">
<DotsHorizontal class="w-6 h-6" /> <DotsHorizontal class="w-6 h-6" />

View file

@ -13,7 +13,6 @@
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import { deserialize } from '$app/forms'; import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let longitude: number | null = null; export let longitude: number | null = null;
export let latitude: number | null = null; export let latitude: number | null = null;
export let collection: Collection | null = null; export let collection: Collection | null = null;
@ -22,7 +21,7 @@
let query: string = ''; let query: string = '';
let places: OpenStreetMapPlace[] = []; let places: OpenStreetMapPlace[] = [];
let images: { id: string; image: string }[] = []; let images: { id: string; image: string; is_primary: boolean }[] = [];
let warningMessage: string = ''; let warningMessage: string = '';
let constrainDates: boolean = false; let constrainDates: boolean = false;
@ -33,6 +32,10 @@
import CategoryDropdown from './CategoryDropdown.svelte'; import CategoryDropdown from './CategoryDropdown.svelte';
import { findFirstValue } from '$lib'; import { findFirstValue } from '$lib';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import ImmichSelect from './ImmichSelect.svelte';
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
let wikiError: string = ''; let wikiError: string = '';
@ -161,6 +164,33 @@
close(); close();
} }
let willBeMarkedVisited: boolean = false;
$: {
willBeMarkedVisited = false; // Reset before evaluating
const today = new Date(); // Cache today's date to avoid redundant calculations
for (const visit of adventure.visits) {
const startDate = new Date(visit.start_date);
const endDate = visit.end_date ? new Date(visit.end_date) : null;
// If the visit has both a start date and an end date, check if it started by today
if (startDate && endDate && startDate <= today) {
willBeMarkedVisited = true;
break; // Exit the loop since we've determined the result
}
// If the visit has a start date but no end date, check if it started by today
if (startDate && !endDate && startDate <= today) {
willBeMarkedVisited = true;
break; // Exit the loop since we've determined the result
}
}
console.log('WMBV:', willBeMarkedVisited);
}
let previousCoords: { lat: number; lng: number } | null = null; let previousCoords: { lat: number; lng: number } | null = null;
$: if (markers.length > 0) { $: if (markers.length > 0) {
@ -179,28 +209,70 @@
} }
} }
async function fetchImage() { async function makePrimaryImage(image_id: string) {
let res = await fetch(url); let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
let data = await res.blob(); method: 'POST'
if (!data) {
imageError = $t('adventures.no_image_url');
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
}); });
let data2 = await res2.json(); if (res.ok) {
console.log(data2); images = images.map((image) => {
if (data2.type === 'success') { if (image.id === image_id) {
images = [...images, data2]; image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
adventure.images = images; adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
} else { } else {
console.error('Error in makePrimaryImage:', res);
}
}
async function fetchImage() {
try {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
imageError = $t('adventures.no_image_url');
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
});
let data2 = await res2.json();
if (data2.type === 'success') {
console.log('Response Data:', data2);
// Deserialize the nested data
let rawData = JSON.parse(data2.data); // Parse the data field
console.log('Deserialized Data:', rawData);
// Assuming the first object in the array is the new image
let newImage = {
id: rawData[1],
image: rawData[2], // This is the URL for the image
is_primary: false
};
console.log('New Image:', newImage);
// Update images and adventure
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
url = '';
} else {
addToast('error', $t('adventures.image_upload_error'));
}
} catch (error) {
console.error('Error in fetchImage:', error);
addToast('error', $t('adventures.image_upload_error')); addToast('error', $t('adventures.image_upload_error'));
} }
} }
@ -227,7 +299,7 @@
if (res2.ok) { if (res2.ok) {
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } }; let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
console.log(newData); console.log(newData);
let newImage = { id: newData.data.id, image: newData.data.image }; let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
console.log(newImage); console.log(newImage);
images = [...images, newImage]; images = [...images, newImage];
adventure.images = images; adventure.images = images;
@ -337,6 +409,8 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
let immichIntegration: boolean = false;
onMount(async () => { onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal(); modal.showModal();
@ -347,6 +421,16 @@
} else { } else {
addToast('error', $t('adventures.category_fetch_error')); addToast('error', $t('adventures.category_fetch_error'));
} }
// Check for Immich Integration
let res = await fetch('/api/integrations');
if (!res.ok) {
addToast('error', $t('immich.integration_fetch_error'));
} else {
let data = await res.json();
if (data.immich) {
immichIntegration = true;
}
}
}); });
function close() { function close() {
@ -458,6 +542,10 @@
addToast('error', $t('adventures.adventure_update_error')); addToast('error', $t('adventures.adventure_update_error'));
} }
} }
if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
markVisited();
}
imageSearch = adventure.name;
} }
</script> </script>
@ -704,7 +792,12 @@ it would also work to just use on:click on the MapLibre component itself. -->
: $t('adventures.not_visited')} : $t('adventures.not_visited')}
</p> </p>
</div> </div>
{#if !reverseGeocodePlace.is_visited} {#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited}
<button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
{/if}
{#if !reverseGeocodePlace.is_visited && willBeMarkedVisited}
<div role="alert" class="alert alert-info mt-2"> <div role="alert" class="alert alert-info mt-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -720,16 +813,10 @@ it would also work to just use on:click on the MapLibre component itself. -->
></path> ></path>
</svg> </svg>
<span <span
>{$t('adventures.mark_region_as_visited', { >{reverseGeocodePlace.region},
values: { {reverseGeocodePlace.country}
region: reverseGeocodePlace.region, {$t('adventures.will_be_marked')}</span
country: reverseGeocodePlace.country
}
})}</span
> >
<button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
</div> </div>
{/if} {/if}
{/if} {/if}
@ -915,84 +1002,129 @@ it would also work to just use on:click on the MapLibre component itself. -->
</form> </form>
</div> </div>
{:else} {:else}
<p>{$t('adventures.upload_images_here')}</p> <p class="text-lg">{$t('adventures.upload_images_here')}</p>
<!-- <p>{adventureToEdit.id}</p> -->
<div class="mb-2"> <div class="mb-4">
<label for="image">{$t('adventures.image')} </label><br /> <label for="image" class="block font-medium mb-2">
<div class="flex"> {$t('adventures.image')}
<form </label>
method="POST" <form
action="/adventures?/image" method="POST"
use:enhance={imageSubmit} action="/adventures?/image"
enctype="multipart/form-data" use:enhance={imageSubmit}
> enctype="multipart/form-data"
<input class="flex flex-col items-start gap-2"
type="file" >
name="image" <input
class="file-input file-input-bordered w-full max-w-xs" type="file"
bind:this={fileInput} name="image"
accept="image/*" class="file-input file-input-bordered w-full max-w-sm"
id="image" bind:this={fileInput}
/> accept="image/*"
<input type="hidden" name="adventure" value={adventure.id} id="adventure" /> id="image"
<button class="btn btn-neutral mt-2 mb-2" type="submit" />
>{$t('adventures.upload_image')}</button <input type="hidden" name="adventure" value={adventure.id} id="adventure" />
> <button class="btn btn-neutral w-full max-w-sm" type="submit">
</form> {$t('adventures.upload_image')}
</div> </button>
<div class="mt-2"> </form>
<label for="url">{$t('adventures.url')}</label><br /> </div>
<div class="mb-4">
<label for="url" class="block font-medium mb-2">
{$t('adventures.url')}
</label>
<div class="flex gap-2">
<input <input
type="text" type="text"
id="url" id="url"
name="url" name="url"
bind:value={url} bind:value={url}
class="input input-bordered w-full" class="input input-bordered flex-1"
placeholder="Enter image URL"
/> />
<button class="btn btn-neutral mt-2" type="button" on:click={fetchImage} <button class="btn btn-neutral" type="button" on:click={fetchImage}>
>{$t('adventures.fetch_image')}</button {$t('adventures.fetch_image')}
> </button>
</div> </div>
<div class="mt-2"> </div>
<label for="name">{$t('adventures.wikipedia')}</label><br />
<div class="mb-4">
<label for="name" class="block font-medium mb-2">
{$t('adventures.wikipedia')}
</label>
<div class="flex gap-2">
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
bind:value={imageSearch} bind:value={imageSearch}
class="input input-bordered w-full" class="input input-bordered flex-1"
placeholder="Search Wikipedia for images"
/> />
<button class="btn btn-neutral mt-2" type="button" on:click={fetchWikiImage} <button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
>{$t('adventures.fetch_image')}</button {$t('adventures.fetch_image')}
> </button>
</div> </div>
<div class="divider"></div> </div>
{#if images.length > 0}
<h1 class="font-semibold text-xl">{$t('adventures.my_images')}</h1> {#if immichIntegration}
{:else} <ImmichSelect
<h1 class="font-semibold text-xl">{$t('adventures.no_images')}</h1> on:fetchImage={(e) => {
{/if} url = e.detail;
<div class="flex flex-wrap gap-2 mt-2"> fetchImage();
}}
/>
{/if}
<div class="divider"></div>
{#if images.length > 0}
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
<div class="flex flex-wrap gap-4">
{#each images as image} {#each images as image}
<div class="relative h-32 w-32"> <div class="relative h-32 w-32">
<button <button
type="button" type="button"
class="absolute top-0 left-0 btn btn-error btn-sm z-10" class="absolute top-1 right-1 btn btn-error btn-xs z-10"
on:click={() => removeImage(image.id)} on:click={() => removeImage(image.id)}
> >
X
</button> </button>
<img src={image.image} alt={image.id} class="w-full h-full object-cover" /> {#if !image.is_primary}
<button
type="button"
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
on:click={() => makePrimaryImage(image.id)}
>
<Star class="h-4 w-4" />
</button>
{:else}
<!-- crown icon -->
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
<Crown class="h-4 w-4" />
</div>
{/if}
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover rounded-md shadow-md"
/>
</div> </div>
{/each} {/each}
</div> </div>
</div> {:else}
<div class="mt-4"> <h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
<button type="button" class="btn btn-primary" on:click={saveAndClose} {/if}
>{$t('about.close')}</button
> <div class="mt-6">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')}
</button>
</div> </div>
{/if} {/if}
{#if adventure.is_public && adventure.id} {#if adventure.is_public && adventure.id}
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm"> <div class="bg-neutral p-4 mt-2 rounded-md shadow-sm">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p> <p class=" font-semibold">{$t('adventures.share_adventure')}</p>

View file

@ -9,7 +9,11 @@
let image_url: string | null = null; let image_url: string | null = null;
$: adventure_images = adventures.flatMap((adventure) => $: adventure_images = adventures.flatMap((adventure) =>
adventure.images.map((image) => ({ image: image.image, adventure: adventure })) adventure.images.map((image) => ({
image: image.image,
adventure: adventure,
is_primary: image.is_primary
}))
); );
$: { $: {
@ -18,6 +22,19 @@
} }
} }
$: {
// sort so that any image in adventure_images .is_primary is first
adventure_images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
}
function changeSlide(direction: string) { function changeSlide(direction: string) {
if (direction === 'next' && currentSlide < adventure_images.length - 1) { if (direction === 'next' && currentSlide < adventure_images.length - 1) {
currentSlide = currentSlide + 1; currentSlide = currentSlide + 1;

View file

@ -0,0 +1,175 @@
<script lang="ts">
let immichSearchValue: string = '';
let searchOrSelect: string = 'search';
let immichError: string = '';
let immichNext: string = '';
let immichPage: number = 1;
import { createEventDispatcher, onMount } from 'svelte';
const dispatch = createEventDispatcher();
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
$: {
if (currentAlbum) {
immichImages = [];
fetchAlbumAssets(currentAlbum);
} else {
immichImages = [];
}
}
async function fetchAlbumAssets(album_id: string) {
let res = await fetch(`/api/integrations/immich/albums/${album_id}`);
if (res.ok) {
let data = await res.json();
immichNext = '';
immichImages = data;
}
}
onMount(async () => {
let res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
let data = await res.json();
albums = data;
}
});
let immichImages: any[] = [];
import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg';
import type { ImmichAlbum } from '$lib/types';
async function searchImmich() {
let res = await fetch(`/api/integrations/immich/search/?query=${immichSearchValue}`);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.log(errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
console.log(data);
immichError = '';
if (data.results && data.results.length > 0) {
immichImages = data.results;
} else {
immichError = $t('immich.no_items_found');
}
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
} else {
immichNext = '';
}
}
}
async function loadMoreImmich() {
let res = await fetch(immichNext);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.log(errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
console.log(data);
immichError = '';
if (data.results && data.results.length > 0) {
immichImages = [...immichImages, ...data.results];
} else {
immichError = $t('immich.no_items_found');
}
if (data.next) {
immichNext =
'/api/integrations/immich/search?query=' +
immichSearchValue +
'&page=' +
(immichPage + 1);
immichPage++;
} else {
immichNext = '';
}
}
}
</script>
<div class="mb-4">
<label for="immich" class="block font-medium mb-2">
{$t('immich.immich')}
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" />
</label>
<div class="mt-4">
<div class="join">
<input
on:click={() => (currentAlbum = '')}
type="radio"
class="join-item btn"
bind:group={searchOrSelect}
value="search"
aria-label="Search"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchOrSelect}
value="select"
aria-label="Select Album"
/>
</div>
<div>
{#if searchOrSelect === 'search'}
<form on:submit|preventDefault={searchImmich}>
<input
type="text"
placeholder="Type here"
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs"
/>
<button type="submit" class="btn btn-neutral mt-2">Search</button>
</form>
{:else}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
<option value="" disabled selected>Select an Album</option>
{#each albums as album}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
{/if}
</div>
</div>
<p class="text-red-500">{immichError}</p>
<div class="flex flex-wrap gap-4 mr-4 mt-2">
{#each immichImages as image}
<div class="flex flex-col items-center gap-2">
<!-- svelte-ignore a11y-img-redundant-alt -->
<img
src={`/immich/${image.id}`}
alt="Image from Immich"
class="h-24 w-24 object-cover rounded-md"
/>
<button
type="button"
class="btn btn-sm btn-primary"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
dispatch('fetchImage', fullUrl);
}}
>
{$t('adventures.upload_image')}
</button>
</div>
{/each}
{#if immichNext}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
{/if}
</div>
</div>

View file

@ -13,6 +13,18 @@
import { t, locale, locales } from 'svelte-i18n'; import { t, locale, locales } from 'svelte-i18n';
import { themes } from '$lib'; import { themes } from '$lib';
let languages: { [key: string]: string } = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
it: 'Italiano',
nl: 'Nederlands',
sv: 'Svenska',
zh: '中文',
pl: 'Polski'
};
let query: string = ''; let query: string = '';
let isAboutModalOpen: boolean = false; let isAboutModalOpen: boolean = false;
@ -236,8 +248,8 @@
on:change={submitLocaleChange} on:change={submitLocaleChange}
bind:value={$locale} bind:value={$locale}
> >
{#each $locales as loc} {#each $locales as loc (loc)}
<option value={loc} class="text-base-content">{$t(`languages.${loc}`)}</option> <option value={loc} class="text-base-content">{languages[loc]}</option>
{/each} {/each}
</select> </select>
<input type="hidden" name="locale" value={$locale} /> <input type="hidden" name="locale" value={$locale} />

View file

@ -188,7 +188,7 @@
<div> <div>
<label for="content">{$t('notes.content')}</label><br /> <label for="content">{$t('notes.content')}</label><br />
{#if !isReadOnly} {#if !isReadOnly}
<MarkdownEditor bind:text={newNote.content} editor_height={'h-32'} /> <MarkdownEditor bind:text={newNote.content} editor_height={'h-72'} />
{:else if note} {:else if note}
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p> <p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
<article <article

View file

@ -1,4 +1,4 @@
export let appVersion = 'v0.7.1'; export let appVersion = 'v0.8.0';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.7.1'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.8.0';
export let appTitle = 'AdventureLog'; export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025'; export let copyrightYear = '2023-2025';

View file

@ -296,7 +296,7 @@ export function getRandomBackground() {
const newYearsStart = new Date(today.getFullYear() - 1, 11, 31); const newYearsStart = new Date(today.getFullYear() - 1, 11, 31);
newYearsStart.setHours(0, 0, 0, 0); newYearsStart.setHours(0, 0, 0, 0);
const newYearsEnd = new Date(today.getFullYear(), 0, 7); const newYearsEnd = new Date(today.getFullYear(), 0, 2);
newYearsEnd.setHours(23, 59, 59, 999); newYearsEnd.setHours(23, 59, 59, 999);
if (today >= newYearsStart && today <= newYearsEnd) { if (today >= newYearsStart && today <= newYearsEnd) {
return { return {

View file

@ -24,6 +24,7 @@ export type Adventure = {
images: { images: {
id: string; id: string;
image: string; image: string;
is_primary: boolean;
}[]; }[];
visits: { visits: {
id: string; id: string;
@ -50,6 +51,8 @@ export type Country = {
capital: string; capital: string;
num_regions: number; num_regions: number;
num_visits: number; num_visits: number;
longitude: number | null;
latitude: number | null;
}; };
export type Region = { export type Region = {
@ -194,3 +197,37 @@ export type Category = {
user_id: string; user_id: string;
num_adventures?: number | null; num_adventures?: number | null;
}; };
export type ImmichIntegration = {
id: string;
server_url: string;
api_key: string;
};
export type ImmichAlbum = {
albumName: string;
description: string;
albumThumbnailAssetId: string;
createdAt: string;
updatedAt: string;
id: string;
ownerId: string;
owner: {
id: string;
email: string;
name: string;
profileImagePath: string;
avatarColor: string;
profileChangedAt: string;
};
albumUsers: any[];
shared: boolean;
hasSharedLink: boolean;
startDate: string;
endDate: string;
assets: any[];
assetCount: number;
isActivityEnabled: boolean;
order: string;
lastModifiedAssetTimestamp: string;
};

View file

@ -215,7 +215,9 @@
"start": "Start", "start": "Start",
"starting_airport": "Startflughafen", "starting_airport": "Startflughafen",
"to": "Zu", "to": "Zu",
"transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden." "transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.",
"show_map": "Karte anzeigen",
"will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert ist."
}, },
"home": { "home": {
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",
@ -454,17 +456,7 @@
"show_visited_regions": "Besuchte Regionen anzeigen", "show_visited_regions": "Besuchte Regionen anzeigen",
"view_details": "Details anzeigen" "view_details": "Details anzeigen"
}, },
"languages": { "languages": {},
"de": "Deutsch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch",
"it": "Italienisch",
"nl": "Niederländisch",
"sv": "Schwedisch",
"zh": "chinesisch",
"pl": "Polnisch"
},
"share": { "share": {
"no_users_shared": "Keine Benutzer geteilt mit", "no_users_shared": "Keine Benutzer geteilt mit",
"not_shared_with": "Nicht geteilt mit", "not_shared_with": "Nicht geteilt mit",
@ -500,5 +492,30 @@
"total_adventures": "Totale Abenteuer", "total_adventures": "Totale Abenteuer",
"total_visited_regions": "Insgesamt besuchte Regionen", "total_visited_regions": "Insgesamt besuchte Regionen",
"welcome_back": "Willkommen zurück" "welcome_back": "Willkommen zurück"
},
"immich": {
"api_key": "Immich-API-Schlüssel",
"api_note": "Hinweis: Dies muss die URL zum Immich-API-Server sein, daher endet sie wahrscheinlich mit /api, es sei denn, Sie haben eine benutzerdefinierte Konfiguration.",
"disable": "Deaktivieren",
"enable_immich": "Immich aktivieren",
"imageid_required": "Bild-ID ist erforderlich",
"immich": "Immich",
"immich_desc": "Integrieren Sie Ihr Immich-Konto mit AdventureLog, damit Sie Ihre Fotobibliothek durchsuchen und Fotos für Ihre Abenteuer importieren können.",
"immich_disabled": "Immich-Integration erfolgreich deaktiviert!",
"immich_enabled": "Immich-Integration erfolgreich aktiviert!",
"immich_error": "Fehler beim Aktualisieren der Immich-Integration",
"immich_updated": "Immich-Einstellungen erfolgreich aktualisiert!",
"integration_enabled": "Integration aktiviert",
"integration_fetch_error": "Fehler beim Abrufen der Daten aus der Immich-Integration",
"integration_missing": "Im Backend fehlt die Immich-Integration",
"load_more": "Mehr laden",
"no_items_found": "Keine Artikel gefunden",
"query_required": "Abfrage ist erforderlich",
"server_down": "Der Immich-Server ist derzeit ausgefallen oder nicht erreichbar",
"server_url": "Immich-Server-URL",
"update_integration": "Update-Integration",
"immich_integration": "Immich-Integration",
"documentation": "Immich-Integrationsdokumentation",
"localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden."
} }
} }

View file

@ -229,8 +229,10 @@
"no_location_found": "No location found", "no_location_found": "No location found",
"from": "From", "from": "From",
"to": "To", "to": "To",
"will_be_marked": "will be marked as visited once the adventure is saved.",
"start": "Start", "start": "Start",
"end": "End", "end": "End",
"show_map": "Show Map",
"emoji_picker": "Emoji Picker", "emoji_picker": "Emoji Picker",
"download_calendar": "Download Calendar", "download_calendar": "Download Calendar",
"date_information": "Date Information", "date_information": "Date Information",
@ -466,17 +468,7 @@
"set_public": "In order to allow users to share with you, you need your profile set to public.", "set_public": "In order to allow users to share with you, you need your profile set to public.",
"go_to_settings": "Go to settings" "go_to_settings": "Go to settings"
}, },
"languages": { "languages": {},
"en": "English",
"de": "German",
"es": "Spanish",
"fr": "French",
"it": "Italian",
"nl": "Dutch",
"sv": "Swedish",
"zh": "Chinese",
"pl": "Polish"
},
"profile": { "profile": {
"member_since": "Member since", "member_since": "Member since",
"user_stats": "User Stats", "user_stats": "User Stats",
@ -500,5 +492,30 @@
"recent_adventures": "Recent Adventures", "recent_adventures": "Recent Adventures",
"no_recent_adventures": "No recent adventures?", "no_recent_adventures": "No recent adventures?",
"add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below." "add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below."
},
"immich": {
"immich": "Immich",
"integration_fetch_error": "Error fetching data from the Immich integration",
"integration_missing": "The Immich integration is missing from the backend",
"query_required": "Query is required",
"server_down": "The Immich server is currently down or unreachable",
"no_items_found": "No items found",
"imageid_required": "Image ID is required",
"load_more": "Load More",
"immich_updated": "Immich settings updated successfully!",
"immich_enabled": "Immich integration enabled successfully!",
"immich_error": "Error updating Immich integration",
"immich_disabled": "Immich integration disabled successfully!",
"immich_desc": "Integrate your Immich account with AdventureLog to allow you to search your photos library and import photos for your adventures.",
"integration_enabled": "Integration Enabled",
"disable": "Disable",
"server_url": "Immich Server URL",
"api_note": "Note: this must be the URL to the Immich API server so it likely ends with /api unless you have a custom config.",
"api_key": "Immich API Key",
"enable_immich": "Enable Immich",
"update_integration": "Update Integration",
"immich_integration": "Immich Integration",
"localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.",
"documentation": "Immich Integration Documentation"
} }
} }

View file

@ -262,7 +262,9 @@
"start": "Comenzar", "start": "Comenzar",
"starting_airport": "Aeropuerto de inicio", "starting_airport": "Aeropuerto de inicio",
"to": "A", "to": "A",
"transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer." "transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.",
"show_map": "Mostrar mapa",
"will_be_marked": "se marcará como visitado una vez guardada la aventura."
}, },
"worldtravel": { "worldtravel": {
"all": "Todo", "all": "Todo",
@ -466,17 +468,7 @@
"no_shared_found": "No se encontraron colecciones que se compartan contigo.", "no_shared_found": "No se encontraron colecciones que se compartan contigo.",
"set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público." "set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público."
}, },
"languages": { "languages": {},
"de": "Alemán",
"en": "Inglés",
"es": "Español",
"fr": "Francés",
"it": "italiano",
"nl": "Holandés",
"sv": "sueco",
"zh": "Chino",
"pl": "Polaco"
},
"profile": { "profile": {
"member_since": "Miembro desde", "member_since": "Miembro desde",
"user_stats": "Estadísticas de usuario", "user_stats": "Estadísticas de usuario",
@ -500,5 +492,30 @@
"total_adventures": "Aventuras totales", "total_adventures": "Aventuras totales",
"total_visited_regions": "Total de regiones visitadas", "total_visited_regions": "Total de regiones visitadas",
"welcome_back": "Bienvenido de nuevo" "welcome_back": "Bienvenido de nuevo"
},
"immich": {
"api_key": "Clave API de Immich",
"api_note": "Nota: esta debe ser la URL del servidor API de Immich, por lo que probablemente termine con /api a menos que tenga una configuración personalizada.",
"disable": "Desactivar",
"enable_immich": "Habilitar Immich",
"imageid_required": "Se requiere identificación con imagen",
"immich": "immicha",
"immich_desc": "Integre su cuenta de Immich con AdventureLog para permitirle buscar en su biblioteca de fotos e importar fotos para sus aventuras.",
"immich_disabled": "¡La integración de Immich se deshabilitó exitosamente!",
"immich_enabled": "¡La integración de Immich se habilitó exitosamente!",
"immich_error": "Error al actualizar la integración de Immich",
"immich_updated": "¡La configuración de Immich se actualizó exitosamente!",
"integration_enabled": "Integración habilitada",
"integration_fetch_error": "Error al obtener datos de la integración de Immich",
"integration_missing": "Falta la integración de Immich en el backend",
"load_more": "Cargar más",
"no_items_found": "No se encontraron artículos",
"query_required": "Se requiere consulta",
"server_down": "El servidor Immich está actualmente inactivo o inaccesible",
"server_url": "URL del servidor Immich",
"update_integration": "Integración de actualización",
"immich_integration": "Integración Immich",
"documentation": "Documentación de integración de Immich",
"localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio."
} }
} }

View file

@ -215,7 +215,9 @@
"start": "Commencer", "start": "Commencer",
"starting_airport": "Aéroport de départ", "starting_airport": "Aéroport de départ",
"to": "À", "to": "À",
"transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée." "transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.",
"show_map": "Afficher la carte",
"will_be_marked": "sera marqué comme visité une fois laventure sauvegardée."
}, },
"home": { "home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité", "desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -454,17 +456,7 @@
"show_visited_regions": "Afficher les régions visitées", "show_visited_regions": "Afficher les régions visitées",
"view_details": "Afficher les détails" "view_details": "Afficher les détails"
}, },
"languages": { "languages": {},
"de": "Allemand",
"en": "Anglais",
"es": "Espagnol",
"fr": "Français",
"it": "italien",
"nl": "Néerlandais",
"sv": "suédois",
"zh": "Chinois",
"pl": "Polonais"
},
"share": { "share": {
"no_users_shared": "Aucun utilisateur partagé avec", "no_users_shared": "Aucun utilisateur partagé avec",
"not_shared_with": "Non partagé avec", "not_shared_with": "Non partagé avec",
@ -500,5 +492,30 @@
"total_adventures": "Aventures totales", "total_adventures": "Aventures totales",
"total_visited_regions": "Total des régions visitées", "total_visited_regions": "Total des régions visitées",
"welcome_back": "Content de te revoir" "welcome_back": "Content de te revoir"
},
"immich": {
"api_key": "Clé API Immich",
"api_note": "Remarque : il doit s'agir de l'URL du serveur API Immich, elle se termine donc probablement par /api, sauf si vous disposez d'une configuration personnalisée.",
"disable": "Désactiver",
"enable_immich": "Activer Immich",
"imageid_required": "L'identifiant de l'image est requis",
"immich": "Immich",
"immich_desc": "Intégrez votre compte Immich à AdventureLog pour vous permettre de rechercher dans votre bibliothèque de photos et d'importer des photos pour vos aventures.",
"immich_disabled": "Intégration Immich désactivée avec succès !",
"immich_enabled": "Intégration Immich activée avec succès !",
"immich_error": "Erreur lors de la mise à jour de l'intégration Immich",
"immich_integration": "Intégration Immich",
"immich_updated": "Paramètres Immich mis à jour avec succès !",
"integration_enabled": "Intégration activée",
"integration_fetch_error": "Erreur lors de la récupération des données de l'intégration Immich",
"integration_missing": "L'intégration Immich est absente du backend",
"load_more": "Charger plus",
"no_items_found": "Aucun article trouvé",
"query_required": "La requête est obligatoire",
"server_down": "Le serveur Immich est actuellement en panne ou inaccessible",
"server_url": "URL du serveur Immich",
"update_integration": "Intégration des mises à jour",
"documentation": "Documentation d'intégration Immich",
"localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine."
} }
} }

View file

@ -215,7 +215,9 @@
"start": "Inizio", "start": "Inizio",
"starting_airport": "Inizio aeroporto", "starting_airport": "Inizio aeroporto",
"to": "A", "to": "A",
"transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata." "transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.",
"show_map": "Mostra mappa",
"will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura."
}, },
"home": { "home": {
"desc_1": "Scopri, pianifica ed esplora con facilità", "desc_1": "Scopri, pianifica ed esplora con facilità",
@ -454,17 +456,7 @@
"show_visited_regions": "Mostra regioni visitate", "show_visited_regions": "Mostra regioni visitate",
"view_details": "Visualizza dettagli" "view_details": "Visualizza dettagli"
}, },
"languages": { "languages": {},
"de": "tedesco",
"en": "Inglese",
"es": "spagnolo",
"fr": "francese",
"it": "Italiano",
"nl": "Olandese",
"sv": "svedese",
"zh": "cinese",
"pl": "Polacco"
},
"share": { "share": {
"no_users_shared": "Nessun utente condiviso con", "no_users_shared": "Nessun utente condiviso con",
"not_shared_with": "Non condiviso con", "not_shared_with": "Non condiviso con",
@ -500,5 +492,30 @@
"total_adventures": "Avventure totali", "total_adventures": "Avventure totali",
"total_visited_regions": "Totale regioni visitate", "total_visited_regions": "Totale regioni visitate",
"welcome_back": "Bentornato" "welcome_back": "Bentornato"
},
"immich": {
"api_key": "Chiave API Immich",
"api_note": "Nota: questo deve essere l'URL del server API Immich, quindi probabilmente termina con /api a meno che tu non abbia una configurazione personalizzata.",
"disable": "Disabilita",
"enable_immich": "Abilita Immich",
"imageid_required": "L'ID immagine è obbligatorio",
"immich": "Immich",
"immich_desc": "Integra il tuo account Immich con AdventureLog per consentirti di cercare nella tua libreria di foto e importare foto per le tue avventure.",
"immich_disabled": "Integrazione Immich disabilitata con successo!",
"immich_enabled": "Integrazione Immich abilitata con successo!",
"immich_error": "Errore durante l'aggiornamento dell'integrazione Immich",
"immich_integration": "Integrazione di Immich",
"immich_updated": "Impostazioni Immich aggiornate con successo!",
"integration_enabled": "Integrazione abilitata",
"integration_fetch_error": "Errore durante il recupero dei dati dall'integrazione Immich",
"integration_missing": "L'integrazione Immich manca dal backend",
"load_more": "Carica altro",
"no_items_found": "Nessun articolo trovato",
"query_required": "La domanda è obbligatoria",
"server_down": "Il server Immich è attualmente inattivo o irraggiungibile",
"server_url": "URL del server Immich",
"update_integration": "Aggiorna integrazione",
"documentation": "Documentazione sull'integrazione di Immich",
"localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio."
} }
} }

View file

@ -215,7 +215,9 @@
"starting_airport": "Startende luchthaven", "starting_airport": "Startende luchthaven",
"to": "Naar", "to": "Naar",
"transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.", "transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.",
"ending_airport": "Einde luchthaven" "ending_airport": "Einde luchthaven",
"show_map": "Toon kaart",
"will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen."
}, },
"home": { "home": {
"desc_1": "Ontdek, plan en verken met gemak", "desc_1": "Ontdek, plan en verken met gemak",
@ -454,17 +456,7 @@
"show_visited_regions": "Toon bezochte regio's", "show_visited_regions": "Toon bezochte regio's",
"view_details": "Details bekijken" "view_details": "Details bekijken"
}, },
"languages": { "languages": {},
"de": "Duits",
"en": "Engels",
"es": "Spaans",
"fr": "Frans",
"it": "Italiaans",
"nl": "Nederlands",
"sv": "Zweeds",
"zh": "Chinese",
"pl": "Pools"
},
"share": { "share": {
"no_users_shared": "Er zijn geen gebruikers gedeeld", "no_users_shared": "Er zijn geen gebruikers gedeeld",
"not_shared_with": "Niet gedeeld met", "not_shared_with": "Niet gedeeld met",
@ -500,5 +492,30 @@
"total_adventures": "Totale avonturen", "total_adventures": "Totale avonturen",
"total_visited_regions": "Totaal bezochte regio's", "total_visited_regions": "Totaal bezochte regio's",
"welcome_back": "Welkom terug" "welcome_back": "Welkom terug"
},
"immich": {
"api_key": "Immich API-sleutel",
"api_note": "Let op: dit moet de URL naar de Immich API-server zijn, dus deze eindigt waarschijnlijk op /api, tenzij je een aangepaste configuratie hebt.",
"disable": "Uitzetten",
"enable_immich": "Schakel Immich in",
"imageid_required": "Afbeeldings-ID is vereist",
"immich": "Immich",
"immich_desc": "Integreer uw Immich-account met AdventureLog zodat u in uw fotobibliotheek kunt zoeken en foto's voor uw avonturen kunt importeren.",
"immich_disabled": "Immich-integratie succesvol uitgeschakeld!",
"immich_enabled": "Immich-integratie succesvol ingeschakeld!",
"immich_error": "Fout bij updaten van Immich-integratie",
"immich_integration": "Immich-integratie",
"immich_updated": "Immich-instellingen zijn succesvol bijgewerkt!",
"integration_enabled": "Integratie ingeschakeld",
"integration_fetch_error": "Fout bij het ophalen van gegevens uit de Immich-integratie",
"integration_missing": "De Immich-integratie ontbreekt in de backend",
"load_more": "Laad meer",
"no_items_found": "Geen artikelen gevonden",
"query_required": "Er is een zoekopdracht vereist",
"server_down": "De Immich-server is momenteel offline of onbereikbaar",
"server_url": "Immich-server-URL",
"update_integration": "Integratie bijwerken",
"documentation": "Immich-integratiedocumentatie",
"localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij u de docker-netwerken dienovereenkomstig hebt ingesteld. \nHet wordt aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken."
} }
} }

View file

@ -262,7 +262,9 @@
"start": "Start", "start": "Start",
"starting_airport": "Początkowe lotnisko", "starting_airport": "Początkowe lotnisko",
"to": "Do", "to": "Do",
"transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć." "transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.",
"show_map": "Pokaż mapę",
"will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody."
}, },
"worldtravel": { "worldtravel": {
"country_list": "Lista krajów", "country_list": "Lista krajów",
@ -466,17 +468,7 @@
"set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.", "set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.",
"go_to_settings": "Przejdź do ustawień" "go_to_settings": "Przejdź do ustawień"
}, },
"languages": { "languages": {},
"en": "Angielski",
"de": "Niemiecki",
"es": "Hiszpański",
"fr": "Francuski",
"it": "Włoski",
"nl": "Holenderski",
"sv": "Szwedzki",
"zh": "Chiński",
"pl": "Polski"
},
"profile": { "profile": {
"member_since": "Użytkownik od", "member_since": "Użytkownik od",
"user_stats": "Statystyki użytkownika", "user_stats": "Statystyki użytkownika",
@ -500,5 +492,30 @@
"total_adventures": "Totalne przygody", "total_adventures": "Totalne przygody",
"total_visited_regions": "Łączna liczba odwiedzonych regionów", "total_visited_regions": "Łączna liczba odwiedzonych regionów",
"welcome_back": "Witamy z powrotem" "welcome_back": "Witamy z powrotem"
},
"immich": {
"api_key": "Klucz API Immicha",
"api_note": "Uwaga: musi to być adres URL serwera API Immich, więc prawdopodobnie kończy się na /api, chyba że masz niestandardową konfigurację.",
"disable": "Wyłączyć",
"enable_immich": "Włącz Immicha",
"immich": "Immich",
"immich_enabled": "Integracja z Immich została pomyślnie włączona!",
"immich_error": "Błąd podczas aktualizacji integracji Immich",
"immich_integration": "Integracja Immicha",
"immich_updated": "Ustawienia Immich zostały pomyślnie zaktualizowane!",
"integration_enabled": "Integracja włączona",
"integration_fetch_error": "Błąd podczas pobierania danych z integracji Immich",
"integration_missing": "W backendie brakuje integracji z Immich",
"load_more": "Załaduj więcej",
"no_items_found": "Nie znaleziono żadnych elementów",
"query_required": "Zapytanie jest wymagane",
"server_down": "Serwer Immich jest obecnie wyłączony lub nieosiągalny",
"server_url": "Adres URL serwera Immich",
"update_integration": "Zaktualizuj integrację",
"imageid_required": "Wymagany jest identyfikator obrazu",
"immich_desc": "Zintegruj swoje konto Immich z AdventureLog, aby móc przeszukiwać bibliotekę zdjęć i importować zdjęcia do swoich przygód.",
"immich_disabled": "Integracja z Immich została pomyślnie wyłączona!",
"documentation": "Dokumentacja integracji Immicha",
"localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny."
} }
} }

View file

@ -1,5 +1,4 @@
{ {
"about": { "about": {
"about": "Om", "about": "Om",
"close": "Stäng", "close": "Stäng",
@ -216,7 +215,9 @@
"start": "Start", "start": "Start",
"starting_airport": "Startar flygplats", "starting_airport": "Startar flygplats",
"to": "Till", "to": "Till",
"transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras." "transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.",
"show_map": "Visa karta",
"will_be_marked": "kommer att markeras som besökt när äventyret har sparats."
}, },
"home": { "home": {
"desc_1": "Upptäck, planera och utforska med lätthet", "desc_1": "Upptäck, planera och utforska med lätthet",
@ -455,17 +456,7 @@
"show_visited_regions": "Visa besökta regioner", "show_visited_regions": "Visa besökta regioner",
"view_details": "Visa detaljer" "view_details": "Visa detaljer"
}, },
"languages": { "languages": {},
"de": "tyska",
"en": "engelska",
"es": "spanska",
"fr": "franska",
"it": "italienska",
"nl": "holländska",
"sv": "svenska",
"zh": "kinesiska",
"pl": "polska"
},
"share": { "share": {
"no_users_shared": "Inga användare delas med", "no_users_shared": "Inga användare delas med",
"not_shared_with": "Inte delad med", "not_shared_with": "Inte delad med",
@ -501,5 +492,30 @@
"total_adventures": "Totala äventyr", "total_adventures": "Totala äventyr",
"total_visited_regions": "Totalt antal besökta regioner", "total_visited_regions": "Totalt antal besökta regioner",
"welcome_back": "Välkommen tillbaka" "welcome_back": "Välkommen tillbaka"
},
"immich": {
"api_key": "Immich API-nyckel",
"api_note": "Obs: detta måste vara URL:en till Immich API-servern så den slutar troligen med /api om du inte har en anpassad konfiguration.",
"disable": "Inaktivera",
"enable_immich": "Aktivera Immich",
"imageid_required": "Bild-ID krävs",
"immich": "Immich",
"immich_desc": "Integrera ditt Immich-konto med AdventureLog så att du kan söka i ditt fotobibliotek och importera bilder för dina äventyr.",
"immich_disabled": "Immich-integrationen inaktiverades framgångsrikt!",
"immich_enabled": "Immich-integrationen har aktiverats framgångsrikt!",
"immich_error": "Fel vid uppdatering av Immich-integration",
"immich_integration": "Immich Integration",
"immich_updated": "Immich-inställningarna har uppdaterats framgångsrikt!",
"integration_enabled": "Integration aktiverad",
"integration_fetch_error": "Fel vid hämtning av data från Immich-integrationen",
"integration_missing": "Immich-integrationen saknas i backend",
"load_more": "Ladda mer",
"no_items_found": "Inga föremål hittades",
"query_required": "Fråga krävs",
"server_down": "Immich-servern är för närvarande nere eller kan inte nås",
"server_url": "Immich Server URL",
"update_integration": "Uppdatera integration",
"documentation": "Immich Integrationsdokumentation",
"localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet."
} }
} }

View file

@ -215,7 +215,9 @@
"start": "开始", "start": "开始",
"starting_airport": "出发机场", "starting_airport": "出发机场",
"to": "到", "to": "到",
"transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。" "transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。",
"show_map": "显示地图",
"will_be_marked": "保存冒险后将被标记为已访问。"
}, },
"home": { "home": {
"desc_1": "轻松发现、规划和探索", "desc_1": "轻松发现、规划和探索",
@ -454,17 +456,7 @@
"show_visited_regions": "显示访问过的地区", "show_visited_regions": "显示访问过的地区",
"view_details": "查看详情" "view_details": "查看详情"
}, },
"languages": { "languages": {},
"de": "德语",
"en": "英语",
"es": "西班牙语",
"fr": "法语",
"it": "意大利语",
"nl": "荷兰语",
"sv": "瑞典",
"zh": "中国人",
"pl": "波兰语"
},
"share": { "share": {
"no_users_shared": "没有与之共享的用户", "no_users_shared": "没有与之共享的用户",
"not_shared_with": "不与共享", "not_shared_with": "不与共享",
@ -500,5 +492,30 @@
"total_adventures": "全面冒险", "total_adventures": "全面冒险",
"total_visited_regions": "总访问地区", "total_visited_regions": "总访问地区",
"welcome_back": "欢迎回来" "welcome_back": "欢迎回来"
},
"immich": {
"api_key": "伊米奇 API 密钥",
"api_note": "注意:这必须是 Immich API 服务器的 URL因此它可能以 /api 结尾,除非您有自定义配置。",
"disable": "禁用",
"enable_immich": "启用伊米奇",
"imageid_required": "需要图像 ID",
"immich": "伊米奇",
"immich_desc": "将您的 Immich 帐户与 AdventureLog 集成,以便您搜索照片库并导入冒险照片。",
"immich_disabled": "Immich 集成成功禁用!",
"immich_enabled": "Immich 集成成功启用!",
"immich_error": "更新 Immich 集成时出错",
"immich_integration": "伊米奇整合",
"immich_updated": "Immich 设置更新成功!",
"integration_enabled": "启用集成",
"integration_fetch_error": "从 Immich 集成获取数据时出错",
"integration_missing": "后端缺少 Immich 集成",
"load_more": "加载更多",
"no_items_found": "没有找到物品",
"query_required": "需要查询",
"server_down": "Immich 服务器当前已关闭或无法访问",
"server_url": "伊米奇服务器网址",
"update_integration": "更新集成",
"documentation": "Immich 集成文档",
"localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。"
} }
} }

View file

@ -34,6 +34,16 @@
onMount(() => { onMount(() => {
if (data.props.adventure) { if (data.props.adventure) {
adventure = data.props.adventure; adventure = data.props.adventure;
// sort so that any image in adventure_images .is_primary is first
adventure.images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
} else { } else {
notFound = true; notFound = true;
} }

View file

@ -0,0 +1,54 @@
import type { RequestHandler } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const GET: RequestHandler = async (event) => {
try {
const key = event.params.key;
// Forward the session ID from cookies
const sessionid = event.cookies.get('sessionid');
if (!sessionid) {
return new Response(JSON.stringify({ error: 'Session ID is missing' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Proxy the request to the backend
const res = await fetch(`${endpoint}/api/integrations/immich/get/${key}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Cookie: `sessionid=${sessionid}`
}
});
if (!res.ok) {
// Return an error response if the backend request fails
const errorData = await res.json();
return new Response(JSON.stringify(errorData), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
// Get the image as a Blob
const image = await res.blob();
// Create a Response to pass the image back
return new Response(image, {
status: res.status,
headers: {
'Content-Type': res.headers.get('Content-Type') || 'image/jpeg'
}
});
} catch (error) {
console.error('Error proxying request:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch image' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View file

@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit'; import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types'; import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { User } from '$lib/types'; import type { ImmichIntegration, User } from '$lib/types';
import { fetchCSRFToken } from '$lib/index.server'; import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@ -56,11 +56,22 @@ export const load: PageServerLoad = async (event) => {
let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse; let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse;
let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean; let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean;
let immichIntegration: ImmichIntegration | null = null;
let immichIntegrationsFetch = await fetch(`${endpoint}/api/integrations/immich/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (immichIntegrationsFetch.ok) {
immichIntegration = await immichIntegrationsFetch.json();
}
return { return {
props: { props: {
user, user,
emails, emails,
authenticators authenticators,
immichIntegration
} }
}; };
}; };

View file

@ -2,14 +2,16 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import type { User } from '$lib/types.js'; import type { ImmichIntegration, User } from '$lib/types.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import TotpModal from '$lib/components/TOTPModal.svelte'; import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js'; import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg';
export let data; export let data;
console.log(data);
let user: User; let user: User;
let emails: typeof data.props.emails; let emails: typeof data.props.emails;
if (data.user) { if (data.user) {
@ -19,6 +21,14 @@
let new_email: string = ''; let new_email: string = '';
let immichIntegration = data.props.immichIntegration;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
id: ''
};
let isMFAModalOpen: boolean = false; let isMFAModalOpen: boolean = false;
onMount(async () => { onMount(async () => {
@ -131,6 +141,54 @@
} }
} }
async function enableImmichIntegration() {
if (!immichIntegration?.id) {
let res = await fetch('/api/integrations/immich/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newImmichIntegration)
});
let data = await res.json();
if (res.ok) {
addToast('success', $t('immich.immich_enabled'));
immichIntegration = data;
} else {
addToast('error', $t('immich.immich_error'));
}
} else {
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newImmichIntegration)
});
let data = await res.json();
if (res.ok) {
addToast('success', $t('immich.immich_updated'));
immichIntegration = data;
} else {
addToast('error', $t('immich.immich_error'));
}
}
}
async function disableImmichIntegration() {
if (immichIntegration && immichIntegration.id) {
let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, {
method: 'DELETE'
});
if (res.ok) {
addToast('success', $t('immich.immich_disabled'));
immichIntegration = null;
} else {
addToast('error', $t('immich.immich_error'));
}
}
}
async function disableMfa() { async function disableMfa() {
const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', {
method: 'DELETE' method: 'DELETE'
@ -174,7 +232,9 @@
class="space-y-6" class="space-y-6"
> >
<div> <div>
<label for="username" class="text-sm font-medium">{$t('auth.username')}</label> <label for="username" class="text-sm font-medium text-neutral-content"
>{$t('auth.username')}</label
>
<input <input
type="text" type="text"
id="username" id="username"
@ -185,7 +245,9 @@
</div> </div>
<div> <div>
<label for="first_name" class="text-sm font-medium">{$t('auth.first_name')}</label> <label for="first_name" class="text-sm font-medium text-neutral-content"
>{$t('auth.first_name')}</label
>
<input <input
type="text" type="text"
id="first_name" id="first_name"
@ -196,7 +258,9 @@
</div> </div>
<div> <div>
<label for="last_name" class="text-sm font-medium">{$t('auth.last_name')}</label> <label for="last_name" class="text-sm font-medium text-neutral-content"
>{$t('auth.last_name')}</label
>
<input <input
type="text" type="text"
id="last_name" id="last_name"
@ -207,7 +271,9 @@
</div> </div>
<div> <div>
<label for="profile_pic" class="text-sm font-medium">{$t('auth.profile_picture')}</label> <label for="profile_pic" class="text-sm font-medium text-neutral-content"
>{$t('auth.profile_picture')}</label
>
<input <input
type="file" type="file"
id="profile_pic" id="profile_pic"
@ -224,7 +290,9 @@
bind:checked={user.public_profile} bind:checked={user.public_profile}
class="toggle toggle-primary" class="toggle toggle-primary"
/> />
<label for="public_profile" class="ml-2 text-sm">{$t('auth.public_profile')}</label> <label for="public_profile" class="ml-2 text-sm text-neutral-content"
>{$t('auth.public_profile')}</label
>
</div> </div>
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.update')}</button> <button class="w-full mt-4 btn btn-primary py-2">{$t('settings.update')}</button>
@ -240,7 +308,7 @@
<div class="bg-neutral p-6 rounded-lg shadow-md"> <div class="bg-neutral p-6 rounded-lg shadow-md">
<form method="post" action="?/changePassword" use:enhance class="space-y-6"> <form method="post" action="?/changePassword" use:enhance class="space-y-6">
<div> <div>
<label for="current_password" class="text-sm font-medium" <label for="current_password" class="text-sm font-medium text-neutral-content"
>{$t('settings.current_password')}</label >{$t('settings.current_password')}</label
> >
<input <input
@ -252,7 +320,9 @@
</div> </div>
<div> <div>
<label for="password1" class="text-sm font-medium">{$t('settings.new_password')}</label> <label for="password1" class="text-sm font-medium text-neutral-content"
>{$t('settings.new_password')}</label
>
<input <input
type="password" type="password"
id="password1" id="password1"
@ -262,7 +332,7 @@
</div> </div>
<div> <div>
<label for="password2" class="text-sm font-medium" <label for="password2" class="text-sm font-medium text-neutral-content"
>{$t('settings.confirm_new_password')}</label >{$t('settings.confirm_new_password')}</label
> >
<input <input
@ -317,7 +387,7 @@
</div> </div>
{/each} {/each}
{#if emails.length === 0} {#if emails.length === 0}
<p class="text-center">{$t('settings.no_email_set')}</p> <p class="text-center text-neutral-content">{$t('settings.no_email_set')}</p>
{/if} {/if}
</div> </div>
@ -342,7 +412,7 @@
</h2> </h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center"> <div class="bg-neutral p-6 rounded-lg shadow-md text-center">
{#if !data.props.authenticators} {#if !data.props.authenticators}
<p>{$t('settings.mfa_not_enabled')}</p> <p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
<button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)} <button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)}
>{$t('settings.enable_mfa')}</button >{$t('settings.enable_mfa')}</button
> >
@ -354,6 +424,85 @@
</div> </div>
</section> </section>
<!-- Immich Integration Section -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('immich.immich_integration')}
<img src={ImmichLogo} alt="Immich" class="inline-block w-8 h-8 -mt-1" />
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md">
<p class="text-center text-neutral-content">
{$t('immich.immich_desc')}
<a
class="link link-primary"
href="https://adventurelog.app/docs/configuration/immich_integration.html"
target="_blank">{$t('immich.documentation')}</a
>
</p>
{#if immichIntegration}
<div class="flex flex-col items-center justify-center mt-1 space-y-2">
<div class="badge badge-success">{$t('immich.integration_enabled')}</div>
<div class="flex space-x-2">
<button
class="btn btn-warning"
on:click={() => {
if (immichIntegration) newImmichIntegration = immichIntegration;
}}>Edit</button
>
<button class="btn btn-error" on:click={disableImmichIntegration}
>{$t('immich.disable')}</button
>
</div>
</div>
{/if}
{#if !immichIntegration || newImmichIntegration.id}
<div class="mt-4">
<div>
<label for="immich_url" class="text-sm font-medium text-neutral-content"
>{$t('immich.server_url')}</label
>
<input
type="url"
id="immich_url"
name="immich_url"
bind:value={newImmichIntegration.server_url}
placeholder="{$t('immich.server_url')} (e.g. https://immich.example.com/api)"
class="block w-full mt-1 input input-bordered input-primary"
/>
{#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')}
<p class="text-s text-warning mt-2">
{$t('immich.api_note')}
</p>
{/if}
{#if newImmichIntegration.server_url && (newImmichIntegration.server_url.indexOf('localhost') !== -1 || newImmichIntegration.server_url.indexOf('127.0.0.1') !== -1)}
<p class="text-s text-warning mt-2">
{$t('immich.localhost_note')}
</p>
{/if}
</div>
<div class="mt-4">
<label for="immich_api_key" class="text-sm font-medium text-neutral-content"
>{$t('immich.api_key')}</label
>
<input
type="text"
id="immich_api_key"
name="immich_api_key"
bind:value={newImmichIntegration.api_key}
placeholder={$t('immich.api_key')}
class="block w-full mt-1 input input-bordered input-primary"
/>
</div>
<button on:click={enableImmichIntegration} class="w-full mt-4 btn btn-primary py-2"
>{!immichIntegration?.id
? $t('immich.enable_immich')
: $t('immich.update_integration')}</button
>
</div>
{/if}
</div>
</section>
<!-- Visited Region Check Section --> <!-- Visited Region Check Section -->
<section class="text-center mt-8"> <section class="text-center mt-8">
<h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2> <h2 class="text-2xl font-semibold">{$t('adventures.visited_region_check')}</h2>

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import CountryCard from '$lib/components/CountryCard.svelte'; import CountryCard from '$lib/components/CountryCard.svelte';
import type { Country } from '$lib/types'; import type { Country } from '$lib/types';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
export let data: PageData; export let data: PageData;
console.log(data); console.log(data);
@ -12,6 +14,7 @@
let filteredCountries: Country[] = []; let filteredCountries: Country[] = [];
const allCountries: Country[] = data.props?.countries || []; const allCountries: Country[] = data.props?.countries || [];
let worldSubregions: string[] = []; let worldSubregions: string[] = [];
let showMap: boolean = false;
worldSubregions = [...new Set(allCountries.map((country) => country.subregion))]; worldSubregions = [...new Set(allCountries.map((country) => country.subregion))];
// remove blank subregions // remove blank subregions
@ -96,6 +99,16 @@
<option value={subregion}>{subregion}</option> <option value={subregion}>{subregion}</option>
{/each} {/each}
</select> </select>
<!-- borderd checkbox -->
<div class="flex items-center justify-center ml-4">
<input
type="checkbox"
class="checkbox checkbox-bordered"
bind:checked={showMap}
aria-label={$t('adventures.show_map')}
/>
<span class="ml-2">{$t('adventures.show_map')}</span>
</div>
</div> </div>
<div class="flex items-center justify-center mb-4"> <div class="flex items-center justify-center mb-4">
@ -115,6 +128,33 @@
{/if} {/if}
</div> </div>
{#if showMap}
<div class="mt-4 mb-4 flex justify-center">
<!-- checkbox to toggle marker -->
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls
zoom={2}
>
{#each filteredCountries as country}
{#if country.latitude && country.longitude}
<Marker
lngLat={[country.longitude, country.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 bg-green-200 text-black focus:outline-6 focus:outline-black"
on:click={() => goto(`/worldtravel/${country.country_code}`)}
>
<span class="text-xs">
{country.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
</div>
{/if}
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center"> <div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each filteredCountries as country} {#each filteredCountries as country}
<CountryCard {country} /> <CountryCard {country} />

View file

@ -161,7 +161,6 @@
{/if} {/if}
{/each} {/each}
</MapLibre> </MapLibre>
<!-- button to clear to and from location -->
</div> </div>
<svelte:head> <svelte:head>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB