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

Import and Export Functionality (#698)

* feat(backup): add BackupViewSet for data export and import functionality

* Fixed frontend returning corrupt binary data

* feat(import): enhance import functionality with confirmation check and improved city/region/country handling

* Potential fix for code scanning alert no. 29: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Refactor response handling to use arrayBuffer instead of bytes

* Refactor image cleanup command to use LocationImage model and update import/export view to include backup and restore functionality

* Update backup export versioning and improve data restore warning message

* Enhance image navigation and localization support in modal components

* Refactor location handling in Immich integration components for consistency

* Enhance backup and restore functionality with improved localization and error handling

* Improve accessibility by adding 'for' attribute to backup file input label

---------

Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Sean Morley 2025-06-26 10:23:37 -04:00 committed by GitHub
parent 493a13995c
commit c461f7b105
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2843 additions and 1114 deletions

View file

@ -1,7 +1,7 @@
import os import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from adventures.models import AdventureImage, Attachment from adventures.models import LocationImage, Attachment
from users.models import CustomUser from users.models import CustomUser
@ -21,8 +21,8 @@ class Command(BaseCommand):
# Get all image and attachment file paths from database # Get all image and attachment file paths from database
used_files = set() used_files = set()
# Get AdventureImage file paths # Get LocationImage file paths
for img in AdventureImage.objects.all(): for img in LocationImage.objects.all():
if img.image and img.image.name: if img.image and img.image.name:
used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name)) used_files.add(os.path.join(settings.MEDIA_ROOT, img.image.name))

View file

