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
from django.core.management.base import BaseCommand
from django.conf import settings
from adventures.models import AdventureImage, Attachment
from adventures.models import LocationImage, Attachment
from users.models import CustomUser
@ -21,8 +21,8 @@ class Command(BaseCommand):
# Get all image and attachment file paths from database
used_files = set()
# Get AdventureImage file paths
for img in AdventureImage.objects.all():
# Get LocationImage file paths
for img in LocationImage.objects.all():
if img.image and 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'attachments', AttachmentViewSet, basename='attachments')
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 = [
# Include the router under the 'api/' prefix

View file

@ -13,4 +13,5 @@ from .transportation_view import *
from .global_search_view import *
from .attachment_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
# 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
# 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)
else:
# 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({
'message': 'Image is not linked to any location and you are not the owner.',
'error': True,

View file

@ -326,6 +326,9 @@ LOGGING = {
# 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
COUNTRY_REGION_JSON_VERSION = 'v2.6'

View file

@ -33,146 +33,201 @@
}
</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-element-interactions -->
<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"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- Branding -->
<div class="text-center">
<h3
class="text-2xl font-extrabold text-gray-800 dark:text-white flex items-center justify-center"
>
{$t('about.about')} AdventureLog
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" />
</h3>
<p class="mt-2 text-gray-500 dark:text-gray-300 text-sm">
AdventureLog
<a
href={versionChangelog}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{appVersion}
</a>
</p>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<img src="/favicon.png" alt="AdventureLog" class="w-12 h-12" />
</div>
<div>
<h1 class="text-2xl font-bold text-primary">
{$t('about.about')} AdventureLog
</h1>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" 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>
</button>
</div>
<!-- Links and Details -->
<div class="mt-4 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
Sean Morley
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{$t('about.source_code')}
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p>
</div>
<!-- Content -->
<div class="space-y-4">
<!-- Version & Developer Info -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">{$t('about.version')}</div>
<div class="text-lg font-bold text-primary">{appVersion}</div>
<a
href={versionChangelog}
target="_blank"
rel="noopener noreferrer"
class="text-sm link link-primary"
>
{$t('about.view_changelog')}
</a>
</div>
<div>
<div class="text-sm text-base-content/60">{$t('about.developer')}</div>
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold link link-primary"
>
Sean Morley
</a>
<div class="text-sm text-base-content/60">{$t('about.message')}</div>
</div>
</div>
</div>
</div>
<!-- Divider -->
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div>
<!-- Map Services -->
<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
href="https://developers.google.com/maps/terms"
href="https://github.com/seanmorley15/AdventureLog/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
class="link link-primary mt-2"
>
Google Maps
{$t('about.view_license')}
</a>
.
</p>
{: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}
</div>
</div>
<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>
<!-- Close Button -->
<div class="flex justify-center mt-6">
<button
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"
on:click={close}
>
<!-- Footer -->
<div class="flex items-center justify-between mt-6 pt-4 border-t border-base-300">
<div class="text-sm text-base-content/60">
{$t('about.thank_you')}
</div>
<button class="btn btn-primary btn-sm" on:click={close}>
{$t('about.close')}
</button>
</div>
</div>
</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[] = [];
let currentSlide = 0;
let image_url: string | null = null;
let showImageModal = false;
let modalInitialIndex = 0;
$: adventure_images = adventures.flatMap((adventure) =>
adventure.images.map((image) => ({
@ -42,13 +43,22 @@
currentSlide = currentSlide - 1;
}
}
function openImageModal(initialIndex: number = currentSlide) {
modalInitialIndex = initialIndex;
showImageModal = true;
}
function closeImageModal() {
showImageModal = false;
}
</script>
{#if image_url}
{#if showImageModal && adventure_images.length > 0}
<ImageDisplayModal
adventure={adventure_images[currentSlide].adventure}
image={image_url}
on:close={() => (image_url = null)}
images={adventure_images}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
/>
{/if}
@ -61,14 +71,46 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->
<a
on:click|stopPropagation={() => (image_url = adventure_images[currentSlide].image)}
class="cursor-pointer"
on:click|stopPropagation={() => openImageModal(currentSlide)}
class="cursor-pointer relative group"
>
<img
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}
/>
<!-- 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>
{#if adventure_images.length > 1}
@ -76,8 +118,18 @@
{#if currentSlide > 0}
<button
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}
<div class="w-12"></div>
{/if}
@ -85,17 +137,43 @@
{#if currentSlide < adventure_images.length - 1}
<button
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}
<div class="w-12"></div>
{/if}
</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}
</div>
</div>
{: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">
<!-- subtle button bottom left text
<div

View file

@ -5,14 +5,21 @@
let modal: HTMLDialogElement;
import type { Location } from '$lib/types';
export let image: string;
export let adventure: Location | null = null;
export let images: { image: string; adventure: Location | 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(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
// Set initial values
updateCurrentSlide(initialIndex);
});
function close() {
@ -25,6 +32,10 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
} else if (event.key === 'ArrowLeft') {
previousSlide();
} else if (event.key === 'ArrowRight') {
nextSlide();
}
}
@ -33,44 +44,228 @@
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>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<dialog id="my_modal_1" class="modal" on:click={handleClickOutside}>
<dialog id="my_modal_1" class="modal backdrop-blur-sm" on:click={handleClickOutside}>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
{#if adventure}
<div class="modal-header flex justify-between items-center mb-4">
<h3 class="font-bold text-2xl">{adventure.name}</h3>
<button class="btn btn-circle btn-neutral" on:click={close}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
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
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"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
{#if currentAdventure && currentImage}
<!-- Header -->
<div
class="flex justify-center items-center"
style="display: flex; justify-content: center; align-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"
>
<img
src={image}
alt={adventure.name}
style="max-width: 100%; max-height: 75vh; object-fit: contain;"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<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>
{/if}
</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 loading = false;
export let adventure: Location | null = null;
export let location: Location | null = null;
export let copyImmichLocally: boolean = false;
const dispatch = createEventDispatcher();
@ -21,7 +21,7 @@
let currentAlbum: string = '';
let selectedDate: string =
(adventure as Location | null)?.visits
(location as Location | null)?.visits
.map((v) => new Date(v.end_date || v.start_date))
.sort((a, b) => +b - +a)[0]
?.toISOString()
@ -57,8 +57,8 @@
}
async function saveImmichRemoteUrl(imageId: string) {
if (!adventure) {
console.error('No adventure provided to save the image URL');
if (!location) {
console.error('No location provided to save the image URL');
return;
}
let res = await fetch('/api/images', {
@ -68,7 +68,7 @@
},
body: JSON.stringify({
immich_id: imageId,
adventure: adventure.id
location: location.id
})
});
if (res.ok) {

View file

@ -273,7 +273,7 @@
{#if type != 'link'}
<div class="flex justify-between items-center">
<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}`)}
>
<Launch class="w-4 h-4" />

View file

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

View file

@ -17,6 +17,18 @@
export let is_enabled: boolean;
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(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
@ -113,72 +125,214 @@
}
</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-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('settings.enable_mfa')}</h3>
<div
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}
<div class="mb-4 flex items-center justify-center mt-2">
<img src={qrCodeDataUrl} alt="QR Code" class="w-64 h-64" />
<!-- Status Badge -->
<div class="hidden md:flex items-center gap-2">
<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>
{/if}
<div class="flex items-center justify-center mb-6">
</div>
<!-- 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}
<div class="flex items-center">
<input
type="text"
placeholder={secret}
class="input input-bordered w-full max-w-xs"
readonly
/>
<button class="btn btn-primary ml-2" on:click={() => copyToClipboard(secret)}
>{$t('settings.copy')}</button
>
<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">
<Key class="w-5 h-5 text-secondary" />
{$t('settings.manual_entry')}
</h3>
<div class="flex items-center gap-3">
<div class="flex-1">
<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>
{/if}
</div>
<input
type="text"
placeholder={$t('settings.authenticator_code')}
class="input input-bordered w-full max-w-xs"
bind:value={first_code}
/>
<div class="recovery-codes-container">
{#if recovery_codes.length > 0}
<h3 class="mt-4 text-center font-bold text-lg">{$t('settings.recovery_codes')}</h3>
<p class="text-center text-lg mb-2">
{$t('settings.recovery_codes_desc')}
</p>
<button
class="btn btn-primary ml-2"
on:click={() => copyToClipboard(recovery_codes.join(', '))}>{$t('settings.copy')}</button
>
{/if}
<div class="recovery-codes-grid flex flex-wrap">
{#each recovery_codes as code}
<div
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"
>
<input type="text" value={code} class="input input-bordered w-full" readonly />
</div>
{/each}
<!-- Footer Actions -->
<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">
{is_enabled
? $t('settings.mfa_already_enabled')
: $t('settings.complete_setup_to_enable')}
</div>
<div class="flex items-center gap-3">
{#if !is_enabled && first_code.length >= 6}
<button class="btn btn-success gap-2" on:click={sendTotp}>
<Shield class="w-4 h-4" />
{$t('settings.enable_mfa')}
</button>
{/if}
<button class="btn btn-primary gap-2" on:click={close}>
<Check class="w-4 h-4" />
{$t('about.close')}
</button>
</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>
</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 appTitle = 'AdventureLog';
export let copyrightYear = '2023-2025';

View file

@ -9,7 +9,15 @@
"oss_attributions": "Open Source Quellenangaben",
"other_attributions": "Weitere Hinweise finden Sie in der README-Datei.",
"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": {
"activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Besuchen Sie den Kalender",
"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.",
"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": {
"desc_1": "Entdecken, planen und erkunden Sie mühelos",
@ -497,7 +506,36 @@
"staff_status": "Personalstatus",
"staff_user": "Personalbenutzer",
"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_delete_error": "Fehler beim Löschen der Checkliste",
@ -722,5 +760,6 @@
"location": "Standort",
"locations": "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.",
"other_attributions": "Atribuciones adicionales se pueden encontrar en el archivo README.",
"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": {
"hero_1": "Descubre las Aventuras Más Emocionantes del Mundo",
@ -302,7 +310,8 @@
"visit_calendar": "Visitar el calendario",
"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.",
"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": {
"all": "Todo",
@ -497,7 +506,36 @@
"all_rights_reserved": "Reservados todos los derechos.",
"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",
"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_delete_error": "Error al eliminar la lista de tareas",
@ -722,5 +760,6 @@
"location": "Ubicación",
"locations": "Ubicación",
"my_locations": "Mis ubicaciones"
}
},
"settings_download_backup": "Descargar copia de seguridad"
}

View file

@ -9,7 +9,15 @@
"oss_attributions": "Attributions Open Source",
"other_attributions": "Des attributions supplémentaires peuvent être trouvées dans le fichier README.",
"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": {
"activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Visiter le calendrier",
"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é.",
"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": {
"desc_1": "Découvrez, planifiez et explorez en toute simplicité",
@ -497,7 +506,36 @@
"disconnected": "Déconnecté",
"email_management": "Gestion des e-mails",
"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_delete_error": "Erreur lors de la suppression de la liste de contrôle",
@ -722,5 +760,6 @@
"location": "Emplacement",
"locations": "Lieux",
"my_locations": "Mes emplacements"
}
},
"settings_download_backup": "Télécharger la sauvegarde"
}

View file

@ -9,7 +9,15 @@
"oss_attributions": "Attribuzioni Open Source",
"other_attributions": "Ulteriori attribuzioni possono essere trovate nel file README.",
"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": {
"activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Visita il calendario",
"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.",
"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": {
"desc_1": "Scopri, pianifica ed esplora con facilità",
@ -497,7 +506,36 @@
"staff_status": "Stato del personale",
"staff_user": "Utente del personale",
"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_delete_error": "Errore durante l'eliminazione della lista di controllo",
@ -722,5 +760,6 @@
"location": "Posizione",
"locations": "Luoghi",
"my_locations": "Le mie posizioni"
}
},
"settings_download_backup": "Scarica il backup"
}

View file

@ -9,7 +9,15 @@
"oss_attributions": "오픈 소스 속성",
"other_attributions": "추가 속성은 README 파일에서 찾을 수 있습니다.",
"source_code": "소스 코드",
"generic_attributions": "Adventurelog에 로그인하여 활성화 된 통합 및 서비스에 대한 속성을보십시오."
"generic_attributions": "Adventurelog에 로그인하여 활성화 된 통합 및 서비스에 대한 속성을보십시오.",
"attributions": "속성",
"developer": "개발자",
"license_info": "특허",
"sponsor": "스폰서",
"thank_you": "Adventurelog를 사용해 주셔서 감사합니다!",
"version": "버전",
"view_changelog": "changelog를 봅니다",
"view_license": "라이센스보기"
},
"adventures": {
"actions": "행동",
@ -250,7 +258,8 @@
"visit_calendar": "캘린더를 방문하십시오",
"wiki_location_desc": "위치 이름과 일치하는 Wikipedia 기사에서 발췌 한 내용을 가져옵니다.",
"will_be_marked_location": "위치가 저장되면 방문한대로 표시됩니다.",
"no_locations_found": "발견 된 위치는 없습니다"
"no_locations_found": "발견 된 위치는 없습니다",
"image_modal_navigate": "화살표 키를 사용하거나 클릭하여 탐색하십시오"
},
"auth": {
"confirm_password": "비밀번호 확인",
@ -589,7 +598,36 @@
"social_auth_setup": "소셜 인증 설정",
"staff_status": "직원 상태",
"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": {
"go_to_settings": "설정으로 이동",
@ -721,5 +759,6 @@
"location": "위치",
"locations": "위치",
"my_locations": "내 위치"
}
},
"settings_download_backup": "백업을 다운로드하십시오"
}

View file

@ -9,7 +9,15 @@
"oss_attributions": "Open source gebruik",
"other_attributions": "Aanvullende vermeldingen zijn te vinden in het README-bestand.",
"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": {
"activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Bezoek de agenda",
"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.",
"no_locations_found": "Geen locaties gevonden"
"no_locations_found": "Geen locaties gevonden",
"image_modal_navigate": "Gebruik pijltoetsen of klik om te navigeren"
},
"home": {
"desc_1": "Ontdek, plan en verken met gemak",
@ -497,7 +506,36 @@
"staff_status": "Status",
"staff_user": "Personeelsgebruiker",
"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_delete_error": "Fout bij het verwijderen van de checklist",
@ -722,5 +760,6 @@
"location": "Locatie",
"locations": "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.",
"other_attributions": "Ytterligere attribusjoner finnes i README-filen.",
"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": {
"hero_1": "Oppdag verdens mest spennende eventyr",
@ -302,7 +310,8 @@
"visit_calendar": "Besøk kalenderen",
"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.",
"no_locations_found": "Ingen steder funnet"
"no_locations_found": "Ingen steder funnet",
"image_modal_navigate": "Bruk piltastene eller klikk for å navigere"
},
"worldtravel": {
"country_list": "Liste over land",
@ -497,7 +506,36 @@
"social_auth_setup": "Sosial autentiseringsoppsett",
"staff_status": "Personalstatus",
"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_created": "Samling opprettet!",
@ -722,5 +760,6 @@
"location": "Sted",
"locations": "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.",
"other_attributions": "Dodatkowe atrybucje można znaleźć w pliku README.",
"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": {
"hero_1": "Odkryj najbardziej ekscytujące podróże na świecie",
@ -302,7 +310,8 @@
"visit_calendar": "Odwiedź kalendarz",
"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.",
"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": {
"country_list": "Lista krajów",
@ -497,7 +506,36 @@
"social_auth_setup": "Konfiguracja uwierzytelniania społecznego",
"staff_status": "Status 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_created": "Kolekcja została pomyślnie utworzona!",
@ -722,5 +760,6 @@
"location": "Lokalizacja",
"locations": "Lokalizacje",
"my_locations": "Moje lokalizacje"
}
},
"settings_download_backup": "Pobierz kopię zapasową"
}

View file

@ -43,7 +43,15 @@
"nominatim_2": "Их данные лицензированы под лицензией ODbL.",
"other_attributions": "Дополнительные атрибуции можно найти в файле README.",
"generic_attributions": "Войдите в AdventureLog, чтобы просмотреть атрибуции для включённых интеграций и сервисов.",
"close": "Закрыть"
"close": "Закрыть",
"attributions": "Атрибуты",
"developer": "Разработчик",
"license_info": "Лицензия",
"sponsor": "Спонсор",
"thank_you": "Спасибо за использование AdventureLog!",
"version": "Версия",
"view_changelog": "Посмотреть изменение изменений",
"view_license": "Просмотреть лицензию"
},
"home": {
"hero_1": "Откройте для себя самые захватывающие приключения мира",
@ -302,7 +310,8 @@
"visit_calendar": "Посетите календарь",
"wiki_location_desc": "Вытягивает отрывок из статьи Википедии, соответствующей названию места.",
"will_be_marked_location": "будет отмечен по посещению после сохранения местоположения.",
"no_locations_found": "Никаких мест не найдено"
"no_locations_found": "Никаких мест не найдено",
"image_modal_navigate": "Используйте клавиши со стрелками или нажмите, чтобы перемещаться"
},
"worldtravel": {
"country_list": "Список стран",
@ -497,7 +506,36 @@
"enter_new_password": "Введите новый пароль",
"connected": "Подключено",
"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_created": "Коллекция успешно создана!",
@ -722,5 +760,6 @@
"location": "Расположение",
"locations": "Локации",
"my_locations": "Мои локации"
}
},
"settings_download_backup": "Скачать резервную копию"
}

View file

@ -9,7 +9,15 @@
"oss_attributions": "Tillskrivningar med öppen källkod",
"other_attributions": "Ytterligare attributioner finns i README-filen.",
"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": {
"activities": {},
@ -250,7 +258,8 @@
"visit_calendar": "Besök kalendern",
"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.",
"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": {
"desc_1": "Upptäck, planera och utforska med lätthet",
@ -497,7 +506,36 @@
"social_auth_setup": "Social autentiseringsinställning",
"staff_status": "Personalstatus",
"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_delete_error": "Ett fel uppstod vid borttagning av checklista",
@ -722,5 +760,6 @@
"location": "Plats",
"locations": "Plats",
"my_locations": "Mina platser"
}
},
"settings_download_backup": "Ladda ner säkerhetskopiering"
}

View file

@ -43,7 +43,15 @@
"other_attributions": "其他声明可以在 README 文件中找到。",
"source_code": "源代码",
"close": "关闭",
"generic_attributions": "登录到AdventureLog以查看启用集成和服务的归因。"
"generic_attributions": "登录到AdventureLog以查看启用集成和服务的归因。",
"attributions": "归因",
"developer": "开发人员",
"license_info": "执照",
"sponsor": "赞助",
"thank_you": "感谢您使用冒险!",
"version": "版本",
"view_changelog": "查看ChangElog",
"view_license": "查看许可证"
},
"home": {
"desc_1": "轻松发现、规划和探索",
@ -302,7 +310,8 @@
"visit_calendar": "访问日历",
"wiki_location_desc": "从Wikipedia文章中提取摘录符合该位置的名称。",
"will_be_marked_location": "保存位置后,将被标记为访问。",
"no_locations_found": "找不到位置"
"no_locations_found": "找不到位置",
"image_modal_navigate": "使用箭头键或单击以导航"
},
"auth": {
"forgot_password": "忘记密码?",
@ -497,7 +506,36 @@
"quick_actions": "快速动作",
"region_updates": "区域更新",
"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_delete_error": "删除清单时出错",
@ -722,5 +760,6 @@
"location": "地点",
"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
const cleanHeaders = new Headers(response.headers);
cleanHeaders.delete('set-cookie');

View file

@ -88,7 +88,9 @@
let notFound: 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 () => {
if (data.props.adventure) {
@ -114,6 +116,19 @@
geojson = null;
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>
{#if notFound}
@ -139,8 +154,12 @@
/>
{/if}
{#if image_url}
<ImageDisplayModal image={image_url} on:close={() => (image_url = null)} {adventure} />
{#if isImageModalOpen}
<ImageDisplayModal
images={adventure_images}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
/>
{/if}
{#if !adventure && !notFound}
@ -176,7 +195,7 @@
>
<button
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}`}
>
<img src={image.image} class="w-full h-full object-cover" alt={adventure.name} />
@ -728,13 +747,13 @@
<div class="card-body">
<h3 class="card-title text-lg mb-4">🖼️ {$t('adventures.images')}</h3>
<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="aspect-square bg-cover bg-center rounded-lg cursor-pointer transition-transform duration-200 group-hover:scale-105"
style="background-image: url({image.image})"
on:click={() => (image_url = image.image)}
on:keydown={(e) => e.key === 'Enter' && (image_url = image.image)}
on:click={() => openImageModal(index)}
on:keydown={(e) => e.key === 'Enter' && openImageModal(index)}
role="button"
tabindex="0"
></div>

View file

@ -262,7 +262,7 @@ export const actions: Actions = {
return { success: true };
}
},
changeEmail: async (event) => {
restoreData: async (event) => {
if (!event.locals.user) {
return redirect(302, '/');
}
@ -270,28 +270,51 @@ export const actions: Actions = {
if (!sessionId) {
return redirect(302, '/');
}
const formData = await event.request.formData();
const new_email = formData.get('new_email') as string | null | undefined;
if (!new_email) {
return fail(400, { message: 'auth.email_required' });
} else {
try {
const formData = await event.request.formData();
const file = formData.get('file') as File | null | undefined;
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 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',
headers: {
Referer: event.url.origin, // Include Referer header
Referer: event.url.origin,
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
new_email
})
body: apiFormData
});
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 };
} catch (error) {
console.error('Restore error:', error);
return fail(500, { message: 'settings.generic_error' });
}
}
};

View file

@ -20,12 +20,12 @@
emails = data.props.emails;
}
let new_password_disable_setting: boolean = false;
let new_email: string = '';
let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration;
let googleMapsEnabled = data.props.googleMapsEnabled;
let activeSection: string = 'profile';
let acknowledgeRestoreOverride: boolean = false;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
@ -41,6 +41,7 @@
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
];
@ -924,6 +925,198 @@
</div>
{/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 -->
{#if activeSection === 'admin' && user.is_staff}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">