@ -18,7 +18,8 @@ router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-cale
router.register(r'search', GlobalSearchView, basename='search') router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments') router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'lodging', LodgingViewSet, basename='lodging') router.register(r'lodging', LodgingViewSet, basename='lodging')
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations') router.register(r'recommendations', RecommendationsViewSet, basename='recommendations'),
router.register(r'backup', BackupViewSet, basename='backup')
urlpatterns = [ urlpatterns = [
# Include the router under the 'api/' prefix # Include the router under the 'api/' prefix

View file

@ -13,4 +13,5 @@ from .transportation_view import *
from .global_search_view import * from .global_search_view import *
from .attachment_view import * from .attachment_view import *
from .lodging_view import * from .lodging_view import *
from .recommendations_view import * from .recommendations_view import *
from .import_export_view import *

View file

@ -0,0 +1,630 @@
# views.py
import json
import zipfile
import tempfile
import os
from datetime import datetime
from django.http import HttpResponse
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from django.conf import settings
from adventures.models import (
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
LocationImage, Attachment, Category, Lodging, Visit
)
from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country
User = get_user_model()
class BackupViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
"""
Simple ViewSet for handling backup and import operations
"""
@action(detail=False, methods=['get'])
def export(self, request):
"""
Export all user data as a ZIP file containing JSON data and files
"""
user = request.user
# Build export data structure
export_data = {
'version': settings.ADVENTURELOG_RELEASE_VERSION,
'export_date': datetime.now().isoformat(),
'user_email': user.email,
'user_username': user.username,
'categories': [],
'collections': [],
'locations': [],
'transportation': [],
'notes': [],
'checklists': [],
'lodging': [],
'visited_cities': [],
'visited_regions': []
}
# Export Visited Cities
for visited_city in user.visitedcity_set.all():
export_data['visited_cities'].append({
'city': visited_city.city.id,
})
# Export Visited Regions
for visited_region in user.visitedregion_set.all():
export_data['visited_regions'].append({
'region': visited_region.region.id,
})
# Export Categories
for category in user.category_set.all():
export_data['categories'].append({
'name': category.name,
'display_name': category.display_name,
'icon': category.icon,
})
# Export Collections
for idx, collection in enumerate(user.collection_set.all()):
export_data['collections'].append({
'export_id': idx, # Add unique identifier for this export
'name': collection.name,
'description': collection.description,
'is_public': collection.is_public,
'start_date': collection.start_date.isoformat() if collection.start_date else None,
'end_date': collection.end_date.isoformat() if collection.end_date else None,
'is_archived': collection.is_archived,
'link': collection.link,
'shared_with_user_ids': [str(uuid) for uuid in collection.shared_with.values_list('uuid', flat=True)]
})
# Create collection name to export_id mapping
collection_name_to_id = {col.name: idx for idx, col in enumerate(user.collection_set.all())}
# Export locations with related data
for idx, location in enumerate(user.location_set.all()):
location_data = {
'export_id': idx, # Add unique identifier for this export
'name': location.name,
'location': location.location,
'tags': location.tags,
'description': location.description,
'rating': location.rating,
'link': location.link,
'is_public': location.is_public,
'longitude': str(location.longitude) if location.longitude else None,
'latitude': str(location.latitude) if location.latitude else None,
'city': location.city_id,
'region': location.region_id,
'country': location.country_id,
'category_name': location.category.name if location.category else None,
'collection_export_ids': [collection_name_to_id[col_name] for col_name in location.collections.values_list('name', flat=True) if col_name in collection_name_to_id],
'visits': [],
'images': [],
'attachments': []
}
# Add visits
for visit in location.visits.all():
location_data['visits'].append({
'start_date': visit.start_date.isoformat() if visit.start_date else None,
'end_date': visit.end_date.isoformat() if visit.end_date else None,
'timezone': visit.timezone,
'notes': visit.notes
})
# Add images
for image in location.images.all():
image_data = {
'immich_id': image.immich_id,
'is_primary': image.is_primary,
'filename': None
}
if image.image:
image_data['filename'] = image.image.name.split('/')[-1]
location_data['images'].append(image_data)
# Add attachments
for attachment in location.attachments.all():
attachment_data = {
'name': attachment.name,
'filename': None
}
if attachment.file:
attachment_data['filename'] = attachment.file.name.split('/')[-1]
location_data['attachments'].append(attachment_data)
export_data['locations'].append(location_data)
# Export Transportation
for transport in user.transportation_set.all():
collection_export_id = None
if transport.collection:
collection_export_id = collection_name_to_id.get(transport.collection.name)
export_data['transportation'].append({
'type': transport.type,
'name': transport.name,
'description': transport.description,
'rating': transport.rating,
'link': transport.link,
'date': transport.date.isoformat() if transport.date else None,
'end_date': transport.end_date.isoformat() if transport.end_date else None,
'start_timezone': transport.start_timezone,
'end_timezone': transport.end_timezone,
'flight_number': transport.flight_number,
'from_location': transport.from_location,
'origin_latitude': str(transport.origin_latitude) if transport.origin_latitude else None,
'origin_longitude': str(transport.origin_longitude) if transport.origin_longitude else None,
'destination_latitude': str(transport.destination_latitude) if transport.destination_latitude else None,
'destination_longitude': str(transport.destination_longitude) if transport.destination_longitude else None,
'to_location': transport.to_location,
'is_public': transport.is_public,
'collection_export_id': collection_export_id
})
# Export Notes
for note in user.note_set.all():
collection_export_id = None
if note.collection:
collection_export_id = collection_name_to_id.get(note.collection.name)
export_data['notes'].append({
'name': note.name,
'content': note.content,
'links': note.links,
'date': note.date.isoformat() if note.date else None,
'is_public': note.is_public,
'collection_export_id': collection_export_id
})
# Export Checklists
for checklist in user.checklist_set.all():
collection_export_id = None
if checklist.collection:
collection_export_id = collection_name_to_id.get(checklist.collection.name)
checklist_data = {
'name': checklist.name,
'date': checklist.date.isoformat() if checklist.date else None,
'is_public': checklist.is_public,
'collection_export_id': collection_export_id,
'items': []
}
# Add checklist items
for item in checklist.checklistitem_set.all():
checklist_data['items'].append({
'name': item.name,
'is_checked': item.is_checked
})
export_data['checklists'].append(checklist_data)
# Export Lodging
for lodging in user.lodging_set.all():
collection_export_id = None
if lodging.collection:
collection_export_id = collection_name_to_id.get(lodging.collection.name)
export_data['lodging'].append({
'name': lodging.name,
'type': lodging.type,
'description': lodging.description,
'rating': lodging.rating,
'link': lodging.link,
'check_in': lodging.check_in.isoformat() if lodging.check_in else None,
'check_out': lodging.check_out.isoformat() if lodging.check_out else None,
'timezone': lodging.timezone,
'reservation_number': lodging.reservation_number,
'price': str(lodging.price) if lodging.price else None,
'latitude': str(lodging.latitude) if lodging.latitude else None,
'longitude': str(lodging.longitude) if lodging.longitude else None,
'location': lodging.location,
'is_public': lodging.is_public,
'collection_export_id': collection_export_id
})
# Create ZIP file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
with zipfile.ZipFile(tmp_file.name, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# Add JSON data
zip_file.writestr('data.json', json.dumps(export_data, indent=2))
# Add images and attachments
files_added = set()
for location in user.location_set.all():
# Add images
for image in location.images.all():
if image.image and image.image.name not in files_added:
try:
image_content = default_storage.open(image.image.name).read()
filename = image.image.name.split('/')[-1]
zip_file.writestr(f'images/{filename}', image_content)
files_added.add(image.image.name)
except Exception as e:
print(f"Error adding image {image.image.name}: {e}")
# Add attachments
for attachment in location.attachments.all():
if attachment.file and attachment.file.name not in files_added:
try:
file_content = default_storage.open(attachment.file.name).read()
filename = attachment.file.name.split('/')[-1]
zip_file.writestr(f'attachments/{filename}', file_content)
files_added.add(attachment.file.name)
except Exception as e:
print(f"Error adding attachment {attachment.file.name}: {e}")
# Return ZIP file as response
with open(tmp_file.name, 'rb') as zip_file:
response = HttpResponse(zip_file.read(), content_type='application/zip')
filename = f"adventurelog_backup_{user.username}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
response['Content-Disposition'] = f'attachment; filename="{filename}"'
# Clean up
os.unlink(tmp_file.name)
return response
@action(
detail=False,
methods=['post'],
parser_classes=[MultiPartParser],
url_path='import', # changes the URL path to /import
url_name='import' # changes the reverse name to 'import'
)
def import_data(self, request):
"""
Import data from a ZIP backup file
"""
if 'file' not in request.FILES:
return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
if 'confirm' not in request.data or request.data['confirm'] != 'yes':
return Response({'error': 'Confirmation required to proceed with import'},
status=status.HTTP_400_BAD_REQUEST)
backup_file = request.FILES['file']
user = request.user
# Save file temporarily
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
for chunk in backup_file.chunks():
tmp_file.write(chunk)
tmp_file_path = tmp_file.name
try:
with zipfile.ZipFile(tmp_file_path, 'r') as zip_file:
# Validate backup structure
if 'data.json' not in zip_file.namelist():
return Response({'error': 'Invalid backup file - missing data.json'},
status=status.HTTP_400_BAD_REQUEST)
# Load data
backup_data = json.loads(zip_file.read('data.json').decode('utf-8'))
# Import with transaction
with transaction.atomic():
# Clear existing data first
self._clear_user_data(user)
summary = self._import_data(backup_data, zip_file, user)
return Response({
'success': True,
'message': 'Data imported successfully',
'summary': summary
}, status=status.HTTP_200_OK)
except json.JSONDecodeError:
return Response({'error': 'Invalid JSON in backup file'},
status=status.HTTP_400_BAD_REQUEST)
except Exception:
import logging
logging.error("Import failed", exc_info=True)
return Response({'error': 'An internal error occurred during import'},
status=status.HTTP_400_BAD_REQUEST)
finally:
os.unlink(tmp_file_path)
def _clear_user_data(self, user):
"""Clear all existing user data before import"""
# Delete in reverse order of dependencies
user.checklistitem_set.all().delete()
user.checklist_set.all().delete()
user.note_set.all().delete()
user.transportation_set.all().delete()
user.lodging_set.all().delete()
# Delete location-related data
user.locationimage_set.all().delete()
user.attachment_set.all().delete()
# Visits are deleted via cascade when locations are deleted
user.location_set.all().delete()
# Delete collections and categories last
user.collection_set.all().delete()
user.category_set.all().delete()
# Clear visited cities and regions
user.visitedcity_set.all().delete()
user.visitedregion_set.all().delete()
def _import_data(self, backup_data, zip_file, user):
"""Import backup data and return summary"""
# Track mappings and counts
category_map = {}
collection_map = {} # Map export_id to actual collection object
summary = {
'categories': 0, 'collections': 0, 'locations': 0,
'transportation': 0, 'notes': 0, 'checklists': 0,
'checklist_items': 0, 'lodging': 0, 'images': 0, 'attachments': 0, 'visited_cities': 0, 'visited_regions': 0
}
# Import Visited Cities
for city_data in backup_data.get('visited_cities', []):
try:
city_obj = City.objects.get(id=city_data['city'])
VisitedCity.objects.create(user=user, city=city_obj)
summary['visited_cities'] += 1
except City.DoesNotExist:
# If city does not exist, we can skip or log it
pass
# Import Visited Regions
for region_data in backup_data.get('visited_regions', []):
try:
region_obj = Region.objects.get(id=region_data['region'])
VisitedRegion.objects.create(user=user, region=region_obj)
summary['visited_regions'] += 1
except Region.DoesNotExist:
# If region does not exist, we can skip or log it
pass
# Import Categories
for cat_data in backup_data.get('categories', []):
category = Category.objects.create(
user=user,
name=cat_data['name'],
display_name=cat_data['display_name'],
icon=cat_data.get('icon', '🌍')
)
category_map[cat_data['name']] = category
summary['categories'] += 1
# Import Collections
for col_data in backup_data.get('collections', []):
collection = Collection.objects.create(
user=user,
name=col_data['name'],
description=col_data.get('description', ''),
is_public=col_data.get('is_public', False),
start_date=col_data.get('start_date'),
end_date=col_data.get('end_date'),
is_archived=col_data.get('is_archived', False),
link=col_data.get('link')
)
collection_map[col_data['export_id']] = collection
summary['collections'] += 1
# Handle shared users
for uuid in col_data.get('shared_with_user_ids', []):
try:
shared_user = User.objects.get(uuid=uuid)
if shared_user.public_profile:
collection.shared_with.add(shared_user)
except User.DoesNotExist:
pass
# Import Locations
for adv_data in backup_data.get('locations', []):
city = None
if adv_data.get('city'):
try:
city = City.objects.get(id=adv_data['city'])
except City.DoesNotExist:
city = None
region = None
if adv_data.get('region'):
try:
region = Region.objects.get(id=adv_data['region'])
except Region.DoesNotExist:
region = None
country = None
if adv_data.get('country'):
try:
country = Country.objects.get(id=adv_data['country'])
except Country.DoesNotExist:
country = None
location = Location(
user=user,
name=adv_data['name'],
location=adv_data.get('location'),
tags=adv_data.get('tags', []),
description=adv_data.get('description'),
rating=adv_data.get('rating'),
link=adv_data.get('link'),
is_public=adv_data.get('is_public', False),
longitude=adv_data.get('longitude'),
latitude=adv_data.get('latitude'),
city=city,
region=region,
country=country,
category=category_map.get(adv_data.get('category_name'))
)
location.save(_skip_geocode=True) # Skip geocoding for now
# Add to collections using export_ids
for collection_export_id in adv_data.get('collection_export_ids', []):
if collection_export_id in collection_map:
location.collections.add(collection_map[collection_export_id])
# Import visits
for visit_data in adv_data.get('visits', []):
Visit.objects.create(
location=location,
start_date=visit_data.get('start_date'),
end_date=visit_data.get('end_date'),
timezone=visit_data.get('timezone'),
notes=visit_data.get('notes')
)
# Import images
for img_data in adv_data.get('images', []):
immich_id = img_data.get('immich_id')
if immich_id:
LocationImage.objects.create(
user=user,
location=location,
immich_id=immich_id,
is_primary=img_data.get('is_primary', False)
)
summary['images'] += 1
else:
filename = img_data.get('filename')
if filename:
try:
img_content = zip_file.read(f'images/{filename}')
img_file = ContentFile(img_content, name=filename)
LocationImage.objects.create(
user=user,
location=location,
image=img_file,
is_primary=img_data.get('is_primary', False)
)
summary['images'] += 1
except KeyError:
pass
# Import attachments
for att_data in adv_data.get('attachments', []):
filename = att_data.get('filename')
if filename:
try:
att_content = zip_file.read(f'attachments/{filename}')
att_file = ContentFile(att_content, name=filename)
Attachment.objects.create(
user=user,
location=location,
file=att_file,
name=att_data.get('name')
)
summary['attachments'] += 1
except KeyError:
pass
summary['locations'] += 1
# Import Transportation
for trans_data in backup_data.get('transportation', []):
collection = None
if trans_data.get('collection_export_id') is not None:
collection = collection_map.get(trans_data['collection_export_id'])
Transportation.objects.create(
user=user,
type=trans_data['type'],
name=trans_data['name'],
description=trans_data.get('description'),
rating=trans_data.get('rating'),
link=trans_data.get('link'),
date=trans_data.get('date'),
end_date=trans_data.get('end_date'),
start_timezone=trans_data.get('start_timezone'),
end_timezone=trans_data.get('end_timezone'),
flight_number=trans_data.get('flight_number'),
from_location=trans_data.get('from_location'),
origin_latitude=trans_data.get('origin_latitude'),
origin_longitude=trans_data.get('origin_longitude'),
destination_latitude=trans_data.get('destination_latitude'),
destination_longitude=trans_data.get('destination_longitude'),
to_location=trans_data.get('to_location'),
is_public=trans_data.get('is_public', False),
collection=collection
)
summary['transportation'] += 1
# Import Notes
for note_data in backup_data.get('notes', []):
collection = None
if note_data.get('collection_export_id') is not None:
collection = collection_map.get(note_data['collection_export_id'])
Note.objects.create(
user=user,
name=note_data['name'],
content=note_data.get('content'),
links=note_data.get('links', []),
date=note_data.get('date'),
is_public=note_data.get('is_public', False),
collection=collection
)
summary['notes'] += 1
# Import Checklists
for check_data in backup_data.get('checklists', []):
collection = None
if check_data.get('collection_export_id') is not None:
collection = collection_map.get(check_data['collection_export_id'])
checklist = Checklist.objects.create(
user=user,
name=check_data['name'],
date=check_data.get('date'),
is_public=check_data.get('is_public', False),
collection=collection
)
# Import checklist items
for item_data in check_data.get('items', []):
ChecklistItem.objects.create(
user=user,
checklist=checklist,
name=item_data['name'],
is_checked=item_data.get('is_checked', False)
)
summary['checklist_items'] += 1
summary['checklists'] += 1
# Import Lodging
for lodg_data in backup_data.get('lodging', []):
collection = None
if lodg_data.get('collection_export_id') is not None:
collection = collection_map.get(lodg_data['collection_export_id'])
Lodging.objects.create(
user=user,
name=lodg_data['name'],
type=lodg_data.get('type', 'other'),
description=lodg_data.get('description'),
rating=lodg_data.get('rating'),
link=lodg_data.get('link'),
check_in=lodg_data.get('check_in'),
check_out=lodg_data.get('check_out'),
timezone=lodg_data.get('timezone'),
reservation_number=lodg_data.get('reservation_number'),
price=lodg_data.get('price'),
latitude=lodg_data.get('latitude'),
longitude=lodg_data.get('longitude'),
location=lodg_data.get('location'),
is_public=lodg_data.get('is_public', False),
collection=collection
)
summary['lodging'] += 1
return summary

View file

@ -297,7 +297,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
is_authorized = True is_authorized = True
# Level 3: Owner access # Level 3: Owner access
elif request.user.is_authenticated and request.user.id == owner_id: elif request.user.is_authenticated and request.user == owner_id:
is_authorized = True is_authorized = True
# Level 4: Shared collection access - check if user has access to any collection # Level 4: Shared collection access - check if user has access to any collection
@ -314,7 +314,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
else: else:
# No LocationImage exists; allow only the integration owner # No LocationImage exists; allow only the integration owner
if not request.user.is_authenticated or request.user.id != owner_id: if not request.user.is_authenticated or request.user != owner_id:
return Response({ return Response({
'message': 'Image is not linked to any location and you are not the owner.', 'message': 'Image is not linked to any location and you are not the owner.',
'error': True, 'error': True,

View file

@ -326,6 +326,9 @@ LOGGING = {
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') # ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
# Major release version of AdventureLog, not including the patch version date.
ADVENTURELOG_RELEASE_VERSION = 'v0.10.0'
# https://github.com/dr5hn/countries-states-cities-database/tags # https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.6' COUNTRY_REGION_JSON_VERSION = 'v2.6'

View file

@ -33,146 +33,201 @@
} }
</script> </script>
<dialog id="about_modal" class="modal backdrop-blur-md bg-opacity-70"> <dialog id="about_modal" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div <div
class="modal-box rounded-xl shadow-lg backdrop-blur-lg bg-white/80 dark:bg-gray-900/80 transition-transform duration-300 ease-out transform scale-100" class="modal-box w-11/12 max-w-2xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
role="dialog" role="dialog"
on:keydown={handleKeydown} on:keydown={handleKeydown}
tabindex="0" tabindex="0"
> >
<!-- Branding --> <!-- Header -->
<div class="text-center"> <div class="flex items-center justify-between mb-6">
<h3 <div class="flex items-center gap-3">
class="text-2xl font-extrabold text-gray-800 dark:text-white flex items-center justify-center" <div class="p-2 bg-primary/10 rounded-lg">
> <img src="/favicon.png" alt="AdventureLog" class="w-12 h-12" />
{$t('about.about')} AdventureLog </div>
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" /> <div>
</h3> <h1 class="text-2xl font-bold text-primary">
<p class="mt-2 text-gray-500 dark:text-gray-300 text-sm"> {$t('about.about')} AdventureLog
AdventureLog </h1>
<a </div>
href={versionChangelog} </div>
target="_blank"
rel="noopener noreferrer" <button class="btn btn-ghost btn-sm btn-square" on:click={close}>
class="text-primary hover:underline" <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
> <path
{appVersion} stroke-linecap="round"
</a> stroke-linejoin="round"
</p> stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div> </div>
<!-- Links and Details --> <!-- Content -->
<div class="mt-4 text-center"> <div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400"> <!-- Version & Developer Info -->
© {copyrightYear} <div class="card bg-base-200/30 border border-base-300">
<a <div class="card-body p-4">
href="https://seanmorley.com" <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
target="_blank" <div>
rel="noopener noreferrer" <div class="text-sm text-base-content/60">{$t('about.version')}</div>
class="text-primary hover:underline" <div class="text-lg font-bold text-primary">{appVersion}</div>
> <a
Sean Morley href={versionChangelog}
</a> target="_blank"
</p> rel="noopener noreferrer"
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p> class="text-sm link link-primary"
<p class="text-sm text-gray-600 dark:text-gray-400"> >
<a {$t('about.view_changelog')}
href="https://github.com/seanmorley15/AdventureLog" </a>
target="_blank" </div>
rel="noopener noreferrer" <div>
class="text-primary hover:underline" <div class="text-sm text-base-content/60">{$t('about.developer')}</div>
> <a
{$t('about.source_code')} href="https://seanmorley.com"
</a> target="_blank"
</p> rel="noopener noreferrer"
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p> class="text-lg font-semibold link link-primary"
</div> >
Sean Morley
</a>
<div class="text-sm text-base-content/60">{$t('about.message')}</div>
</div>
</div>
</div>
</div>
<!-- Divider --> <!-- Map Services -->
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div> <div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold text-primary mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{$t('about.attributions')}
</h3>
{#if integrations && integrations?.google_maps}
<div class="flex items-center gap-2">
<span class="text-sm text-base-content/60">{$t('about.nominatim_1')}</span>
<a
href="https://developers.google.com/maps/terms"
target="_blank"
rel="noopener noreferrer"
class="link link-primary font-semibold"
>
Google Maps Platform
</a>
</div>
{:else if integrations && !integrations?.google_maps}
<div class="flex items-center gap-2">
<span class="text-sm text-base-content/60">{$t('about.nominatim_1')}</span>
<a
href="https://operations.osmfoundation.org/policies/nominatim/"
target="_blank"
rel="noopener noreferrer"
class="link link-primary font-semibold"
>
OpenStreetMap Nominatim
</a>
</div>
{:else}
<div class="text-sm text-base-content/60">{$t('about.generic_attributions')}</div>
{/if}
<p class="text-sm text-base-content/60">{$t('about.other_attributions')}</p>
</div>
</div>
<!-- Liscense info -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold text-primary mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{$t('about.license_info')}
</h3>
<p class="text-sm text-base-content/60 mb-2">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
Sean Morley
</a>
</p>
<p class="text-sm text-base-content/60">
{$t('about.license')}
</p>
<!-- OSS Acknowledgments -->
<div class="text-left">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
{$t('about.oss_attributions')}
</h3>
{#if integrations && integrations?.google_maps}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$t('about.nominatim_1')}
<a <a
href="https://developers.google.com/maps/terms" href="https://github.com/seanmorley15/AdventureLog/blob/main/LICENSE"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:underline" class="link link-primary mt-2"
> >
Google Maps {$t('about.view_license')}
</a> </a>
. </div>
</p> </div>
{:else if integrations && !integrations?.google_maps}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$t('about.nominatim_1')}
<a
href="https://operations.osmfoundation.org/policies/nominatim/"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
OpenStreetMap
</a>
. {$t('about.nominatim_2')}
</p>
{:else}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{$t('about.generic_attributions')}
</p>
{/if}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p> <!-- Links -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<div class="flex flex-wrap gap-3">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
GitHub →
</a>
<a
href="https://seanmorley.com/sponsor"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
{$t('about.sponsor')}
</a>
<!-- documentation -->
<a
href="https://adventurelog.app"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
{$t('navbar.documentation')}
</a>
</div>
</div>
</div>
</div> </div>
<!-- Close Button --> <!-- Footer -->
<div class="flex justify-center mt-6"> <div class="flex items-center justify-between mt-6 pt-4 border-t border-base-300">
<button <div class="text-sm text-base-content/60">
class="px-6 py-2 text-sm font-medium text-white bg-primary rounded-full shadow-md hover:shadow-lg hover:scale-105 transform transition" {$t('about.thank_you')}
on:click={close} </div>
> <button class="btn btn-primary btn-sm" on:click={close}>
{$t('about.close')} {$t('about.close')}
</button> </button>
</div> </div>
</div> </div>
</dialog> </dialog>
<style>
.modal {
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease-in-out;
}
.modal-box {
max-width: 600px;
padding: 2rem;
animation: slideUp 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View file

@ -6,7 +6,8 @@
export let adventures: Location[] = []; export let adventures: Location[] = [];
let currentSlide = 0; let currentSlide = 0;
let image_url: string | null = null; let showImageModal = false;
let modalInitialIndex = 0;
$: adventure_images = adventures.flatMap((adventure) => $: adventure_images = adventures.flatMap((adventure) =>
adventure.images.map((image) => ({ adventure.images.map((image) => ({
@ -42,13 +43,22 @@
currentSlide = currentSlide - 1; currentSlide = currentSlide - 1;
} }
} }
function openImageModal(initialIndex: number = currentSlide) {
modalInitialIndex = initialIndex;
showImageModal = true;
}
function closeImageModal() {
showImageModal = false;
}
</script> </script>
{#if image_url} {#if showImageModal && adventure_images.length > 0}
<ImageDisplayModal <ImageDisplayModal
adventure={adventure_images[currentSlide].adventure} images={adventure_images}
image={image_url} initialIndex={modalInitialIndex}
on:close={() => (image_url = null)} on:close={closeImageModal}
/> />
{/if} {/if}
@ -61,14 +71,46 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
<a <a
on:click|stopPropagation={() => (image_url = adventure_images[currentSlide].image)} on:click|stopPropagation={() => openImageModal(currentSlide)}
class="cursor-pointer" class="cursor-pointer relative group"
> >
<img <img
src={adventure_images[currentSlide].image} src={adventure_images[currentSlide].image}
class="w-full h-48 object-cover" class="w-full h-48 object-cover transition-all group-hover:brightness-110"
alt={adventure_images[currentSlide].adventure.name} alt={adventure_images[currentSlide].adventure.name}
/> />
<!-- Overlay indicator for multiple images -->
<!-- {#if adventure_images.length > 1}
<div
class="absolute top-3 right-3 bg-black/60 text-white px-2 py-1 rounded-lg text-xs font-medium"
>
{currentSlide + 1} / {adventure_images.length}
</div>
{/if} -->
<!-- Click to expand hint -->
<!-- <div
class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all flex items-center justify-center"
>
<div
class="opacity-0 group-hover:opacity-100 transition-all bg-white/90 rounded-full p-2"
>
<svg
class="w-6 h-6 text-gray-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</div>
</div> -->
</a> </a>
{#if adventure_images.length > 1} {#if adventure_images.length > 1}
@ -76,8 +118,18 @@
{#if currentSlide > 0} {#if currentSlide > 0}
<button <button
on:click|stopPropagation={() => changeSlide('prev')} on:click|stopPropagation={() => changeSlide('prev')}
class="btn btn-circle btn-sm ml-2 pointer-events-auto"></button class="btn btn-circle btn-sm mr-2 pointer-events-auto bg-neutral border-none text-neutral-content shadow-lg"
aria-label="Previous image"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{:else} {:else}
<div class="w-12"></div> <div class="w-12"></div>
{/if} {/if}
@ -85,17 +137,43 @@
{#if currentSlide < adventure_images.length - 1} {#if currentSlide < adventure_images.length - 1}
<button <button
on:click|stopPropagation={() => changeSlide('next')} on:click|stopPropagation={() => changeSlide('next')}
class="btn btn-circle mr-2 btn-sm pointer-events-auto"></button class="btn btn-circle btn-sm mr-2 pointer-events-auto bg-neutral border-none text-neutral-content shadow-lg"
aria-label="Next image"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{:else} {:else}
<div class="w-12"></div> <div class="w-12"></div>
{/if} {/if}
</div> </div>
<!-- Dot indicators at bottom -->
<!-- {#if adventure_images.length > 1}
<div class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2">
{#each adventure_images as _, index}
<button
on:click|stopPropagation={() => (currentSlide = index)}
class="w-2 h-2 rounded-full transition-all pointer-events-auto {index ===
currentSlide
? 'bg-white shadow-lg'
: 'bg-white/50 hover:bg-white/80'}"
aria-label="Go to image {index + 1}"
/>
{/each}
</div>
{/if} -->
{/if} {/if}
</div> </div>
</div> </div>
{:else} {:else}
<!-- add a figure with a gradient instead - --> <!-- add a figure with a gradient instead -->
<div class="w-full h-48 bg-gradient-to-r from-success via-base to-primary relative"> <div class="w-full h-48 bg-gradient-to-r from-success via-base to-primary relative">
<!-- subtle button bottom left text <!-- subtle button bottom left text
<div <div

View file

@ -5,14 +5,21 @@
let modal: HTMLDialogElement; let modal: HTMLDialogElement;
import type { Location } from '$lib/types'; import type { Location } from '$lib/types';
export let image: string; export let images: { image: string; adventure: Location | null }[] = [];
export let adventure: Location | null = null; export let initialIndex: number = 0;
import { t } from 'svelte-i18n';
let currentIndex = initialIndex;
let currentImage = images[currentIndex]?.image || '';
let currentAdventure = images[currentIndex]?.adventure || null;
onMount(() => { onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
modal.showModal(); modal.showModal();
} }
// Set initial values
updateCurrentSlide(initialIndex);
}); });
function close() { function close() {
@ -25,6 +32,10 @@
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
close(); close();
} else if (event.key === 'ArrowLeft') {
previousSlide();
} else if (event.key === 'ArrowRight') {
nextSlide();
} }
} }
@ -33,44 +44,228 @@
close(); close();
} }
} }
function updateCurrentSlide(index: number) {
currentIndex = index;
currentImage = images[currentIndex]?.image || '';
currentAdventure = images[currentIndex]?.adventure || null;
}
function nextSlide() {
if (images.length > 0) {
const nextIndex = (currentIndex + 1) % images.length;
updateCurrentSlide(nextIndex);
}
}
function previousSlide() {
if (images.length > 0) {
const prevIndex = (currentIndex - 1 + images.length) % images.length;
updateCurrentSlide(prevIndex);
}
}
function goToSlide(index: number) {
updateCurrentSlide(index);
}
// Reactive statement to handle prop changes
$: if (images.length > 0 && currentIndex >= images.length) {
updateCurrentSlide(0);
}
</script> </script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <dialog id="my_modal_1" class="modal backdrop-blur-sm" on:click={handleClickOutside}>
<dialog id="my_modal_1" class="modal" on:click={handleClickOutside}>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div
{#if adventure} class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
<div class="modal-header flex justify-between items-center mb-4"> role="dialog"
<h3 class="font-bold text-2xl">{adventure.name}</h3> on:keydown={handleKeydown}
<button class="btn btn-circle btn-neutral" on:click={close}> tabindex="0"
<svg >
xmlns="http://www.w3.org/2000/svg" {#if currentAdventure && currentImage}
class="h-6 w-6" <!-- Header -->
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div <div
class="flex justify-center items-center" class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
style="display: flex; justify-content: center; align-items: center;"
> >
<img <div class="flex items-center justify-between">
src={image} <div class="flex items-center gap-3">
alt={adventure.name} <div class="p-2 bg-primary/10 rounded-xl">
style="max-width: 100%; max-height: 75vh; object-fit: contain;" <svg
/> class="w-6 h-6 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-primary">
{currentAdventure.name}
</h1>
{#if images.length > 1}
<p class="text-sm text-base-content/60">
{currentIndex + 1} of {images.length}
{$t('adventures.images')}
</p>
{/if}
</div>
</div>
<!-- Navigation indicators for multiple images -->
{#if images.length > 1}
<div class="hidden md:flex items-center gap-2">
<div class="flex gap-1">
{#each images as _, index}
<button
class="w-2 h-2 rounded-full transition-all {index === currentIndex
? 'bg-primary'
: 'bg-base-300 hover:bg-base-400'}"
on:click={() => goToSlide(index)}
/>
{/each}
</div>
</div>
{/if}
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Image Display Area -->
<div class="relative h-[75vh] flex justify-center items-center max-w-full">
<!-- Previous Button -->
{#if images.length > 1}
<button
class="absolute left-4 top-1/2 -translate-y-1/2 z-20 btn btn-circle btn-primary/80 hover:btn-primary"
on:click={previousSlide}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<!-- Main Image -->
<div class="flex justify-center items-center max-w-full">
<img
src={currentImage}
alt={currentAdventure.name}
class="max-w-full max-h-[75vh] object-contain rounded-lg shadow-lg"
style="max-width: 100%; max-height: 75vh; object-fit: contain;"
/>
</div>
<!-- Next Button -->
{#if images.length > 1}
<button
class="absolute right-4 top-1/2 -translate-y-1/2 z-20 btn btn-circle btn-primary/80 hover:btn-primary"
on:click={nextSlide}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/if}
</div>
<!-- Thumbnail Navigation (for multiple images) -->
{#if images.length > 1}
<div class="mt-6 px-2">
<div class="flex gap-2 overflow-x-auto pb-2">
{#each images as imageData, index}
<button
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all {index ===
currentIndex
? 'border-primary shadow-lg'
: 'border-base-300 hover:border-base-400'}"
on:click={() => goToSlide(index)}
>
<img
src={imageData.image}
alt={imageData.adventure?.name || 'Image'}
class="w-full h-full object-cover"
/>
</button>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
<div
class="bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="text-sm text-base-content/60">
{#if currentAdventure.location}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{currentAdventure.location}
</span>
{/if}
</div>
<div class="flex items-center gap-3">
{#if images.length > 1}
<div class="text-sm text-base-content/60">
{$t('adventures.image_modal_navigate')}
</div>
{/if}
<button class="btn btn-primary gap-2" on:click={close}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('about.close')}
</button>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -1,91 +0,0 @@
<script lang="ts">
import { addToast } from '$lib/toasts';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
let url: string = '';
export let name: string | null = null;
let error = '';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
async function fetchImage() {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
error = 'No image found at that URL.';
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
close();
dispatch('image', { file });
}
async function fetchWikiImage() {
let res = await fetch(`/api/generate/img/?name=${name}`);
let data = await res.json();
if (data.source) {
let imageUrl = data.source;
let res = await fetch(imageUrl);
let blob = await res.blob();
let file = new File([blob], `${name}.jpg`, { type: 'image/jpeg' });
close();
dispatch('image', { file });
} else {
error = 'No image found for that Wikipedia article.';
}
}
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">Image Fetcher with URL</h3>
<form>
<input
type="text"
class="input input-bordered w-full max-w-xs"
bind:value={url}
placeholder="Enter a URL"
/>
<button class="btn btn-primary" on:click={fetchImage}>Submit</button>
</form>
<h3 class="font-bold text-lg">Image Fetcher from Wikipedia</h3>
<form>
<input
type="text"
class="input input-bordered w-full max-w-xs"
bind:value={name}
placeholder="Enter a Wikipedia Article Name"
/>
<button class="btn btn-primary" on:click={fetchWikiImage}>Submit</button>
</form>
{#if error}
<p class="text-red-500">{error}</p>
{/if}
<button class="btn btn-primary" on:click={close}>Close</button>
</div>
</dialog>

View file

@ -12,7 +12,7 @@
let immichNextURL: string = ''; let immichNextURL: string = '';
let loading = false; let loading = false;
export let adventure: Location | null = null; export let location: Location | null = null;
export let copyImmichLocally: boolean = false; export let copyImmichLocally: boolean = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -21,7 +21,7 @@
let currentAlbum: string = ''; let currentAlbum: string = '';
let selectedDate: string = let selectedDate: string =
(adventure as Location | null)?.visits (location as Location | null)?.visits
.map((v) => new Date(v.end_date || v.start_date)) .map((v) => new Date(v.end_date || v.start_date))
.sort((a, b) => +b - +a)[0] .sort((a, b) => +b - +a)[0]
?.toISOString() ?.toISOString()
@ -57,8 +57,8 @@
} }
async function saveImmichRemoteUrl(imageId: string) { async function saveImmichRemoteUrl(imageId: string) {
if (!adventure) { if (!location) {
console.error('No adventure provided to save the image URL'); console.error('No location provided to save the image URL');
return; return;
} }
let res = await fetch('/api/images', { let res = await fetch('/api/images', {
@ -68,7 +68,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
immich_id: imageId, immich_id: imageId,
adventure: adventure.id location: location.id
}) })
}); });
if (res.ok) { if (res.ok) {

View file

@ -273,7 +273,7 @@
{#if type != 'link'} {#if type != 'link'}
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<button <button
class="btn btn-neutral btn-sm flex-1 mr-2" class="btn btn-base-300 btn-sm flex-1 mr-2"
on:click={() => goto(`/locations/${adventure.id}`)} on:click={() => goto(`/locations/${adventure.id}`)}
> >
<Launch class="w-4 h-4" /> <Launch class="w-4 h-4" />

View file

@ -847,7 +847,7 @@
{#if immichIntegration} {#if immichIntegration}
<ImmichSelect <ImmichSelect
adventure={location} {location}
on:fetchImage={(e) => { on:fetchImage={(e) => {
url = e.detail; url = e.detail;
fetchImage(); fetchImage();

View file

@ -17,6 +17,18 @@
export let is_enabled: boolean; export let is_enabled: boolean;
let reauthError: boolean = false; let reauthError: boolean = false;
// import Account from '~icons/mdi/account';
import Clear from '~icons/mdi/close';
import Check from '~icons/mdi/check-circle';
import Copy from '~icons/mdi/content-copy';
import Error from '~icons/mdi/alert-circle';
import Key from '~icons/mdi/key';
import QrCode from '~icons/mdi/qrcode';
import Security from '~icons/mdi/security';
import Warning from '~icons/mdi/alert';
import Shield from '~icons/mdi/shield-account';
import Backup from '~icons/mdi/backup-restore';
onMount(() => { onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) { if (modal) {
@ -113,72 +125,214 @@
} }
</script> </script>
<dialog id="my_modal_1" class="modal"> <dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0"> <div
<h3 class="font-bold text-lg">{$t('settings.enable_mfa')}</h3> class="modal-box w-11/12 max-w-4xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- Header Section -->
<div
class=" top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-warning/10 rounded-xl">
<Shield class="w-8 h-8 text-warning" />
</div>
<div>
<h1 class="text-3xl font-bold text-warning bg-clip-text">
{$t('settings.enable_mfa')}
</h1>
<p class="text-sm text-base-content/60">
{$t('settings.secure_your_account')}
</p>
</div>
</div>
{#if qrCodeDataUrl} <!-- Status Badge -->
<div class="mb-4 flex items-center justify-center mt-2"> <div class="hidden md:flex items-center gap-2">
<img src={qrCodeDataUrl} alt="QR Code" class="w-64 h-64" /> <div class="badge badge-warning badge-lg gap-2">
<Security class="w-4 h-4" />
{is_enabled ? $t('settings.enabled') : $t('settings.setup_required')}
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<Clear class="w-5 h-5" />
</button>
</div> </div>
{/if} </div>
<div class="flex items-center justify-center mb-6">
<!-- Main Content -->
<div class="px-2">
<!-- QR Code Section -->
{#if qrCodeDataUrl}
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body items-center text-center">
<h3 class="card-title text-xl mb-4 flex items-center gap-2">
<QrCode class="w-6 h-6 text-primary" />
{$t('settings.scan_qr_code')}
</h3>
<div class="p-4 bg-white rounded-xl border border-base-300 mb-4">
<img src={qrCodeDataUrl} alt="QR Code" class="w-64 h-64" />
</div>
<p class="text-base-content/60 max-w-md">
{$t('settings.scan_with_authenticator_app')}
</p>
</div>
</div>
{/if}
<!-- Secret Key Section -->
{#if secret} {#if secret}
<div class="flex items-center"> <div class="card bg-base-200/50 border border-base-300/50 mb-6">
<input <div class="card-body">
type="text" <h3 class="card-title text-lg mb-4 flex items-center gap-2">
placeholder={secret} <Key class="w-5 h-5 text-secondary" />
class="input input-bordered w-full max-w-xs" {$t('settings.manual_entry')}
readonly </h3>
/> <div class="flex items-center gap-3">
<button class="btn btn-primary ml-2" on:click={() => copyToClipboard(secret)} <div class="flex-1">
>{$t('settings.copy')}</button <input
> type="text"
value={secret}
class="input input-bordered w-full font-mono text-sm bg-base-100/80"
readonly
/>
</div>
<button class="btn btn-secondary gap-2" on:click={() => copyToClipboard(secret)}>
<Copy class="w-4 h-4" />
{$t('settings.copy')}
</button>
</div>
</div>
</div>
{/if}
<!-- Verification Code Section -->
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4 flex items-center gap-2">
<Shield class="w-5 h-5 text-success" />
{$t('settings.verify_setup')}
</h3>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">
{$t('settings.authenticator_code')}
</span>
</label>
<input
type="text"
placeholder={$t('settings.enter_6_digit_code')}
class="input input-bordered bg-base-100/80 font-mono text-center text-lg tracking-widest"
bind:value={first_code}
maxlength="6"
/>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text-alt text-base-content/60">
{$t('settings.enter_code_from_app')}
</span>
</label>
</div>
</div>
</div>
<!-- Recovery Codes Section -->
{#if recovery_codes.length > 0}
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg flex items-center gap-2">
<Backup class="w-5 h-5 text-info" />
{$t('settings.recovery_codes')}
</h3>
<button
class="btn btn-info btn-sm gap-2"
on:click={() => copyToClipboard(recovery_codes.join(', '))}
>
<Copy class="w-4 h-4" />
{$t('settings.copy_all')}
</button>
</div>
<div class="alert alert-warning mb-4">
<Warning class="w-5 h-5" />
<div>
<h4 class="font-semibold">{$t('settings.important')}</h4>
<p class="text-sm">{$t('settings.recovery_codes_desc')}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{#each recovery_codes as code, index}
<div class="relative group">
<input
type="text"
value={code}
class="input input-bordered input-sm w-full font-mono text-center bg-base-100/80 pr-10"
readonly
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity btn btn-ghost btn-xs"
on:click={() => copyToClipboard(code)}
>
<Copy class="w-3 h-3" />
</button>
<span
class="absolute -top-2 -left-2 bg-base-content text-base-100 rounded-full w-5 h-5 text-xs flex items-center justify-center font-bold"
>
{index + 1}
</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Error Message -->
{#if reauthError}
<div class="alert alert-error mb-6">
<Error class="w-5 h-5" />
<div>
<h4 class="font-semibold">{$t('settings.error_occurred')}</h4>
<p class="text-sm">{$t('settings.reset_session_error')}</p>
</div>
</div> </div>
{/if} {/if}
</div> </div>
<input <!-- Footer Actions -->
type="text" <div
placeholder={$t('settings.authenticator_code')} class="bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6 rounded-lg"
class="input input-bordered w-full max-w-xs" >
bind:value={first_code} <div class="flex items-center justify-between">
/> <div class="text-sm text-base-content/60">
{is_enabled
<div class="recovery-codes-container"> ? $t('settings.mfa_already_enabled')
{#if recovery_codes.length > 0} : $t('settings.complete_setup_to_enable')}
<h3 class="mt-4 text-center font-bold text-lg">{$t('settings.recovery_codes')}</h3> </div>
<p class="text-center text-lg mb-2"> <div class="flex items-center gap-3">
{$t('settings.recovery_codes_desc')} {#if !is_enabled && first_code.length >= 6}
</p> <button class="btn btn-success gap-2" on:click={sendTotp}>
<button <Shield class="w-4 h-4" />
class="btn btn-primary ml-2" {$t('settings.enable_mfa')}
on:click={() => copyToClipboard(recovery_codes.join(', '))}>{$t('settings.copy')}</button </button>
> {/if}
{/if} <button class="btn btn-primary gap-2" on:click={close}>
<div class="recovery-codes-grid flex flex-wrap"> <Check class="w-4 h-4" />
{#each recovery_codes as code} {$t('about.close')}
<div </button>
class="recovery-code-item flex items-center justify-center m-2 w-full sm:w-1/2 md:w-1/3 lg:w-1/4" </div>
>
<input type="text" value={code} class="input input-bordered w-full" readonly />
</div>
{/each}
</div> </div>
</div> </div>
{#if reauthError}
<div class="alert alert-error mt-4">
{$t('settings.reset_session_error')}
</div>
{/if}
{#if !is_enabled}
<button class="btn btn-primary mt-4" on:click={sendTotp}>{$t('settings.enable_mfa')}</button>
{/if}
<button class="btn btn-primary mt-4" on:click={close}>{$t('about.close')}</button>
</div> </div>
</dialog> </dialog>

View file

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

View file

@ -9,7 +9,15 @@
"oss_attributions": "Open Source Quellenangaben", "oss_attributions": "Open Source Quellenangaben",
"other_attributions": "Weitere Hinweise finden Sie in der README-Datei.", "other_attributions": "Weitere Hinweise finden Sie in der README-Datei.",
"source_code": "Quellcode", "source_code": "Quellcode",
"generic_attributions": "Melden Sie sich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen." "generic_attributions": "Melden Sie sich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen.",
"attributions": "Zuschreibungen",
"developer": "Entwickler",
"license_info": "Lizenz",
"sponsor": "Sponsor",
"thank_you": "Vielen Dank, dass Sie Adventurelog verwendet haben!",
"version": "Version",
"view_changelog": "ChangeLog anzeigen",
"view_license": "Lizenz anzeigen"
}, },
"adventures": { "adventures": {
"activities": {}, "activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Besuchen Sie den Kalender", "visit_calendar": "Besuchen Sie den Kalender",
"wiki_location_desc": "Zieht Auszug aus dem Wikipedia -Artikel, der dem Namen des Standorts entspricht.", "wiki_location_desc": "Zieht Auszug aus dem Wikipedia -Artikel, der dem Namen des Standorts entspricht.",
"will_be_marked_location": "wird als besucht markiert, sobald der Standort gespeichert ist.", "will_be_marked_location": "wird als besucht markiert, sobald der Standort gespeichert ist.",
"no_locations_found": "Keine Standorte gefunden" "no_locations_found": "Keine Standorte gefunden",
"image_modal_navigate": "Verwenden Sie Pfeiltasten oder klicken Sie, um zu navigieren"
}, },
"home": { "home": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos", "desc_1": "Entdecken, planen und erkunden Sie mühelos",
@ -497,7 +506,36 @@
"staff_status": "Personalstatus", "staff_status": "Personalstatus",
"staff_user": "Personalbenutzer", "staff_user": "Personalbenutzer",
"profile_info_desc": "Aktualisieren Sie Ihre persönlichen Daten und Ihr Profilbild", "profile_info_desc": "Aktualisieren Sie Ihre persönlichen Daten und Ihr Profilbild",
"invalid_credentials": "Ungültige Anmeldeinformationen" "invalid_credentials": "Ungültige Anmeldeinformationen",
"backup_restore": "Backup",
"backup_restore_desc": "Speichern Sie Ihre Daten oder stellen Sie sie in einer früheren Sicherungsdatei wieder her.",
"complete_setup_to_enable": "Komplettes Setup, um MFA zu aktivieren",
"copy_all": "Alle kopieren",
"enter_6_digit_code": "Geben Sie einen 6 -stelligen Code ein",
"enter_code_from_app": "Geben Sie den Code aus der App ein",
"error_occurred": "Es ist ein Fehler aufgetreten",
"important": "Wichtig",
"manual_entry": "Manueller Eintritt",
"mfa_already_enabled": "MFA bereits aktiviert",
"mfa_required": "MFA erforderlich",
"scan_qr_code": "Scannen QR -Code",
"scan_with_authenticator_app": "Scannen Sie mit Authenticator App",
"secure_your_account": "Sichern Sie Ihr Konto",
"setup_required": "Setup erforderlich",
"verify_setup": "Überprüfen Sie das Setup",
"whats_included": "Was ist enthalten",
"backup_your_data": "Sichern Sie Ihre Daten",
"backup_your_data_desc": "Laden Sie eine vollständige Sicherung Ihrer Kontodaten, einschließlich Standorte, Sammlungen, Medien und Besuchen, herunter.",
"data_override_acknowledge": "Ich bestätige, dass dies alle meine vorhandenen Daten überschreibt",
"data_override_acknowledge_desc": "Diese Aktion ist irreversibel und ersetzt alle Standorte, Sammlungen und Besuche in Ihrem Konto.",
"data_override_warning": "Daten überschreiben Warnung",
"data_override_warning_desc": "Das Wiederherstellen von Daten ersetzt alle vorhandenen Daten (die in der Sicherung enthalten sind) in Ihrem Konto vollständig. \nDiese Aktion kann nicht rückgängig gemacht werden.",
"integrations_settings": "Integrationseinstellungen",
"media": "Medien",
"restore_data": "Daten wiederherstellen",
"restore_data_desc": "Laden Sie eine Sicherungsdatei hoch, um Ihre Daten wiederherzustellen.",
"select_backup_file": "Wählen Sie Sicherungsdatei",
"world_travel_visits": "Weltreisebesuche"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Fehler beim Löschen der Checkliste", "checklist_delete_error": "Fehler beim Löschen der Checkliste",
@ -722,5 +760,6 @@
"location": "Standort", "location": "Standort",
"locations": "Standorte", "locations": "Standorte",
"my_locations": "Meine Standorte" "my_locations": "Meine Standorte"
} },
"settings_download_backup": "Backup herunterladen"
} }

File diff suppressed because it is too large Load diff

View file

@ -43,7 +43,15 @@
"nominatim_2": "Sus datos están licenciados bajo la licencia ODbL.", "nominatim_2": "Sus datos están licenciados bajo la licencia ODbL.",
"other_attributions": "Atribuciones adicionales se pueden encontrar en el archivo README.", "other_attributions": "Atribuciones adicionales se pueden encontrar en el archivo README.",
"close": "Cerrar", "close": "Cerrar",
"generic_attributions": "Inicie sesión en AdventureLog para ver las atribuciones para integraciones y servicios habilitados." "generic_attributions": "Inicie sesión en AdventureLog para ver las atribuciones para integraciones y servicios habilitados.",
"attributions": "Atribuciones",
"developer": "Revelador",
"license_info": "Licencia",
"sponsor": "Patrocinador",
"thank_you": "¡Gracias por usar Adventurelog!",
"version": "Versión",
"view_changelog": "Ver ChangeLog",
"view_license": "Ver licencia"
}, },
"home": { "home": {
"hero_1": "Descubre las Aventuras Más Emocionantes del Mundo", "hero_1": "Descubre las Aventuras Más Emocionantes del Mundo",
@ -302,7 +310,8 @@
"visit_calendar": "Visitar el calendario", "visit_calendar": "Visitar el calendario",
"wiki_location_desc": "Extrae extracto del artículo de Wikipedia que coincide con el nombre de la ubicación.", "wiki_location_desc": "Extrae extracto del artículo de Wikipedia que coincide con el nombre de la ubicación.",
"will_be_marked_location": "se marcará según lo visitado una vez que se guarde la ubicación.", "will_be_marked_location": "se marcará según lo visitado una vez que se guarde la ubicación.",
"no_locations_found": "No se encontraron ubicaciones" "no_locations_found": "No se encontraron ubicaciones",
"image_modal_navigate": "Use teclas de flecha o haga clic para navegar"
}, },
"worldtravel": { "worldtravel": {
"all": "Todo", "all": "Todo",
@ -497,7 +506,36 @@
"all_rights_reserved": "Reservados todos los derechos.", "all_rights_reserved": "Reservados todos los derechos.",
"email_verified_erorr_desc": "Su correo electrónico no pudo ser verificado. \nPor favor intente de nuevo.", "email_verified_erorr_desc": "Su correo electrónico no pudo ser verificado. \nPor favor intente de nuevo.",
"no_emai_set": "Sin conjunto de correo electrónico", "no_emai_set": "Sin conjunto de correo electrónico",
"invalid_credentials": "Credenciales no válidas" "invalid_credentials": "Credenciales no válidas",
"backup_restore": "Respaldo",
"backup_restore_desc": "Guardar sus datos o restaurarlo desde un archivo de copia de seguridad anterior.",
"complete_setup_to_enable": "Configuración completa para habilitar MFA",
"copy_all": "Copiar todo",
"enter_6_digit_code": "Ingrese el código de 6 dígitos",
"enter_code_from_app": "Ingrese el código desde la aplicación",
"error_occurred": "Se ha producido un error",
"important": "Importante",
"manual_entry": "Entrada manual",
"mfa_already_enabled": "MFA ya habilitado",
"mfa_required": "MFA requerido",
"scan_qr_code": "Escanear el código QR",
"scan_with_authenticator_app": "Escanear con la aplicación Authenticator",
"secure_your_account": "Asegure su cuenta",
"setup_required": "Configuración requerida",
"verify_setup": "Verificar la configuración",
"whats_included": "¿Qué está incluido?",
"backup_your_data": "Haga una copia de seguridad de sus datos",
"backup_your_data_desc": "Descargue una copia de seguridad completa de los datos de su cuenta, incluidas ubicaciones, colecciones, medios y visitas.",
"data_override_acknowledge": "Reconozco que esto anulará todos mis datos existentes.",
"data_override_acknowledge_desc": "Esta acción es irreversible y reemplazará todas las ubicaciones, colecciones y visitas en su cuenta.",
"data_override_warning": "Advertencia de anulación de datos",
"data_override_warning_desc": "La restauración de datos reemplazará completamente todos los datos existentes (que se incluyen en la copia de seguridad) en su cuenta. \nEsta acción no se puede deshacer.",
"integrations_settings": "Configuración de integraciones",
"media": "Medios de comunicación",
"restore_data": "Restaurar datos",
"restore_data_desc": "Cargue un archivo de copia de seguridad para restaurar sus datos.",
"select_backup_file": "Seleccione el archivo de copia de seguridad",
"world_travel_visits": "Visitas de viajes mundiales"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Error al eliminar la lista de tareas", "checklist_delete_error": "Error al eliminar la lista de tareas",
@ -722,5 +760,6 @@
"location": "Ubicación", "location": "Ubicación",
"locations": "Ubicación", "locations": "Ubicación",
"my_locations": "Mis ubicaciones" "my_locations": "Mis ubicaciones"
} },
"settings_download_backup": "Descargar copia de seguridad"
} }

View file

@ -9,7 +9,15 @@
"oss_attributions": "Attributions Open Source", "oss_attributions": "Attributions Open Source",
"other_attributions": "Des attributions supplémentaires peuvent être trouvées dans le fichier README.", "other_attributions": "Des attributions supplémentaires peuvent être trouvées dans le fichier README.",
"source_code": "Code source", "source_code": "Code source",
"generic_attributions": "Connectez-vous à AdventureLog pour afficher les attributions pour les intégrations et services activés." "generic_attributions": "Connectez-vous à AdventureLog pour afficher les attributions pour les intégrations et services activés.",
"attributions": "Attributions",
"developer": "Promoteur",
"license_info": "Licence",
"sponsor": "Parrainer",
"thank_you": "Merci d'utiliser AdventureLog!",
"version": "Version",
"view_changelog": "Voir Changelog",
"view_license": "Afficher la licence"
}, },
"adventures": { "adventures": {
"activities": {}, "activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Visiter le calendrier", "visit_calendar": "Visiter le calendrier",
"wiki_location_desc": "Tire un extrait de l'article de Wikipedia correspondant au nom de l'emplacement.", "wiki_location_desc": "Tire un extrait de l'article de Wikipedia correspondant au nom de l'emplacement.",
"will_be_marked_location": "sera marqué comme visité une fois l'emplacement enregistré.", "will_be_marked_location": "sera marqué comme visité une fois l'emplacement enregistré.",
"no_locations_found": "Aucun emplacement trouvé" "no_locations_found": "Aucun emplacement trouvé",
"image_modal_navigate": "Utilisez des touches flèches ou cliquez pour naviguer"
}, },
"home": { "home": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité", "desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -497,7 +506,36 @@
"disconnected": "Déconnecté", "disconnected": "Déconnecté",
"email_management": "Gestion des e-mails", "email_management": "Gestion des e-mails",
"enter_last_name": "Entrez votre nom de famille", "enter_last_name": "Entrez votre nom de famille",
"invalid_credentials": "Des références non valides" "invalid_credentials": "Des références non valides",
"backup_restore": "Sauvegarde",
"backup_restore_desc": "Enregistrez vos données ou restaurez-les à partir d'un fichier de sauvegarde précédent.",
"complete_setup_to_enable": "Configuration complète pour activer le MFA",
"copy_all": "Copier tout",
"enter_6_digit_code": "Entrez le code à 6 chiffres",
"enter_code_from_app": "Entrez le code à partir de l'application",
"error_occurred": "Une erreur s'est produite",
"important": "Important",
"manual_entry": "Entrée manuelle",
"mfa_already_enabled": "MFA déjà activé",
"mfa_required": "MFA requis",
"scan_qr_code": "Scanner le code QR",
"scan_with_authenticator_app": "Scanner avec l'application Authenticatrice",
"secure_your_account": "Sécuriser votre compte",
"setup_required": "Configuration requise",
"verify_setup": "Vérifiez la configuration",
"whats_included": "Ce qui est inclus",
"backup_your_data": "Sauvegardez vos données",
"backup_your_data_desc": "Téléchargez une sauvegarde complète des données de votre compte, y compris les emplacements, les collections, les médias et les visites.",
"data_override_acknowledge": "Je reconnais que cela remplacera toutes mes données existantes",
"data_override_acknowledge_desc": "Cette action est irréversible et remplacera tous les emplacements, collections et visites dans votre compte.",
"data_override_warning": "Avertissement de remplacement des données",
"data_override_warning_desc": "La restauration des données remplacera complètement toutes les données existantes (incluses dans la sauvegarde) dans votre compte. \nCette action ne peut pas être annulée.",
"integrations_settings": "Paramètres des intégrations",
"media": "Médias",
"restore_data": "Restaurer les données",
"restore_data_desc": "Téléchargez un fichier de sauvegarde pour restaurer vos données.",
"select_backup_file": "Sélectionnez le fichier de sauvegarde",
"world_travel_visits": "Visites de voyage dans le monde"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Erreur lors de la suppression de la liste de contrôle", "checklist_delete_error": "Erreur lors de la suppression de la liste de contrôle",
@ -722,5 +760,6 @@
"location": "Emplacement", "location": "Emplacement",
"locations": "Lieux", "locations": "Lieux",
"my_locations": "Mes emplacements" "my_locations": "Mes emplacements"
} },
"settings_download_backup": "Télécharger la sauvegarde"
} }

View file

@ -9,7 +9,15 @@
"oss_attributions": "Attribuzioni Open Source", "oss_attributions": "Attribuzioni Open Source",
"other_attributions": "Ulteriori attribuzioni possono essere trovate nel file README.", "other_attributions": "Ulteriori attribuzioni possono essere trovate nel file README.",
"source_code": "Codice sorgente", "source_code": "Codice sorgente",
"generic_attributions": "Accedi a AdventureLog per visualizzare le attribuzioni per integrazioni e servizi abilitati." "generic_attributions": "Accedi a AdventureLog per visualizzare le attribuzioni per integrazioni e servizi abilitati.",
"attributions": "Attribuzioni",
"developer": "Sviluppatore",
"license_info": "Licenza",
"sponsor": "Sponsor",
"thank_you": "Grazie per aver usato AdventureLog!",
"version": "Versione",
"view_changelog": "Visualizza Changelog",
"view_license": "Visualizza la licenza"
}, },
"adventures": { "adventures": {
"activities": {}, "activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Visita il calendario", "visit_calendar": "Visita il calendario",
"wiki_location_desc": "Estratto dall'articolo di Wikipedia che corrisponde al nome della posizione.", "wiki_location_desc": "Estratto dall'articolo di Wikipedia che corrisponde al nome della posizione.",
"will_be_marked_location": "sarà contrassegnato come visitato una volta salvata la posizione.", "will_be_marked_location": "sarà contrassegnato come visitato una volta salvata la posizione.",
"no_locations_found": "Nessuna posizione trovata" "no_locations_found": "Nessuna posizione trovata",
"image_modal_navigate": "Usa i tasti freccia o fai clic per navigare"
}, },
"home": { "home": {
"desc_1": "Scopri, pianifica ed esplora con facilità", "desc_1": "Scopri, pianifica ed esplora con facilità",
@ -497,7 +506,36 @@
"staff_status": "Stato del personale", "staff_status": "Stato del personale",
"staff_user": "Utente del personale", "staff_user": "Utente del personale",
"password_auth": "Autenticazione della password", "password_auth": "Autenticazione della password",
"invalid_credentials": "Credenziali non valide" "invalid_credentials": "Credenziali non valide",
"backup_restore": "Backup",
"backup_restore_desc": "Salva i tuoi dati o ripristinarli da un precedente file di backup.",
"complete_setup_to_enable": "Setup completa per abilitare MFA",
"copy_all": "Copia tutto",
"enter_6_digit_code": "Immettere il codice a 6 cifre",
"enter_code_from_app": "Immettere il codice dall'app",
"error_occurred": "Si è verificato un errore",
"important": "Importante",
"manual_entry": "Ingresso manuale",
"mfa_already_enabled": "MFA già abilitato",
"mfa_required": "MFA richiesto",
"scan_qr_code": "Scansionare il codice QR",
"scan_with_authenticator_app": "Scansiona con l'app Authenticator",
"secure_your_account": "Proteggere il tuo account",
"setup_required": "Setup richiesto",
"verify_setup": "Verifica la configurazione",
"whats_included": "Cosa è incluso",
"backup_your_data": "Eseguire il backup dei tuoi dati",
"backup_your_data_desc": "Scarica un backup completo dei dati dell'account tra cui posizioni, raccolte, media e visite.",
"data_override_acknowledge": "Riconosco che questo sovrascriverà tutti i miei dati esistenti",
"data_override_acknowledge_desc": "Questa azione è irreversibile e sostituirà tutte le posizioni, le raccolte e le visite nel tuo account.",
"data_override_warning": "Avviso di sovraccarico dei dati",
"data_override_warning_desc": "Il ripristino dei dati sostituirà completamente tutti i dati esistenti (inclusi nel backup) nel tuo account. \nQuesta azione non può essere annullata.",
"integrations_settings": "Impostazioni integrazioni",
"media": "Media",
"restore_data": "Ripristina i dati",
"restore_data_desc": "Carica un file di backup per ripristinare i dati.",
"select_backup_file": "Seleziona il file di backup",
"world_travel_visits": "Visite di viaggio nel mondo"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Errore durante l'eliminazione della lista di controllo", "checklist_delete_error": "Errore durante l'eliminazione della lista di controllo",
@ -722,5 +760,6 @@
"location": "Posizione", "location": "Posizione",
"locations": "Luoghi", "locations": "Luoghi",
"my_locations": "Le mie posizioni" "my_locations": "Le mie posizioni"
} },
"settings_download_backup": "Scarica il backup"
} }

View file

@ -9,7 +9,15 @@
"oss_attributions": "오픈 소스 속성", "oss_attributions": "오픈 소스 속성",
"other_attributions": "추가 속성은 README 파일에서 찾을 수 있습니다.", "other_attributions": "추가 속성은 README 파일에서 찾을 수 있습니다.",
"source_code": "소스 코드", "source_code": "소스 코드",
"generic_attributions": "Adventurelog에 로그인하여 활성화 된 통합 및 서비스에 대한 속성을보십시오." "generic_attributions": "Adventurelog에 로그인하여 활성화 된 통합 및 서비스에 대한 속성을보십시오.",
"attributions": "속성",
"developer": "개발자",
"license_info": "특허",
"sponsor": "스폰서",
"thank_you": "Adventurelog를 사용해 주셔서 감사합니다!",
"version": "버전",
"view_changelog": "changelog를 봅니다",
"view_license": "라이센스보기"
}, },
"adventures": { "adventures": {
"actions": "행동", "actions": "행동",
@ -250,7 +258,8 @@
"visit_calendar": "캘린더를 방문하십시오", "visit_calendar": "캘린더를 방문하십시오",
"wiki_location_desc": "위치 이름과 일치하는 Wikipedia 기사에서 발췌 한 내용을 가져옵니다.", "wiki_location_desc": "위치 이름과 일치하는 Wikipedia 기사에서 발췌 한 내용을 가져옵니다.",
"will_be_marked_location": "위치가 저장되면 방문한대로 표시됩니다.", "will_be_marked_location": "위치가 저장되면 방문한대로 표시됩니다.",
"no_locations_found": "발견 된 위치는 없습니다" "no_locations_found": "발견 된 위치는 없습니다",
"image_modal_navigate": "화살표 키를 사용하거나 클릭하여 탐색하십시오"
}, },
"auth": { "auth": {
"confirm_password": "비밀번호 확인", "confirm_password": "비밀번호 확인",
@ -589,7 +598,36 @@
"social_auth_setup": "소셜 인증 설정", "social_auth_setup": "소셜 인증 설정",
"staff_status": "직원 상태", "staff_status": "직원 상태",
"staff_user": "직원 사용자", "staff_user": "직원 사용자",
"invalid_credentials": "잘못된 자격 증명" "invalid_credentials": "잘못된 자격 증명",
"backup_restore": "지원",
"backup_restore_desc": "데이터를 저장하거나 이전 백업 파일에서 복원하십시오.",
"complete_setup_to_enable": "MFA를 활성화하기위한 완전한 설정",
"copy_all": "모두 복사하십시오",
"enter_6_digit_code": "6 자리 코드를 입력하십시오",
"enter_code_from_app": "앱에서 코드를 입력하십시오",
"error_occurred": "오류가 발생했습니다",
"important": "중요한",
"manual_entry": "수동 입력",
"mfa_already_enabled": "MFA는 이미 활성화되었습니다",
"mfa_required": "MFA가 필요합니다",
"scan_qr_code": "QR 코드를 스캔하십시오",
"scan_with_authenticator_app": "Authenticator 앱으로 스캔하십시오",
"secure_your_account": "계정을 확보하십시오",
"setup_required": "설정이 필요합니다",
"verify_setup": "설정을 확인하십시오",
"whats_included": "포함 된 내용",
"backup_your_data": "데이터를 백업하십시오",
"backup_your_data_desc": "위치, 컬렉션, 미디어 및 방문을 포함하여 계정 데이터의 전체 백업을 다운로드하십시오.",
"data_override_acknowledge": "나는 이것이 나의 모든 기존 데이터를 무시할 것임을 인정합니다.",
"data_override_acknowledge_desc": "이 조치는 돌이킬 수 없으며 계정의 모든 위치, 컬렉션 및 방문을 대체합니다.",
"data_override_warning": "데이터를 우선적으로 경고합니다",
"data_override_warning_desc": "데이터 복원은 계정의 모든 기존 데이터 (백업에 포함 된)를 완전히 대체합니다. \n이 조치는 취소 할 수 없습니다.",
"integrations_settings": "통합 설정",
"media": "메디아",
"restore_data": "데이터를 복원하십시오",
"restore_data_desc": "백업 파일을 업로드하여 데이터를 복원하십시오.",
"select_backup_file": "백업 파일을 선택하십시오",
"world_travel_visits": "세계 여행 방문"
}, },
"share": { "share": {
"go_to_settings": "설정으로 이동", "go_to_settings": "설정으로 이동",
@ -721,5 +759,6 @@
"location": "위치", "location": "위치",
"locations": "위치", "locations": "위치",
"my_locations": "내 위치" "my_locations": "내 위치"
} },
"settings_download_backup": "백업을 다운로드하십시오"
} }

View file

@ -9,7 +9,15 @@
"oss_attributions": "Open source gebruik", "oss_attributions": "Open source gebruik",
"other_attributions": "Aanvullende vermeldingen zijn te vinden in het README-bestand.", "other_attributions": "Aanvullende vermeldingen zijn te vinden in het README-bestand.",
"source_code": "Broncode", "source_code": "Broncode",
"generic_attributions": "Log in op AdventUrelog om attributies te bekijken voor ingeschakelde integraties en services." "generic_attributions": "Log in op AdventUrelog om attributies te bekijken voor ingeschakelde integraties en services.",
"attributions": "Attributies",
"developer": "Ontwikkelaar",
"license_info": "Licentie",
"sponsor": "Sponsor",
"thank_you": "Bedankt voor het gebruik van AdventUrelog!",
"version": "Versie",
"view_changelog": "Bekijk Changelog",
"view_license": "Bekijk licentie"
}, },
"adventures": { "adventures": {
"activities": {}, "activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Bezoek de agenda", "visit_calendar": "Bezoek de agenda",
"wiki_location_desc": "Haalt fragment uit het Wikipedia -artikel dat overeenkomt met de naam van de locatie.", "wiki_location_desc": "Haalt fragment uit het Wikipedia -artikel dat overeenkomt met de naam van de locatie.",
"will_be_marked_location": "wordt gemarkeerd als bezocht zodra de locatie is opgeslagen.", "will_be_marked_location": "wordt gemarkeerd als bezocht zodra de locatie is opgeslagen.",
"no_locations_found": "Geen locaties gevonden" "no_locations_found": "Geen locaties gevonden",
"image_modal_navigate": "Gebruik pijltoetsen of klik om te navigeren"
}, },
"home": { "home": {
"desc_1": "Ontdek, plan en verken met gemak", "desc_1": "Ontdek, plan en verken met gemak",
@ -497,7 +506,36 @@
"staff_status": "Status", "staff_status": "Status",
"staff_user": "Personeelsgebruiker", "staff_user": "Personeelsgebruiker",
"connected": "Aangesloten", "connected": "Aangesloten",
"invalid_credentials": "Ongeldige referenties" "invalid_credentials": "Ongeldige referenties",
"backup_restore": "Back -up",
"backup_restore_desc": "Sla uw gegevens op of herstel deze in een eerder back -upbestand.",
"complete_setup_to_enable": "Volledige installatie om MFA in te schakelen",
"copy_all": "Kopieer alles",
"enter_6_digit_code": "Voer 6 cijfercode in",
"enter_code_from_app": "Voer de code uit van app",
"error_occurred": "Er is een fout opgetreden",
"important": "Belangrijk",
"manual_entry": "Handmatig invoer",
"mfa_already_enabled": "MFA al ingeschakeld",
"setup_required": "Instellingen vereist",
"verify_setup": "Controleer de installatie",
"whats_included": "Wat is inbegrepen",
"mfa_required": "MFA vereist",
"scan_qr_code": "Scan QR -code",
"scan_with_authenticator_app": "Scan met authenticator -app",
"secure_your_account": "Beveilig uw account",
"backup_your_data": "Back -up van uw gegevens",
"backup_your_data_desc": "Download een volledige back -up van uw accountgegevens, inclusief locaties, collecties, media en bezoeken.",
"data_override_acknowledge": "Ik erken dat dit al mijn bestaande gegevens zal overschrijven",
"data_override_acknowledge_desc": "Deze actie is onomkeerbaar en vervangt alle locaties, collecties en bezoeken in uw account.",
"data_override_warning": "Gegevensoverschrijdende waarschuwing",
"data_override_warning_desc": "Het herstellen van gegevens zal alle bestaande gegevens (die in de back -up zijn opgenomen) in uw account volledig vervangen. \nDeze actie kan niet ongedaan worden gemaakt.",
"integrations_settings": "Instellingen integraties",
"media": "Media",
"restore_data": "Herstel gegevens",
"restore_data_desc": "Upload een back -upbestand om uw gegevens te herstellen.",
"select_backup_file": "Selecteer back -upbestand",
"world_travel_visits": "Wereldreizen bezoeken"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Fout bij het verwijderen van de checklist", "checklist_delete_error": "Fout bij het verwijderen van de checklist",
@ -722,5 +760,6 @@
"location": "Locatie", "location": "Locatie",
"locations": "Locaties", "locations": "Locaties",
"my_locations": "Mijn locaties" "my_locations": "Mijn locaties"
} },
"settings_download_backup": "Download back -up"
} }

View file

@ -43,7 +43,15 @@
"nominatim_2": "Deres data er lisensiert under ODbL-lisensen.", "nominatim_2": "Deres data er lisensiert under ODbL-lisensen.",
"other_attributions": "Ytterligere attribusjoner finnes i README-filen.", "other_attributions": "Ytterligere attribusjoner finnes i README-filen.",
"close": "Lukk", "close": "Lukk",
"generic_attributions": "Logg inn på Adventurelog for å se attribusjoner for aktiverte integrasjoner og tjenester." "generic_attributions": "Logg inn på Adventurelog for å se attribusjoner for aktiverte integrasjoner og tjenester.",
"attributions": "Attribusjoner",
"developer": "Utvikler",
"license_info": "Tillatelse",
"sponsor": "Sponsor",
"thank_you": "Takk for at du bruker Adventurelog!",
"version": "Versjon",
"view_changelog": "Se Changelog",
"view_license": "Vis lisens"
}, },
"home": { "home": {
"hero_1": "Oppdag verdens mest spennende eventyr", "hero_1": "Oppdag verdens mest spennende eventyr",
@ -302,7 +310,8 @@
"visit_calendar": "Besøk kalenderen", "visit_calendar": "Besøk kalenderen",
"wiki_location_desc": "Trekker utdrag fra Wikipedia -artikkelen som samsvarer med navnet på stedet.", "wiki_location_desc": "Trekker utdrag fra Wikipedia -artikkelen som samsvarer med navnet på stedet.",
"will_be_marked_location": "vil bli merket som besøkt når stedet er lagret.", "will_be_marked_location": "vil bli merket som besøkt når stedet er lagret.",
"no_locations_found": "Ingen steder funnet" "no_locations_found": "Ingen steder funnet",
"image_modal_navigate": "Bruk piltastene eller klikk for å navigere"
}, },
"worldtravel": { "worldtravel": {
"country_list": "Liste over land", "country_list": "Liste over land",
@ -497,7 +506,36 @@
"social_auth_setup": "Sosial autentiseringsoppsett", "social_auth_setup": "Sosial autentiseringsoppsett",
"staff_status": "Personalstatus", "staff_status": "Personalstatus",
"staff_user": "Personalbruker", "staff_user": "Personalbruker",
"invalid_credentials": "Ugyldig legitimasjon" "invalid_credentials": "Ugyldig legitimasjon",
"backup_restore": "Sikkerhetskopi",
"backup_restore_desc": "Lagre dataene dine eller gjenopprett dem fra en tidligere sikkerhetskopifil.",
"complete_setup_to_enable": "Komplett oppsett for å aktivere MFA",
"copy_all": "Kopier alle",
"enter_6_digit_code": "Skriv inn 6 -sifret kode",
"enter_code_from_app": "Skriv inn kode fra appen",
"error_occurred": "Det har oppstått en feil",
"important": "Viktig",
"manual_entry": "Manuell oppføring",
"mfa_already_enabled": "MFA er allerede aktivert",
"mfa_required": "MFA kreves",
"scan_qr_code": "Skann QR -kode",
"scan_with_authenticator_app": "Skann med Authenticator -appen",
"secure_your_account": "Sikre kontoen din",
"setup_required": "Oppsett kreves",
"verify_setup": "Bekreft oppsett",
"whats_included": "Hva som er inkludert",
"backup_your_data": "Sikkerhetskopiere dataene dine",
"backup_your_data_desc": "Last ned en komplett sikkerhetskopi av kontodataene dine, inkludert steder, samlinger, media og besøk.",
"data_override_acknowledge": "Jeg erkjenner at dette vil overstyre alle mine eksisterende data",
"data_override_acknowledge_desc": "Denne handlingen er irreversibel og vil erstatte alle steder, samlinger og besøk på kontoen din.",
"data_override_warning": "Data overstyrer advarsel",
"data_override_warning_desc": "Gjenoppretting av data vil erstatte alle eksisterende data fullstendig (som er inkludert i sikkerhetskopien) på kontoen din. \nDenne handlingen kan ikke angres.",
"integrations_settings": "Integrasjoner Innstillinger",
"media": "Media",
"restore_data": "Gjenopprett data",
"restore_data_desc": "Last opp en sikkerhetskopifil for å gjenopprette dataene dine.",
"select_backup_file": "Velg sikkerhetskopifil",
"world_travel_visits": "Verdens reisebesøk"
}, },
"collection": { "collection": {
"collection_created": "Samling opprettet!", "collection_created": "Samling opprettet!",
@ -722,5 +760,6 @@
"location": "Sted", "location": "Sted",
"locations": "Lokasjoner", "locations": "Lokasjoner",
"my_locations": "Mine lokasjoner" "my_locations": "Mine lokasjoner"
} },
"settings_download_backup": "Last ned sikkerhetskopi"
} }

View file

@ -43,7 +43,15 @@
"nominatim_2": "Ich dane są licencjonowane na licencji ODbL.", "nominatim_2": "Ich dane są licencjonowane na licencji ODbL.",
"other_attributions": "Dodatkowe atrybucje można znaleźć w pliku README.", "other_attributions": "Dodatkowe atrybucje można znaleźć w pliku README.",
"close": "Zamknij", "close": "Zamknij",
"generic_attributions": "Zaloguj się do Adventurelog, aby wyświetlić atrybucje dla włączonych integracji i usług." "generic_attributions": "Zaloguj się do Adventurelog, aby wyświetlić atrybucje dla włączonych integracji i usług.",
"attributions": "Przypisania",
"developer": "Wywoływacz",
"license_info": "Licencja",
"sponsor": "Sponsor",
"thank_you": "Dziękujemy za korzystanie z Adventurelog!",
"version": "Wersja",
"view_changelog": "Zobacz Changelog",
"view_license": "Wyświetl licencję"
}, },
"home": { "home": {
"hero_1": "Odkryj najbardziej ekscytujące podróże na świecie", "hero_1": "Odkryj najbardziej ekscytujące podróże na świecie",
@ -302,7 +310,8 @@
"visit_calendar": "Odwiedź kalendarz", "visit_calendar": "Odwiedź kalendarz",
"wiki_location_desc": "Wyciąga fragment artykułu Wikipedii pasujący do nazwy lokalizacji.", "wiki_location_desc": "Wyciąga fragment artykułu Wikipedii pasujący do nazwy lokalizacji.",
"will_be_marked_location": "zostanie oznaczone jako odwiedzone po zapisaniu lokalizacji.", "will_be_marked_location": "zostanie oznaczone jako odwiedzone po zapisaniu lokalizacji.",
"no_locations_found": "Nie znaleziono żadnych lokalizacji" "no_locations_found": "Nie znaleziono żadnych lokalizacji",
"image_modal_navigate": "Użyj klawiszy strzałek lub kliknij, aby nawigować"
}, },
"worldtravel": { "worldtravel": {
"country_list": "Lista krajów", "country_list": "Lista krajów",
@ -497,7 +506,36 @@
"social_auth_setup": "Konfiguracja uwierzytelniania społecznego", "social_auth_setup": "Konfiguracja uwierzytelniania społecznego",
"staff_status": "Status personelu", "staff_status": "Status personelu",
"staff_user": "Użytkownik personelu", "staff_user": "Użytkownik personelu",
"invalid_credentials": "Nieprawidłowe poświadczenia" "invalid_credentials": "Nieprawidłowe poświadczenia",
"backup_restore": "Kopia zapasowa",
"backup_restore_desc": "Zapisz dane lub przywróć je z poprzedniego pliku kopii zapasowej.",
"complete_setup_to_enable": "Pełna konfiguracja, aby włączyć MFA",
"copy_all": "Kopiuj wszystko",
"enter_6_digit_code": "Wprowadź 6 -cyfrowy kod",
"enter_code_from_app": "Wprowadź kod z aplikacji",
"error_occurred": "Wystąpił błąd",
"important": "Ważny",
"manual_entry": "Wpis ręczny",
"mfa_already_enabled": "MFA już włączona",
"mfa_required": "Wymagane MSZ",
"scan_qr_code": "Skanuj kod QR",
"scan_with_authenticator_app": "Skanuj za pomocą aplikacji Authenticator",
"secure_your_account": "Zabezpiecz swoje konto",
"setup_required": "Wymagana konfiguracja",
"verify_setup": "Sprawdź konfigurację",
"whats_included": "Co jest uwzględnione",
"backup_your_data": "Kopie wykonuj kopię zapasową danych",
"backup_your_data_desc": "Pobierz pełną kopię zapasową danych konta, w tym lokalizacji, kolekcji, mediów i wizyt.",
"data_override_acknowledge": "Przyjmuję do wiadomości, że zastąpi to wszystkie moje istniejące dane",
"data_override_acknowledge_desc": "Ta akcja jest nieodwracalna i zastąpi wszystkie lokalizacje, kolekcje i wizyty na Twoim koncie.",
"data_override_warning": "Zastąpienie danych ostrzeżenia",
"data_override_warning_desc": "Przywracanie danych całkowicie zastąpi wszystkie istniejące dane (które są zawarte w tworzeniu kopii zapasowej) na Twoim koncie. \nTego działania nie można cofnąć.",
"integrations_settings": "Ustawienia integracji",
"media": "Głoska bezdźwięczna",
"restore_data": "Przywróć dane",
"restore_data_desc": "Prześlij plik kopii zapasowej, aby przywrócić dane.",
"select_backup_file": "Wybierz plik kopii zapasowej",
"world_travel_visits": "Wizyty podróży na świecie"
}, },
"collection": { "collection": {
"collection_created": "Kolekcja została pomyślnie utworzona!", "collection_created": "Kolekcja została pomyślnie utworzona!",
@ -722,5 +760,6 @@
"location": "Lokalizacja", "location": "Lokalizacja",
"locations": "Lokalizacje", "locations": "Lokalizacje",
"my_locations": "Moje lokalizacje" "my_locations": "Moje lokalizacje"
} },
"settings_download_backup": "Pobierz kopię zapasową"
} }

View file

@ -43,7 +43,15 @@
"nominatim_2": "Их данные лицензированы под лицензией ODbL.", "nominatim_2": "Их данные лицензированы под лицензией ODbL.",
"other_attributions": "Дополнительные атрибуции можно найти в файле README.", "other_attributions": "Дополнительные атрибуции можно найти в файле README.",
"generic_attributions": "Войдите в AdventureLog, чтобы просмотреть атрибуции для включённых интеграций и сервисов.", "generic_attributions": "Войдите в AdventureLog, чтобы просмотреть атрибуции для включённых интеграций и сервисов.",
"close": "Закрыть" "close": "Закрыть",
"attributions": "Атрибуты",
"developer": "Разработчик",
"license_info": "Лицензия",
"sponsor": "Спонсор",
"thank_you": "Спасибо за использование AdventureLog!",
"version": "Версия",
"view_changelog": "Посмотреть изменение изменений",
"view_license": "Просмотреть лицензию"
}, },
"home": { "home": {
"hero_1": "Откройте для себя самые захватывающие приключения мира", "hero_1": "Откройте для себя самые захватывающие приключения мира",
@ -302,7 +310,8 @@
"visit_calendar": "Посетите календарь", "visit_calendar": "Посетите календарь",
"wiki_location_desc": "Вытягивает отрывок из статьи Википедии, соответствующей названию места.", "wiki_location_desc": "Вытягивает отрывок из статьи Википедии, соответствующей названию места.",
"will_be_marked_location": "будет отмечен по посещению после сохранения местоположения.", "will_be_marked_location": "будет отмечен по посещению после сохранения местоположения.",
"no_locations_found": "Никаких мест не найдено" "no_locations_found": "Никаких мест не найдено",
"image_modal_navigate": "Используйте клавиши со стрелками или нажмите, чтобы перемещаться"
}, },
"worldtravel": { "worldtravel": {
"country_list": "Список стран", "country_list": "Список стран",
@ -497,7 +506,36 @@
"enter_new_password": "Введите новый пароль", "enter_new_password": "Введите новый пароль",
"connected": "Подключено", "connected": "Подключено",
"disconnected": "Отключено", "disconnected": "Отключено",
"invalid_credentials": "Неверные полномочия" "invalid_credentials": "Неверные полномочия",
"backup_restore": "Резервная копия",
"backup_restore_desc": "Сохраните данные или восстановите их из предыдущего файла резервного копирования.",
"complete_setup_to_enable": "Полная установка, чтобы включить MFA",
"copy_all": "Копировать все",
"enter_6_digit_code": "Введите 6 -значный код",
"enter_code_from_app": "Введите код из приложения",
"error_occurred": "Произошла ошибка",
"important": "Важный",
"manual_entry": "Ручная запись",
"mfa_already_enabled": "MFA уже включен",
"mfa_required": "MFA требуется",
"scan_qr_code": "Сканировать QR -код",
"scan_with_authenticator_app": "Сканирование с помощью приложения аутентификатора",
"secure_your_account": "Защитите свою учетную запись",
"setup_required": "Настройка требуется",
"verify_setup": "Проверьте настройку",
"whats_included": "Что включено",
"backup_your_data": "Резервную копию ваших данных",
"backup_your_data_desc": "Загрузите полное резервное копирование данных вашей учетной записи, включая местоположения, коллекции, медиа и посещения.",
"data_override_acknowledge": "Я признаю, что это переопределит все мои существующие данные",
"data_override_acknowledge_desc": "Это действие необратимо и заменит все местоположения, коллекции и посещения в вашем аккаунте.",
"data_override_warning": "Предупреждение о переопределении данных",
"data_override_warning_desc": "Восстановление данных полностью заменит все существующие данные (которые включены в резервную копию) в вашу учетную запись. \nЭто действие не может быть отменено.",
"integrations_settings": "Настройки интеграции",
"media": "СМИ",
"restore_data": "Восстановить данные",
"restore_data_desc": "Загрузите файл резервного копирования, чтобы восстановить ваши данные.",
"select_backup_file": "Выберите файл резервного копирования",
"world_travel_visits": "Всемирные поездки"
}, },
"collection": { "collection": {
"collection_created": "Коллекция успешно создана!", "collection_created": "Коллекция успешно создана!",
@ -722,5 +760,6 @@
"location": "Расположение", "location": "Расположение",
"locations": "Локации", "locations": "Локации",
"my_locations": "Мои локации" "my_locations": "Мои локации"
} },
"settings_download_backup": "Скачать резервную копию"
} }

View file

@ -9,7 +9,15 @@
"oss_attributions": "Tillskrivningar med öppen källkod", "oss_attributions": "Tillskrivningar med öppen källkod",
"other_attributions": "Ytterligare attributioner finns i README-filen.", "other_attributions": "Ytterligare attributioner finns i README-filen.",
"source_code": "Källkod", "source_code": "Källkod",
"generic_attributions": "Logga in på AdventureLog för att visa attribut för aktiverade integrationer och tjänster." "generic_attributions": "Logga in på AdventureLog för att visa attribut för aktiverade integrationer och tjänster.",
"attributions": "Tillskrivningar",
"developer": "Framkallare",
"license_info": "Licens",
"sponsor": "Sponsor",
"thank_you": "Tack för att du använder AdventureLog!",
"version": "Version",
"view_changelog": "Visa Changelog",
"view_license": "Visa licens"
}, },
"adventures": { "adventures": {
"activities": {}, "activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Besök kalendern", "visit_calendar": "Besök kalendern",
"wiki_location_desc": "Drar utdrag från Wikipedia -artikeln som matchar namnet på platsen.", "wiki_location_desc": "Drar utdrag från Wikipedia -artikeln som matchar namnet på platsen.",
"will_be_marked_location": "kommer att markeras som besöks när platsen har sparats.", "will_be_marked_location": "kommer att markeras som besöks när platsen har sparats.",
"no_locations_found": "Inga platser hittades" "no_locations_found": "Inga platser hittades",
"image_modal_navigate": "Använd pilnycklar eller klicka för att navigera"
}, },
"home": { "home": {
"desc_1": "Upptäck, planera och utforska med lätthet", "desc_1": "Upptäck, planera och utforska med lätthet",
@ -497,7 +506,36 @@
"social_auth_setup": "Social autentiseringsinställning", "social_auth_setup": "Social autentiseringsinställning",
"staff_status": "Personalstatus", "staff_status": "Personalstatus",
"staff_user": "Personalanvändare", "staff_user": "Personalanvändare",
"invalid_credentials": "Ogiltiga referenser" "invalid_credentials": "Ogiltiga referenser",
"backup_restore": "Säkerhetskopiering",
"backup_restore_desc": "Spara dina data eller återställa dem från en tidigare säkerhetskopieringsfil.",
"complete_setup_to_enable": "Komplett installation för att aktivera MFA",
"copy_all": "Kopiera alla",
"enter_6_digit_code": "Ange 6 siffror",
"enter_code_from_app": "Ange kod från appen",
"error_occurred": "Ett fel har inträffat",
"important": "Viktig",
"manual_entry": "Manuell inträde",
"mfa_already_enabled": "MFA redan aktiverat",
"mfa_required": "MFA krävs",
"scan_qr_code": "Skanna QR -kod",
"scan_with_authenticator_app": "Skanna med autentisatorapp",
"secure_your_account": "Säkra ditt konto",
"setup_required": "Installation krävs",
"verify_setup": "Verifiera installationen",
"whats_included": "Vad ingår",
"backup_your_data": "Säkerhetskopiera dina data",
"backup_your_data_desc": "Ladda ner en fullständig säkerhetskopia av dina kontodata inklusive platser, samlingar, media och besök.",
"data_override_acknowledge": "Jag erkänner att detta kommer att åsidosätta alla mina befintliga uppgifter",
"data_override_acknowledge_desc": "Denna åtgärd är irreversibel och kommer att ersätta alla platser, samlingar och besök på ditt konto.",
"data_override_warning": "Data åsidosättande varning",
"data_override_warning_desc": "Återställa data kommer helt att ersätta alla befintliga data (som ingår i säkerhetskopian) i ditt konto. \nDenna åtgärd kan inte ångras.",
"integrations_settings": "Integrationsinställningar",
"media": "Media",
"restore_data": "Återställa data",
"restore_data_desc": "Ladda upp en säkerhetskopieringsfil för att återställa dina data.",
"select_backup_file": "Välj säkerhetskopieringsfil",
"world_travel_visits": "Världsresebesök"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "Ett fel uppstod vid borttagning av checklista", "checklist_delete_error": "Ett fel uppstod vid borttagning av checklista",
@ -722,5 +760,6 @@
"location": "Plats", "location": "Plats",
"locations": "Plats", "locations": "Plats",
"my_locations": "Mina platser" "my_locations": "Mina platser"
} },
"settings_download_backup": "Ladda ner säkerhetskopiering"
} }

View file

@ -43,7 +43,15 @@
"other_attributions": "其他声明可以在 README 文件中找到。", "other_attributions": "其他声明可以在 README 文件中找到。",
"source_code": "源代码", "source_code": "源代码",
"close": "关闭", "close": "关闭",
"generic_attributions": "登录到AdventureLog以查看启用集成和服务的归因。" "generic_attributions": "登录到AdventureLog以查看启用集成和服务的归因。",
"attributions": "归因",
"developer": "开发人员",
"license_info": "执照",
"sponsor": "赞助",
"thank_you": "感谢您使用冒险!",
"version": "版本",
"view_changelog": "查看ChangElog",
"view_license": "查看许可证"
}, },
"home": { "home": {
"desc_1": "轻松发现、规划和探索", "desc_1": "轻松发现、规划和探索",
@ -302,7 +310,8 @@
"visit_calendar": "访问日历", "visit_calendar": "访问日历",
"wiki_location_desc": "从Wikipedia文章中提取摘录符合该位置的名称。", "wiki_location_desc": "从Wikipedia文章中提取摘录符合该位置的名称。",
"will_be_marked_location": "保存位置后,将被标记为访问。", "will_be_marked_location": "保存位置后,将被标记为访问。",
"no_locations_found": "找不到位置" "no_locations_found": "找不到位置",
"image_modal_navigate": "使用箭头键或单击以导航"
}, },
"auth": { "auth": {
"forgot_password": "忘记密码?", "forgot_password": "忘记密码?",
@ -497,7 +506,36 @@
"quick_actions": "快速动作", "quick_actions": "快速动作",
"region_updates": "区域更新", "region_updates": "区域更新",
"region_updates_desc": "更新访问了地区和城市", "region_updates_desc": "更新访问了地区和城市",
"invalid_credentials": "无效的凭据" "invalid_credentials": "无效的凭据",
"backup_restore": "备份",
"backup_restore_desc": "保存数据或从以前的备份文件还原。",
"complete_setup_to_enable": "完整的设置以启用MFA",
"copy_all": "复制全部",
"enter_6_digit_code": "输入6位数代码",
"enter_code_from_app": "从应用程序输入代码",
"error_occurred": "发生了错误",
"important": "重要的",
"manual_entry": "手动输入",
"mfa_already_enabled": "MFA已经启用",
"mfa_required": "需要MFA",
"scan_qr_code": "扫描QR码",
"scan_with_authenticator_app": "使用身份验证器应用程序扫描",
"secure_your_account": "保护您的帐户",
"setup_required": "需要设置",
"verify_setup": "验证设置",
"whats_included": "包括什么",
"backup_your_data": "备份您的数据",
"backup_your_data_desc": "下载您的帐户数据的完整备份,包括位置,集合,媒体和访问。",
"data_override_acknowledge": "我承认这将覆盖我所有现有数据",
"data_override_acknowledge_desc": "此操作是不可逆转的,将取代您帐户中的所有位置,收集和访问。",
"data_override_warning": "数据覆盖警告",
"data_override_warning_desc": "还原数据将完全替换帐户中的所有现有数据(备份中包含在备份中)。\n该动作不能撤消。",
"integrations_settings": "集成设置",
"media": "媒体",
"restore_data": "还原数据",
"restore_data_desc": "上传备份文件以还原数据。",
"select_backup_file": "选择备份文件",
"world_travel_visits": "世界旅行访问"
}, },
"checklist": { "checklist": {
"checklist_delete_error": "删除清单时出错", "checklist_delete_error": "删除清单时出错",
@ -722,5 +760,6 @@
"location": "地点", "location": "地点",
"locations": "位置", "locations": "位置",
"my_locations": "我的位置" "my_locations": "我的位置"
} },
"settings_download_backup": "下载备份"
} }

View file

@ -85,7 +85,7 @@ async function handleRequest(
}); });
} }
const responseData = await response.text(); const responseData = await response.arrayBuffer();
// Create a new Headers object without the 'set-cookie' header // Create a new Headers object without the 'set-cookie' header
const cleanHeaders = new Headers(response.headers); const cleanHeaders = new Headers(response.headers);
cleanHeaders.delete('set-cookie'); cleanHeaders.delete('set-cookie');

View file

@ -88,7 +88,9 @@
let notFound: boolean = false; let notFound: boolean = false;
let isEditModalOpen: boolean = false; let isEditModalOpen: boolean = false;
let image_url: string | null = null; let adventure_images: { image: string; adventure: AdditionalLocation | null }[] = [];
let modalInitialIndex: number = 0;
let isImageModalOpen: boolean = false;
onMount(async () => { onMount(async () => {
if (data.props.adventure) { if (data.props.adventure) {
@ -114,6 +116,19 @@
geojson = null; geojson = null;
await getGpxFiles(); await getGpxFiles();
} }
function closeImageModal() {
isImageModalOpen = false;
}
function openImageModal(imageIndex: number) {
adventure_images = adventure.images.map(img => ({
image: img.image,
adventure: adventure
}));
modalInitialIndex = imageIndex;
isImageModalOpen = true;
}
</script> </script>
{#if notFound} {#if notFound}
@ -139,8 +154,12 @@
/> />
{/if} {/if}
{#if image_url} {#if isImageModalOpen}
<ImageDisplayModal image={image_url} on:close={() => (image_url = null)} {adventure} /> <ImageDisplayModal
images={adventure_images}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
/>
{/if} {/if}
{#if !adventure && !notFound} {#if !adventure && !notFound}
@ -176,7 +195,7 @@
> >
<button <button
class="w-full h-full p-0 bg-transparent border-0" class="w-full h-full p-0 bg-transparent border-0"
on:click={() => (image_url = image.image)} on:click={() => openImageModal(i)}
aria-label={`View full image of ${adventure.name}`} aria-label={`View full image of ${adventure.name}`}
> >
<img src={image.image} class="w-full h-full object-cover" alt={adventure.name} /> <img src={image.image} class="w-full h-full object-cover" alt={adventure.name} />
@ -728,13 +747,13 @@
<div class="card-body"> <div class="card-body">
<h3 class="card-title text-lg mb-4">🖼️ {$t('adventures.images')}</h3> <h3 class="card-title text-lg mb-4">🖼️ {$t('adventures.images')}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each adventure.images as image} {#each adventure.images as image, index}
<div class="relative group"> <div class="relative group">
<div <div
class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105" class="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105"
style="background-image: url({image.image})" style="background-image: url({image.image})"
on:click={() => (image_url = image.image)} on:click={() => openImageModal(index)}
on:keydown={(e) => e.key === 'Enter' && (image_url = image.image)} on:keydown={(e) => e.key === 'Enter' && openImageModal(index)}
role="button" role="button"
tabindex="0" tabindex="0"
></div> ></div>

View file

@ -262,7 +262,7 @@ export const actions: Actions = {
return { success: true }; return { success: true };
} }
}, },
changeEmail: async (event) => { restoreData: async (event) => {
if (!event.locals.user) { if (!event.locals.user) {
return redirect(302, '/'); return redirect(302, '/');
} }
@ -270,28 +270,51 @@ export const actions: Actions = {
if (!sessionId) { if (!sessionId) {
return redirect(302, '/'); return redirect(302, '/');
} }
const formData = await event.request.formData();
const new_email = formData.get('new_email') as string | null | undefined; try {
if (!new_email) { const formData = await event.request.formData();
return fail(400, { message: 'auth.email_required' }); const file = formData.get('file') as File | null | undefined;
} else { const confirm = formData.get('confirm') as string | null | undefined;
if (!file || file.size === 0) {
return fail(400, { message: 'settings.no_file_selected' });
}
if (confirm !== 'yes') {
return fail(400, { message: 'settings.confirmation_required' });
}
let csrfToken = await fetchCSRFToken(); let csrfToken = await fetchCSRFToken();
let res = await fetch(`${endpoint}/auth/change-email/`, {
// Create FormData for the API request
const apiFormData = new FormData();
apiFormData.append('file', file);
apiFormData.append('confirm', 'yes');
let res = await fetch(`${endpoint}/api/backup/import/`, {
method: 'POST', method: 'POST',
headers: { headers: {
Referer: event.url.origin, // Include Referer header Referer: event.url.origin,
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken 'X-CSRFToken': csrfToken
}, },
body: JSON.stringify({ body: apiFormData
new_email
})
}); });
if (!res.ok) { if (!res.ok) {
return fail(res.status, await res.json()); const errorData = await res.json();
return fail(res.status, {
message: errorData.code
? `settings.restore_error_${errorData.code}`
: 'settings.generic_error',
details: errorData
});
} }
return { success: true }; return { success: true };
} catch (error) {
console.error('Restore error:', error);
return fail(500, { message: 'settings.generic_error' });
} }
} }
}; };

View file

@ -20,12 +20,12 @@
emails = data.props.emails; emails = data.props.emails;
} }
let new_password_disable_setting: boolean = false;
let new_email: string = ''; let new_email: string = '';
let public_url: string = data.props.publicUrl; let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration; let immichIntegration = data.props.immichIntegration;
let googleMapsEnabled = data.props.googleMapsEnabled; let googleMapsEnabled = data.props.googleMapsEnabled;
let activeSection: string = 'profile'; let activeSection: string = 'profile';
let acknowledgeRestoreOverride: boolean = false;
let newImmichIntegration: ImmichIntegration = { let newImmichIntegration: ImmichIntegration = {
server_url: '', server_url: '',
@ -41,6 +41,7 @@
{ id: 'security', icon: '🔒', label: () => $t('settings.security') }, { id: 'security', icon: '🔒', label: () => $t('settings.security') },
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') }, { id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') }, { id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') }, { id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') } { id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
]; ];
@ -924,6 +925,198 @@
</div> </div>
{/if} {/if}
<!-- import export -->
{#if activeSection === 'import_export'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-accent/10 rounded-xl">
<span class="text-2xl">📦</span>
</div>
<div>
<div>
<h2 class="text-2xl font-bold">{$t('settings.backup_restore')}</h2>
<p class="text-base-content/70">
{$t('settings.backup_restore_desc')}
</p>
</div>
</div>
</div>
<!-- Backup Coverage -->
<div class="bg-base-200 rounded-xl p-4 mb-6">
<h4 class="text-sm font-semibold mb-3 text-base-content/70">
{$t('settings.whats_included')}
</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<!-- Backed Up -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span>📍 {$t('locations.locations')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>🚶 {$t('adventures.visits')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>📚 {$t('navbar.collections')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>🖼️ {$t('settings.media')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>🌍 {$t('settings.world_travel_visits')}</span>
<span></span>
</div>
</div>
<!-- Not Backed Up -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span>⚙️ {$t('navbar.settings')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>👤 {$t('navbar.profile')}</span>
<span></span>
</div>
<div class="flex items-center justify-between">
<span>🔗 {$t('settings.integrations_settings')}</span>
<span></span>
</div>
<div class="flex items-center justify-between opacity-30">
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<div class="space-y-6">
<!-- Backup Data -->
<div class="p-6 bg-base-200 rounded-xl">
<h3 class="text-lg font-semibold mb-4">📤 {$t('settings.backup_your_data')}</h3>
<p class="text-base-content/70 mb-4">
{$t('settings.backup_your_data_desc')}
</p>
<div class="flex gap-4">
<a class="btn btn-primary" href="/api/backup/export">
💾 {$t('settings_download_backup')}
</a>
</div>
</div>
<!-- Restore Data -->
<div class="p-6 bg-base-200 rounded-xl">
<h3 class="text-lg font-semibold mb-4">📥 {$t('settings.restore_data')}</h3>
<p class="text-base-content/70 mb-4">
{$t('settings.restore_data_desc')}
</p>
<!-- Warning Alert -->
<div class="alert alert-warning mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<div>
<h4 class="font-bold">⚠️ {$t('settings.data_override_warning')}</h4>
<p class="text-sm">
{$t('settings.data_override_warning_desc')}
</p>
</div>
</div>
<!-- File Upload Form -->
<form
method="post"
action="?/restoreData"
use:enhance
enctype="multipart/form-data"
class="space-y-4"
>
<div class="form-control">
<label class="label" for="backup-file">
<span class="label-text font-medium"
>{$t('settings.select_backup_file')}</span
>
</label>
<input
type="file"
name="file"
id="backup-file"
class="file-input file-input-bordered file-input-primary w-full"
accept=".zip"
required
/>
</div>
<!-- Acknowledgment Checkbox -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="confirm"
value="yes"
class="checkbox checkbox-warning"
required
bind:checked={acknowledgeRestoreOverride}
/>
<div>
<span class="label-text font-medium text-warning"
>{$t('settings.data_override_acknowledge')}</span
>
<p class="text-xs text-base-content/60 mt-1">
{$t('settings.data_override_acknowledge_desc')}
</p>
</div>
</label>
</div>
{#if $page.form?.message && $page.form?.message.includes('restore')}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t($page.form?.message)}</span>
</div>
{/if}
<div class="flex gap-4">
<button
type="submit"
class="btn btn-warning"
disabled={!acknowledgeRestoreOverride}
>
🚀 {$t('settings.restore_data')}
</button>
</div>
</form>
</div>
</div>
</div>
{/if}
<!-- Admin Section --> <!-- Admin Section -->
{#if activeSection === 'admin' && user.is_staff} {#if activeSection === 'admin' && user.is_staff}
<div class="bg-base-100 rounded-2xl shadow-xl p-8"> <div class="bg-base-100 rounded-2xl shadow-xl p-8">