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

Merge branch 'development' into fix-spanish-translations

This commit is contained in:
Sean Morley 2025-06-13 09:48:18 -04:00 committed by GitHub
commit c60ced09c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1477 additions and 349 deletions

View file

@ -1,6 +1,8 @@
from django.apps import AppConfig
from django.conf import settings
class AdventuresConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'adventures'
def ready(self):
import adventures.signals # Import signals when the app is ready

View file

@ -3,20 +3,15 @@ from django.db.models import Q
class AdventureManager(models.Manager):
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
# Initialize the query with an empty Q object
query = Q()
# Add owned adventures to the query if included
if include_owned:
query |= Q(user_id=user.id)
query |= Q(user_id=user)
# Add shared adventures to the query if included
if include_shared:
query |= Q(collection__shared_with=user.id)
query |= Q(collections__shared_with=user)
# Add public adventures to the query if included
if include_public:
query |= Q(is_public=True)
# Perform the query with the final Q object and remove duplicates
return self.filter(query).distinct()

View file

@ -0,0 +1,59 @@
# Generated by Django 5.2.1 on 2025-06-10 03:04
from django.db import migrations, models
def migrate_collection_relationships(apps, schema_editor):
"""
Migrate existing ForeignKey relationships to ManyToMany relationships
"""
Adventure = apps.get_model('adventures', 'Adventure')
# Get all adventures that have a collection assigned
adventures_with_collections = Adventure.objects.filter(collection__isnull=False)
for adventure in adventures_with_collections:
# Add the existing collection to the new many-to-many field
adventure.collections.add(adventure.collection_id)
def reverse_migrate_collection_relationships(apps, schema_editor):
"""
Reverse migration - convert first collection back to ForeignKey
Note: This will only preserve the first collection if an adventure has multiple
"""
Adventure = apps.get_model('adventures', 'Adventure')
for adventure in Adventure.objects.all():
first_collection = adventure.collections.first()
if first_collection:
adventure.collection = first_collection
adventure.save()
class Migration(migrations.Migration):
dependencies = [
('adventures', '0034_remove_adventureimage_unique_immich_id_per_user'),
]
operations = [
# First, add the new ManyToMany field
migrations.AddField(
model_name='adventure',
name='collections',
field=models.ManyToManyField(blank=True, related_name='adventures', to='adventures.collection'),
),
# Migrate existing data from old field to new field
migrations.RunPython(
migrate_collection_relationships,
reverse_migrate_collection_relationships
),
# Finally, remove the old ForeignKey field
migrations.RemoveField(
model_name='adventure',
name='collection',
),
]

View file

@ -15,6 +15,7 @@ from django.core.exceptions import ValidationError
from django.utils import timezone
def background_geocode_and_assign(adventure_id: str):
print(f"[Adventure Geocode Thread] Starting geocode for adventure {adventure_id}")
try:
adventure = Adventure.objects.get(id=adventure_id)
if not (adventure.latitude and adventure.longitude):
@ -576,20 +577,14 @@ class Adventure(models.Model):
region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True)
country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True)
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
# Changed from ForeignKey to ManyToManyField
collections = models.ManyToManyField('Collection', blank=True, related_name='adventures')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = AdventureManager()
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
# Migrations performed in this version will remove these fields
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
# date = models.DateField(blank=True, null=True)
# end_date = models.DateField(blank=True, null=True)
# type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general')
def is_visited_status(self):
current_date = timezone.now().date()
for visit in self.visits.all():
@ -601,17 +596,33 @@ class Adventure(models.Model):
return True
return False
def clean(self):
if self.collection:
if self.collection.is_public and not self.is_public:
raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name)
if self.user_id != self.collection.user_id:
raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username)
def clean(self, skip_shared_validation=False):
"""
Validate model constraints.
skip_shared_validation: Skip validation when called by shared users
"""
# Skip validation if this is a shared user update
if skip_shared_validation:
return
# Check collections after the instance is saved (in save method or separate validation)
if self.pk: # Only check if the instance has been saved
for collection in self.collections.all():
if collection.is_public and not self.is_public:
raise ValidationError(f'Adventures associated with a public collection must be public. Collection: {collection.name} Adventure: {self.name}')
# Only enforce same-user constraint for non-shared collections
if self.user_id != collection.user_id:
# Check if this is a shared collection scenario
# Allow if the adventure owner has access to the collection through sharing
if not collection.shared_with.filter(uuid=self.user_id.uuid).exists():
raise ValidationError(f'Adventures must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user_id.username} Adventure owner: {self.user_id.username}')
if self.category:
if self.user_id != self.category.user_id:
raise ValidationError('Adventures must be associated with categories owned by the same user. Category owner: ' + self.category.user_id.username + ' Adventure owner: ' + self.user_id.username)
raise ValidationError(f'Adventures must be associated with categories owned by the same user. Category owner: {self.category.user_id.username} Adventure owner: {self.user_id.username}')
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False):
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False, _skip_shared_validation=False):
if force_insert and force_update:
raise ValueError("Cannot force both insert and updating in model saving.")
@ -625,6 +636,15 @@ class Adventure(models.Model):
result = super().save(force_insert, force_update, using, update_fields)
# Validate collections after saving (since M2M relationships require saved instance)
if self.pk:
try:
self.clean(skip_shared_validation=_skip_shared_validation)
except ValidationError as e:
# If validation fails, you might want to handle this differently
# For now, we'll re-raise the error
raise e
# ⛔ Skip threading if called from geocode background thread
if _skip_geocode:
return result
@ -636,7 +656,6 @@ class Adventure(models.Model):
return result
def __str__(self):
return self.name
@ -656,13 +675,13 @@ class Collection(models.Model):
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
link = models.URLField(blank=True, null=True, max_length=2083)
# if connected adventures are private and collection is public, raise an error
def clean(self):
if self.is_public and self.pk: # Only check if the instance has a primary key
for adventure in self.adventure_set.all():
# Updated to use the new related_name 'adventures'
for adventure in self.adventures.all():
if not adventure.is_public:
raise ValidationError('Public collections cannot be associated with private adventures. Collection: ' + self.name + ' Adventure: ' + adventure.name)
raise ValidationError(f'Public collections cannot be associated with private adventures. Collection: {self.name} Adventure: {adventure.name}')
def __str__(self):
return self.name

View file

@ -2,78 +2,99 @@ from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
Owners can edit, others have read-only access.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions are only allowed to the owner of the object.
# obj.user_id is FK to User, compare with request.user
return obj.user_id == request.user
class IsPublicReadOnly(permissions.BasePermission):
"""
Custom permission to only allow read-only access to public objects,
and write access to the owner of the object.
Read-only if public or owner, write only for owner.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed if the object is public
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.user_id == request.user
# Write permissions are only allowed to the owner of the object
return obj.user_id == request.user
class CollectionShared(permissions.BasePermission):
"""
Custom permission to only allow read-only access to public objects,
and write access to the owner of the object.
Allow full access if user is in shared_with of collection(s) or owner,
read-only if public or shared_with,
write only if owner or shared_with.
"""
def has_object_permission(self, request, view, obj):
user = request.user
if not user or not user.is_authenticated:
# Anonymous: only read public
return request.method in permissions.SAFE_METHODS and obj.is_public
# Read permissions are allowed if the object is shared with the user
if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists():
# Check if user is in shared_with of any collections related to the obj
# If obj is a Collection itself:
if hasattr(obj, 'shared_with'):
if obj.shared_with.filter(id=user.id).exists():
return True
# Write permissions are allowed if the object is shared with the user
if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists():
# If obj is an Adventure (has collections M2M)
if hasattr(obj, 'collections'):
# Check if user is in shared_with of any related collection
shared_collections = obj.collections.filter(shared_with=user)
if shared_collections.exists():
return True
# Read permissions are allowed if the object is public
# Read permission if public or owner
if request.method in permissions.SAFE_METHODS:
return obj.is_public or obj.user_id == request.user
return obj.is_public or obj.user_id == user
# Write permission only if owner or shared user via collections
if obj.user_id == user:
return True
if hasattr(obj, 'collections'):
if obj.collections.filter(shared_with=user).exists():
return True
# Default deny
return False
# Write permissions are only allowed to the owner of the object
return obj.user_id == request.user
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
"""
Custom permission to allow:
- Full access for shared users
- Full access for owners
- Read-only access for others on safe methods
Full access for owners and users shared via collections,
read-only for others if public.
"""
def has_object_permission(self, request, view, obj):
user = request.user
if not user or not user.is_authenticated:
return request.method in permissions.SAFE_METHODS and obj.is_public
# Allow GET only for a public object
if request.method in permissions.SAFE_METHODS and obj.is_public:
return True
# Check if the object has a collection
if hasattr(obj, 'collection') and obj.collection:
# Allow all actions for shared users
if request.user in obj.collection.shared_with.all():
return True
# Always allow GET, HEAD, or OPTIONS requests (safe methods)
# If safe method (read), allow if:
if request.method in permissions.SAFE_METHODS:
if obj.is_public:
return True
if obj.user_id == user:
return True
# If user in shared_with of any collection related to obj
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True
return False
# For write methods, allow if owner or shared user
if obj.user_id == user:
return True
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
return True
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
return True
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
return True
# Allow all actions for the owner
return obj.user_id == request.user
return False

View file

@ -101,12 +101,17 @@ class AdventureSerializer(CustomModelSerializer):
country = CountrySerializer(read_only=True)
region = RegionSerializer(read_only=True)
city = CitySerializer(read_only=True)
collections = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Collection.objects.all(),
required=False
)
class Meta:
model = Adventure
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location',
'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude',
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user']
@ -116,6 +121,19 @@ class AdventureSerializer(CustomModelSerializer):
# Filter out None values from the serialized data
return [image for image in serializer.data if image is not None]
def validate_collections(self, collections):
"""Validate that collections belong to the same user"""
if not collections:
return collections
user = self.context['request'].user
for collection in collections:
if collection.user_id != user:
raise serializers.ValidationError(
f"Collection '{collection.name}' does not belong to the current user."
)
return collections
def validate_category(self, category_data):
if isinstance(category_data, Category):
return category_data
@ -137,7 +155,7 @@ class AdventureSerializer(CustomModelSerializer):
if isinstance(category_data, dict):
name = category_data.get('name', '').lower()
display_name = category_data.get('display_name', name)
icon = category_data.get('icon', '<EFBFBD>')
icon = category_data.get('icon', '🌍')
else:
name = category_data.name.lower()
display_name = category_data.display_name
@ -163,15 +181,24 @@ class AdventureSerializer(CustomModelSerializer):
def create(self, validated_data):
visits_data = validated_data.pop('visits', None)
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', [])
print(category_data)
adventure = Adventure.objects.create(**validated_data)
# Handle visits
for visit_data in visits_data:
Visit.objects.create(adventure=adventure, **visit_data)
# Handle category
if category_data:
category = self.get_or_create_category(category_data)
adventure.category = category
# Handle collections - set after adventure is saved
if collections_data:
adventure.collections.set(collections_data)
adventure.save()
return adventure
@ -181,13 +208,27 @@ class AdventureSerializer(CustomModelSerializer):
visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', None)
collections_add = validated_data.pop('collections_add', [])
collections_remove = validated_data.pop('collections_remove', [])
# Update regular fields
for attr, value in validated_data.items():
setattr(instance, attr, value)
if category_data:
# Handle category - ONLY allow the adventure owner to change categories
user = self.context['request'].user
if category_data and instance.user_id == user:
# Only the owner can set categories
category = self.get_or_create_category(category_data)
instance.category = category
# If not the owner, ignore category changes
# Handle collections - only update if collections were provided
if collections_data is not None:
instance.collections.set(collections_data)
# Handle visits
if has_visits:
current_visits = instance.visits.all()
current_visit_ids = set(current_visits.values_list('id', flat=True))
@ -352,7 +393,7 @@ class ChecklistSerializer(CustomModelSerializer):
return data
class CollectionSerializer(CustomModelSerializer):
adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set')
adventures = AdventureSerializer(many=True, read_only=True)
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')

View file

@ -0,0 +1,23 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from adventures.models import Adventure
@receiver(m2m_changed, sender=Adventure.collections.through)
def update_adventure_publicity(sender, instance, action, **kwargs):
"""
Signal handler to update adventure publicity when collections are added/removed
"""
# Only process when collections are added or removed
if action in ('post_add', 'post_remove', 'post_clear'):
collections = instance.collections.all()
if collections.exists():
# If any collection is public, make the adventure public
has_public_collection = collections.filter(is_public=True).exists()
if has_public_collection and not instance.is_public:
instance.is_public = True
instance.save(update_fields=['is_public'])
elif not has_public_collection and instance.is_public:
instance.is_public = False
instance.save(update_fields=['is_public'])

View file

@ -15,9 +15,12 @@ def checkFilePermission(fileId, user, mediaType):
return True
elif adventure.user_id == user:
return True
elif adventure.collection:
if adventure.collection.shared_with.filter(id=user.id).exists():
elif adventure.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False
else:
return False
except AdventureImage.DoesNotExist:
@ -27,14 +30,18 @@ def checkFilePermission(fileId, user, mediaType):
# Construct the full relative path to match the database field
attachment_path = f"attachments/{fileId}"
# Fetch the Attachment object
attachment = Attachment.objects.get(file=attachment_path).adventure
if attachment.is_public:
attachment = Attachment.objects.get(file=attachment_path)
adventure = attachment.adventure
if adventure.is_public:
return True
elif attachment.user_id == user:
elif adventure.user_id == user:
return True
elif attachment.collection:
if attachment.collection.shared_with.filter(id=user.id).exists():
elif adventure.collections.exists():
# Check if the user is in any collection's shared_with list
for collection in adventure.collections.all():
if collection.shared_with.filter(id=user.id).exists():
return True
return False
else:
return False
except Attachment.DoesNotExist:

View file

@ -51,10 +51,16 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
# Check if the adventure has a collection
if adventure.collection:
# Check if the user is in the collection's shared_with list
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
# Check if the adventure has any collections
if adventure.collections.exists():
# Check if the user is in the shared_with list of any of the adventure's collections
user_has_access = False
for collection in adventure.collections.all():
if collection.shared_with.filter(id=request.user.id).exists():
user_has_access = True
break
if not user_has_access:
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
@ -189,7 +195,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
queryset = AdventureImage.objects.filter(
Q(adventure__id=adventure_uuid) & (
Q(adventure__user_id=request.user) | # User owns the adventure
Q(adventure__collection__shared_with=request.user) # User has shared access via collection
Q(adventure__collections__shared_with=request.user) # User has shared access via collection
)
).distinct()
@ -200,7 +206,7 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
# Updated to include images from adventures the user owns OR has shared access to
return AdventureImage.objects.filter(
Q(adventure__user_id=self.request.user) | # User owns the adventure
Q(adventure__collection__shared_with=self.request.user) # User has shared access via collection
Q(adventure__collections__shared_with=self.request.user) # User has shared access via collection
).distinct()
def perform_create(self, serializer):

View file

@ -3,69 +3,41 @@ from django.db import transaction
from django.core.exceptions import PermissionDenied
from django.db.models import Q, Max
from django.db.models.functions import Lower
from rest_framework import viewsets
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
import requests
from adventures.models import Adventure, Category, Transportation, Lodging
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
from adventures.utils import pagination
import requests
class AdventureViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Adventure objects with support for filtering, sorting,
and sharing functionality.
"""
serializer_class = AdventureSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
pagination_class = pagination.StandardResultsSetPagination
def apply_sorting(self, queryset):
order_by = self.request.query_params.get('order_by', 'updated_at')
order_direction = self.request.query_params.get('order_direction', 'asc')
include_collections = self.request.query_params.get('include_collections', 'true')
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
if order_by not in valid_order_by:
order_by = 'name'
if order_direction not in ['asc', 'desc']:
order_direction = 'asc'
if order_by == 'date':
queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
ordering = 'latest_visit'
elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name'
elif order_by == 'rating':
queryset = queryset.filter(rating__isnull=False)
ordering = 'rating'
else:
ordering = order_by
if order_direction == 'desc':
ordering = f'-{ordering}'
if order_by == 'updated_at':
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
if include_collections == 'false':
queryset = queryset.filter(collection=None)
return queryset.order_by(ordering)
# ==================== QUERYSET & PERMISSIONS ====================
def get_queryset(self):
"""
Returns the queryset for the AdventureViewSet. Unauthenticated users can only
retrieve public adventures, while authenticated users can access their own,
shared, and public adventures depending on the action.
Returns queryset based on user authentication and action type.
Public actions allow unauthenticated access to public adventures.
"""
user = self.request.user
# Actions that allow public access (include 'retrieve' and your custom action)
public_allowed_actions = {'retrieve', 'additional_info'}
if not user.is_authenticated:
if self.action in public_allowed_actions:
return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at')
return Adventure.objects.retrieve_adventures(
user, include_public=True
).order_by('-updated_at')
return Adventure.objects.none()
include_public = self.action in public_allowed_actions
@ -76,131 +48,273 @@ class AdventureViewSet(viewsets.ModelViewSet):
include_shared=True
).order_by('-updated_at')
# ==================== SORTING & FILTERING ====================
def apply_sorting(self, queryset):
"""Apply sorting and collection filtering to queryset."""
order_by = self.request.query_params.get('order_by', 'updated_at')
order_direction = self.request.query_params.get('order_direction', 'asc')
include_collections = self.request.query_params.get('include_collections', 'true')
# Validate parameters
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
if order_by not in valid_order_by:
order_by = 'name'
if order_direction not in ['asc', 'desc']:
order_direction = 'asc'
# Apply sorting logic
queryset = self._apply_ordering(queryset, order_by, order_direction)
# Filter adventures without collections if requested
if include_collections == 'false':
queryset = queryset.filter(collections__isnull=True)
return queryset
def _apply_ordering(self, queryset, order_by, order_direction):
"""Apply ordering to queryset based on field type."""
if order_by == 'date':
queryset = queryset.annotate(
latest_visit=Max('visits__start_date')
).filter(latest_visit__isnull=False)
ordering = 'latest_visit'
elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name'
elif order_by == 'rating':
queryset = queryset.filter(rating__isnull=False)
ordering = 'rating'
elif order_by == 'updated_at':
# Special handling for updated_at (reverse default order)
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
return queryset.order_by(ordering)
else:
ordering = order_by
# Apply direction
if order_direction == 'desc':
ordering = f'-{ordering}'
return queryset.order_by(ordering)
# ==================== CRUD OPERATIONS ====================
@transaction.atomic
def perform_create(self, serializer):
"""Create adventure with collection validation and ownership logic."""
collections = serializer.validated_data.get('collections', [])
# Validate permissions for all collections
self._validate_collection_permissions(collections)
# Determine what user to assign as owner
user_to_assign = self.request.user
if collections:
# Use the current user as owner since ManyToMany allows multiple collection owners
user_to_assign = self.request.user
serializer.save(user_id=user_to_assign)
def perform_update(self, serializer):
adventure = serializer.save()
if adventure.collection:
adventure.is_public = adventure.collection.is_public
adventure.save()
"""Update adventure."""
# Just save the adventure - the signal will handle publicity updates
serializer.save()
def update(self, request, *args, **kwargs):
"""Handle adventure updates with collection permission validation."""
instance = self.get_object()
partial = kwargs.pop('partial', False)
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
# Validate collection permissions if collections are being updated
if 'collections' in serializer.validated_data:
self._validate_collection_update_permissions(
instance, serializer.validated_data['collections']
)
else:
# Remove collections from validated_data if not provided
serializer.validated_data.pop('collections', None)
self.perform_update(serializer)
return Response(serializer.data)
# ==================== CUSTOM ACTIONS ====================
@action(detail=False, methods=['get'])
def filtered(self, request):
"""Filter adventures by category types and visit status."""
types = request.query_params.get('types', '').split(',')
is_visited = request.query_params.get('is_visited', 'all')
# Handle 'all' types
if 'all' in types:
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
types = Category.objects.filter(
user_id=request.user
).values_list('name', flat=True)
else:
# Validate provided types
if not types or not all(
Category.objects.filter(user_id=request.user, name=type).exists() for type in types
Category.objects.filter(user_id=request.user, name=type_name).exists()
for type_name in types
):
return Response({"error": "Invalid category or no types provided"}, status=400)
return Response(
{"error": "Invalid category or no types provided"},
status=400
)
# Build base queryset
queryset = Adventure.objects.filter(
category__in=Category.objects.filter(name__in=types, user_id=request.user),
user_id=request.user.id
)
is_visited_param = request.query_params.get('is_visited')
if is_visited_param is not None:
# Convert is_visited_param to a boolean
if is_visited_param.lower() == 'true':
is_visited_bool = True
elif is_visited_param.lower() == 'false':
is_visited_bool = False
else:
is_visited_bool = None
# Filter logic: "visited" means at least one visit with start_date <= today
now = timezone.now().date()
if is_visited_bool is True:
queryset = queryset.filter(visits__start_date__lte=now).distinct()
elif is_visited_bool is False:
queryset = queryset.exclude(visits__start_date__lte=now).distinct()
# Apply visit status filtering
queryset = self._apply_visit_filtering(queryset, request)
queryset = self.apply_sorting(queryset)
return self.paginate_and_respond(queryset, request)
@action(detail=False, methods=['get'])
def all(self, request):
"""Get all adventures (public and owned) with optional collection filtering."""
if not request.user.is_authenticated:
return Response({"error": "User is not authenticated"}, status=400)
include_collections = request.query_params.get('include_collections', 'false') == 'true'
queryset = Adventure.objects.filter(
Q(is_public=True) | Q(user_id=request.user.id),
collection=None if not include_collections else Q()
)
# Build queryset with collection filtering
base_filter = Q(is_public=True) | Q(user_id=request.user.id)
if include_collections:
queryset = Adventure.objects.filter(base_filter)
else:
queryset = Adventure.objects.filter(base_filter, collections__isnull=True)
queryset = self.apply_sorting(queryset)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def update(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
new_collection = serializer.validated_data.get('collection')
if new_collection and new_collection!=instance.collection:
if new_collection.user_id != request.user or instance.user_id != request.user:
raise PermissionDenied("You do not have permission to use this collection.")
elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
self.perform_update(serializer)
return Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
collection = serializer.validated_data.get('collection')
if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
raise PermissionDenied("You do not have permission to use this collection.")
elif collection:
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
return
serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
def paginate_and_respond(self, queryset, request):
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='additional-info')
def additional_info(self, request, pk=None):
"""Get adventure with additional sunrise/sunset information."""
adventure = self.get_object()
user = request.user
# Allow if public
if not adventure.is_public:
# Only allow owner or shared collection members
if not user.is_authenticated or adventure.user_id != user:
if not (adventure.collection and adventure.collection.shared_with.filter(uuid=user.uuid).exists()):
return Response({"error": "User does not have permission to access this adventure"},
status=status.HTTP_403_FORBIDDEN)
# Validate access permissions
if not self._has_adventure_access(adventure, user):
return Response(
{"error": "User does not have permission to access this adventure"},
status=status.HTTP_403_FORBIDDEN
)
# Get base adventure data
serializer = self.get_serializer(adventure)
response_data = serializer.data
visits = response_data.get('visits', [])
# Add sunrise/sunset data
response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', []))
return Response(response_data)
# ==================== HELPER METHODS ====================
def _validate_collection_permissions(self, collections):
"""Validate user has permission to use all provided collections. Only the owner or shared users can use collections."""
for collection in collections:
if not (collection.user_id == self.request.user or
collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You do not have permission to use collection '{collection.name}'."
)
def _validate_collection_update_permissions(self, instance, new_collections):
"""Validate permissions for collection updates (add/remove)."""
# Check permissions for new collections being added
for collection in new_collections:
if (collection.user_id != self.request.user and
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You do not have permission to use collection '{collection.name}'."
)
# Check permissions for collections being removed
current_collections = set(instance.collections.all())
new_collections_set = set(new_collections)
collections_to_remove = current_collections - new_collections_set
for collection in collections_to_remove:
if (collection.user_id != self.request.user and
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
raise PermissionDenied(
f"You cannot remove the adventure from collection '{collection.name}' "
f"as you don't have permission."
)
def _apply_visit_filtering(self, queryset, request):
"""Apply visit status filtering to queryset."""
is_visited_param = request.query_params.get('is_visited')
if is_visited_param is None:
return queryset
# Convert parameter to boolean
if is_visited_param.lower() == 'true':
is_visited_bool = True
elif is_visited_param.lower() == 'false':
is_visited_bool = False
else:
return queryset
# Apply visit filtering
now = timezone.now().date()
if is_visited_bool:
queryset = queryset.filter(visits__start_date__lte=now).distinct()
else:
queryset = queryset.exclude(visits__start_date__lte=now).distinct()
return queryset
def _has_adventure_access(self, adventure, user):
"""Check if user has access to adventure."""
# Allow if public
if adventure.is_public:
return True
# Check ownership
if user.is_authenticated and adventure.user_id == user:
return True
# Check shared collection access
if user.is_authenticated:
for collection in adventure.collections.all():
if collection.shared_with.filter(uuid=user.uuid).exists():
return True
return False
def _get_sun_times(self, adventure, visits):
"""Get sunrise/sunset times for adventure visits."""
sun_times = []
for visit in visits:
date = visit.get('start_date')
if date and adventure.longitude and adventure.latitude:
api_url = f'https://api.sunrisesunset.io/json?lat={adventure.latitude}&lng={adventure.longitude}&date={date}'
res = requests.get(api_url)
if res.status_code == 200:
data = res.json()
if not (date and adventure.longitude and adventure.latitude):
continue
api_url = (
f'https://api.sunrisesunset.io/json?'
f'lat={adventure.latitude}&lng={adventure.longitude}&date={date}'
)
try:
response = requests.get(api_url)
if response.status_code == 200:
data = response.json()
results = data.get('results', {})
if results.get('sunrise') and results.get('sunset'):
sun_times.append({
"date": date,
@ -208,6 +322,20 @@ class AdventureViewSet(viewsets.ModelViewSet):
"sunrise": results.get('sunrise'),
"sunset": results.get('sunset')
})
except requests.RequestException:
# Skip this visit if API call fails
continue
response_data['sun_times'] = sun_times
return Response(response_data)
return sun_times
def paginate_and_respond(self, queryset, request):
"""Paginate queryset and return response."""
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request)
if page is not None:
serializer = self.get_serializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

View file

@ -26,10 +26,16 @@ class AttachmentViewSet(viewsets.ModelViewSet):
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
if adventure.user_id != request.user:
# Check if the adventure has a collection
if adventure.collection:
# Check if the user is in the collection's shared_with list
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
# Check if the adventure has any collections
if adventure.collections.exists():
# Check if the user is in the shared_with list of any of the adventure's collections
user_has_access = False
for collection in adventure.collections.all():
if collection.shared_with.filter(id=request.user.id).exists():
user_has_access = True
break
if not user_has_access:
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
else:
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
@ -37,4 +43,14 @@ class AttachmentViewSet(viewsets.ModelViewSet):
return super().create(request, *args, **kwargs)
def perform_create(self, serializer):
adventure_id = self.request.data.get('adventure')
adventure = Adventure.objects.get(id=adventure_id)
# If the adventure belongs to collections, set the owner to the collection owner
if adventure.collections.exists():
# Get the first collection's owner (assuming all collections have the same owner)
collection = adventure.collections.first()
serializer.save(user_id=collection.user_id)
else:
# Otherwise, set the owner to the request user
serializer.save(user_id=self.request.user)

View file

@ -4,7 +4,7 @@ from django.db import transaction
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from adventures.models import Collection, Adventure, Transportation, Note
from adventures.models import Collection, Adventure, Transportation, Note, Checklist
from adventures.permissions import CollectionShared
from adventures.serializers import CollectionSerializer
from users.models import CustomUser as User
@ -106,23 +106,40 @@ class CollectionViewSet(viewsets.ModelViewSet):
if 'is_public' in serializer.validated_data:
new_public_status = serializer.validated_data['is_public']
# if is_publuc has changed and the user is not the owner of the collection return an error
# if is_public has changed and the user is not the owner of the collection return an error
if new_public_status != instance.is_public and instance.user_id != request.user:
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
return Response({"error": "User does not own the collection"}, status=400)
# Update associated adventures to match the collection's is_public status
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
# Get all adventures in this collection
adventures_in_collection = Adventure.objects.filter(collections=instance)
# do the same for transportations
if new_public_status:
# If collection becomes public, make all adventures public
adventures_in_collection.update(is_public=True)
else:
# If collection becomes private, check each adventure
# Only set an adventure to private if ALL of its collections are private
# Collect adventures that do NOT belong to any other public collection (excluding the current one)
adventure_ids_to_set_private = []
for adventure in adventures_in_collection:
has_public_collection = adventure.collections.filter(is_public=True).exclude(id=instance.id).exists()
if not has_public_collection:
adventure_ids_to_set_private.append(adventure.id)
# Bulk update those adventures
Adventure.objects.filter(id__in=adventure_ids_to_set_private).update(is_public=False)
# Update transportations, notes, and checklists related to this collection
# These still use direct ForeignKey relationships
Transportation.objects.filter(collection=instance).update(is_public=new_public_status)
# do the same for notes
Note.objects.filter(collection=instance).update(is_public=new_public_status)
Checklist.objects.filter(collection=instance).update(is_public=new_public_status)
# Log the action (optional)
action = "public" if new_public_status else "private"
print(f"Collection {instance.id} and its adventures were set to {action}")
print(f"Collection {instance.id} and its related objects were set to {action}")
self.perform_update(serializer)

View file

@ -7,7 +7,7 @@ AdventureLog can be installed in a variety of ways, depending on your platform o
::: tip Quick Start Script
**The fastest way to get started:**
[Install AdventureLog with a single command →](quick_start.md)
Perfect for and Docker beginners.
Perfect for Docker beginners.
:::
## 🐳 Popular Installation Methods

View file

@ -88,38 +88,61 @@
}
}
async function removeFromCollection() {
async function linkCollection(event: CustomEvent<string>) {
let collectionId = event.detail;
// Create a copy to avoid modifying the original directly
const updatedCollections = adventure.collections ? [...adventure.collections] : [];
// Add the new collection if not already present
if (!updatedCollections.some((c) => String(c) === String(collectionId))) {
updatedCollections.push(collectionId);
}
let res = await fetch(`/api/adventures/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: null })
body: JSON.stringify({ collections: updatedCollections })
});
if (res.ok) {
addToast('info', `${$t('adventures.collection_remove_success')}`);
dispatch('delete', adventure.id);
// Only update the adventure.collections after server confirms success
adventure.collections = updatedCollections;
addToast('info', `${$t('adventures.collection_link_success')}`);
} else {
addToast('error', `${$t('adventures.collection_remove_error')}`);
addToast('error', `${$t('adventures.collection_link_error')}`);
}
}
async function linkCollection(event: CustomEvent<number>) {
async function removeFromCollection(event: CustomEvent<string>) {
let collectionId = event.detail;
if (!collectionId) {
addToast('error', `${$t('adventures.collection_remove_error')}`);
return;
}
// Create a copy to avoid modifying the original directly
if (adventure.collections) {
const updatedCollections = adventure.collections.filter(
(c) => String(c) !== String(collectionId)
);
let res = await fetch(`/api/adventures/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: collectionId })
body: JSON.stringify({ collections: updatedCollections })
});
if (res.ok) {
console.log('Adventure linked to collection');
addToast('info', `${$t('adventures.collection_link_success')}`);
isCollectionModalOpen = false;
dispatch('delete', adventure.id);
// Only update adventure.collections after server confirms success
adventure.collections = updatedCollections;
addToast('info', `${$t('adventures.collection_remove_success')}`);
} else {
addToast('error', `${$t('adventures.collection_link_error')}`);
addToast('error', `${$t('adventures.collection_remove_error')}`);
}
}
}
@ -133,7 +156,12 @@
</script>
{#if isCollectionModalOpen}
<CollectionLink on:link={linkCollection} on:close={() => (isCollectionModalOpen = false)} />
<CollectionLink
on:link={(e) => linkCollection(e)}
on:unlink={(e) => removeFromCollection(e)}
on:close={() => (isCollectionModalOpen = false)}
linkedCollectionList={adventure.collections}
/>
{/if}
{#if isWarningModalOpen}
@ -269,23 +297,14 @@
</button>
</li>
{#if adventure.collection && user?.uuid == adventure.user_id}
<li>
<button on:click={removeFromCollection} class="flex items-center gap-2">
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if !adventure.collection}
{#if user?.uuid == adventure.user_id}
<li>
<button
on:click={() => (isCollectionModalOpen = true)}
class="flex items-center gap-2"
>
<Plus class="w-4 h-4" />
{$t('adventures.add_to_collection')}
{$t('collection.manage_collections')}
</button>
</li>
{/if}

View file

@ -12,21 +12,31 @@
let isLoading: boolean = true;
export let user: User | null;
export let collectionId: string;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
let res = await fetch(`/api/adventures/all/?include_collections=false`, {
let res = await fetch(`/api/adventures/all/?include_collections=true`, {
method: 'GET'
});
const newAdventures = await res.json();
if (res.ok && adventures) {
// Filter out adventures that are already linked to the collections
// basically for each adventure, check if collections array contains the id of the current collection
if (collectionId) {
adventures = newAdventures.filter((adventure: Adventure) => {
// adventure.collections is an array of ids, collectionId is a single id
return !(adventure.collections ?? []).includes(collectionId);
});
} else {
adventures = newAdventures;
}
// No need to reassign adventures to newAdventures here, keep the filtered result
isLoading = false;
});

View file

@ -14,6 +14,7 @@
import { t } from 'svelte-i18n';
import Plus from '~icons/mdi/plus';
import Minus from '~icons/mdi/minus';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import TrashCan from '~icons/mdi/trashcan';
import DeleteWarning from './DeleteWarning.svelte';
@ -23,6 +24,7 @@
const dispatch = createEventDispatcher();
export let type: String | undefined | null;
export let linkedCollectionList: string[] | null = null;
let isShareModalOpen: boolean = false;
function editAdventure() {
@ -138,10 +140,25 @@
<!-- Actions -->
<div class="pt-4 border-t border-base-300">
{#if type == 'link'}
<button class="btn btn-primary btn-block" on:click={() => dispatch('link', collection.id)}>
{#if linkedCollectionList && linkedCollectionList
.map(String)
.includes(String(collection.id))}
<button
class="btn btn-error btn-block"
on:click={() => dispatch('unlink', collection.id)}
>
<Minus class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
{:else}
<button
class="btn btn-primary btn-block"
on:click={() => dispatch('link', collection.id)}
>
<Plus class="w-4 h-4" />
{$t('adventures.add_to_collection')}
</button>
{/if}
{:else}
<div class="flex justify-between items-center">
<button

View file

@ -9,6 +9,8 @@
let collections: Collection[] = [];
export let linkedCollectionList: string[] | null = null;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
@ -30,10 +32,14 @@
dispatch('close');
}
function link(event: CustomEvent<number>) {
function link(event: CustomEvent<string>) {
dispatch('link', event.detail);
}
function unlink(event: CustomEvent<string>) {
dispatch('unlink', event.detail);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
@ -46,9 +52,15 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h1 class="text-center font-bold text-4xl mb-6">{$t('adventures.my_collections')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center mb-4">
{#each collections as collection}
<CollectionCard {collection} type="link" on:link={link} />
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
/>
{/each}
{#if collections.length === 0}
<p class="text-center text-lg">{$t('adventures.no_collections_found')}</p>

View file

@ -60,7 +60,8 @@
zh: '中文',
pl: 'Polski',
ko: '한국어',
no: 'Norsk'
no: 'Norsk',
ru: 'Русский'
};
let query: string = '';

View file

@ -36,7 +36,7 @@ export type Adventure = {
timezone: string | null;
notes: string;
}[];
collection?: string | null;
collections?: string[] | null;
latitude: number | null;
longitude: number | null;
is_public: boolean;

View file

@ -523,7 +523,8 @@
"collection_edit_success": "Collection edited successfully!",
"error_editing_collection": "Error editing collection",
"edit_collection": "Edit Collection",
"public_collection": "Public Collection"
"public_collection": "Public Collection",
"manage_collections": "Manage Collections"
},
"notes": {
"note_deleted": "Note deleted successfully!",

View file

@ -10,7 +10,7 @@
"greeting": "Hola",
"my_adventures": "Mis Aventuras",
"shared_with_me": "Compartido Conmigo",
"settings": "Configuraciones",
"settings": "Ajustes",
"logout": "Cerrar Sesión",
"about": "Acerca de AdventureLog",
"documentation": "Documentación",
@ -29,7 +29,7 @@
"my_tags": "Mis etiquetas",
"tag": "Etiqueta",
"language_selection": "Idioma",
"support": "Apoyo",
"support": "Soporte",
"calendar": "Calendario",
"admin_panel": "Panel de administración"
},
@ -51,7 +51,7 @@
"go_to": "Ir a AdventureLog",
"key_features": "Características Clave",
"desc_1": "Descubre, Planifica y Explora Fácilmente",
"desc_2": "AdventureLog está diseñado para simplificar tu viaje, brindándote las herramientas y recursos para planificar, empacar y navegar tu próxima aventura inolvidable.",
"desc_2": "AdventureLog está diseñado para simplificar tu viaje, brindándote las herramientas y recursos para planificar, hacer la maleta y recorrer tu próxima aventura inolvidable.",
"feature_1": "Registro de Viajes",
"feature_1_desc": "Mantén un registro de tus aventuras con un diario de viaje personalizado y comparte tus experiencias con amigos y familiares.",
"feature_2": "Planificación de Viajes",
@ -78,8 +78,8 @@
"dining": "Cenar 🍽️",
"event": "Evento 🎉",
"festivals": "Festivales 🎪",
"fitness": "Fitness 🏋️",
"general": "Generales 🌍",
"fitness": "Ejercicio 🏋️",
"general": "General 🌍",
"hiking": "Senderismo 🥾",
"historical_sites": "Sitios Históricos 🏛️",
"lodging": "Alojamiento 🛌",
@ -97,7 +97,7 @@
"no_image_found": "No se encontró ninguna imagen",
"adventure_details": "Detalles de la aventura",
"adventure_type": "Tipo de aventura",
"collection": "Recopilación",
"collection": "Colección",
"homepage": "Página principal",
"latitude": "Latitud",
"longitude": "Longitud",
@ -117,11 +117,11 @@
"order_by": "Ordenar por",
"order_direction": "Dirección del pedido",
"rating": "Clasificación",
"sort": "Clasificar",
"sort": "Ordenar",
"sources": "Fuentes",
"updated": "Actualizado",
"category_filter": "Filtro de categoría",
"clear": "Claro",
"clear": "Limpiar",
"archived_collections": "Colecciones archivadas",
"close_filters": "Cerrar filtros",
"my_collections": "Mis colecciones",
@ -164,9 +164,9 @@
"adventure_updated": "Aventura actualizada",
"basic_information": "Información básica",
"category": "Categoría",
"clear_map": "Borrar mapa",
"clear_map": "Limpiar mapa",
"copy_link": "Copiar enlace",
"date_constrain": "Restringir a las fechas de recolección",
"date_constrain": "Restringir a las fechas de la colección",
"description": "Descripción",
"end_date": "Fecha de finalización",
"fetch_image": "Buscar imagen",
@ -199,10 +199,10 @@
"wiki_desc": "Extrae un extracto de un artículo de Wikipedia que coincide con el nombre de la aventura.",
"wikipedia": "Wikipedia",
"add_an_activity": "Agregar una actividad",
"adventure_not_found": "No hay aventuras que mostrar. \n¡Agregue algunos usando el botón más en la parte inferior derecha o intente cambiar los filtros!",
"adventure_not_found": "No hay aventuras que mostrar. \n¡Agregue algunas usando el botón más en la parte inferior derecha o intente cambiar los filtros!",
"no_adventures_found": "No se encontraron aventuras",
"no_collections_found": "No se encontraron colecciones para agregar esta aventura.",
"my_adventures": "mis aventuras",
"my_adventures": "Mis aventuras",
"no_linkable_adventures": "No se encontraron aventuras que puedan vincularse a esta colección.",
"mark_region_as_visited": "¿Marcar región {region}, {country} como visitada?",
"mark_visited": "Marcar como visitado",
@ -215,15 +215,15 @@
"visited_region_check": "Verificación de región visitada",
"visited_region_check_desc": "Al seleccionar esto, el servidor verificará todas sus aventuras visitadas y marcará las regiones en las que se encuentran como visitadas en viajes mundiales.",
"add_new": "Agregar nuevo...",
"checklist": "Lista de verificación",
"checklists": "Listas de verificación",
"checklist": "Lista de tareas",
"checklists": "Listas de tareas",
"collection_archived": "Esta colección ha sido archivada.",
"collection_completed": "¡Has completado esta colección!",
"collection_stats": "Estadísticas de colección",
"days": "días",
"itineary_by_date": "Itinerario por fecha",
"keep_exploring": "¡Sigue explorando!",
"link_new": "Enlace nuevo...",
"link_new": "Vincula una Nueva...",
"linked_adventures": "Aventuras vinculadas",
"links": "Enlaces",
"no_end_date": "Por favor ingresa una fecha de finalización",
@ -242,15 +242,15 @@
"copy_failed": "Copia fallida",
"adventure_calendar": "Calendario de aventuras",
"emoji_picker": "Selector de emojis",
"hide": "Esconder",
"show": "Espectáculo",
"hide": "Ocultar",
"show": "Mostrar",
"download_calendar": "Descargar Calendario",
"md_instructions": "Escriba su descuento aquí...",
"preview": "Avance",
"checklist_delete_confirm": "¿Está seguro de que desea eliminar esta lista de verificación? \nEsta acción no se puede deshacer.",
"md_instructions": "Escriba aquí con markdown...",
"preview": "Vista Previa",
"checklist_delete_confirm": "¿Está seguro de que desea eliminar esta lista de tareas? \nEsta acción no se puede deshacer.",
"clear_location": "Borrar ubicación",
"date_information": "Información de fecha",
"delete_checklist": "Eliminar lista de verificación",
"delete_checklist": "Eliminar lista de tareas",
"delete_note": "Eliminar nota",
"delete_transportation": "Eliminar transporte",
"end": "Fin",
@ -280,7 +280,7 @@
"attachments": "Adjuntos",
"gpx_tip": "¡Sube archivos GPX a archivos adjuntos para verlos en el mapa!",
"images": "Imágenes",
"primary": "Primario",
"primary": "Principal",
"upload": "Subir",
"view_attachment": "Ver archivo adjunto",
"attachment_name": "Nombre del archivo adjunto",
@ -304,7 +304,7 @@
"ordered_itinerary": "Itinerario ordenado",
"additional_info": "información adicional",
"invalid_date_range": "Rango de fechas no válido",
"sunrise_sunset": "Amanecer",
"sunrise_sunset": "Amanecer y Atardecer",
"timezone": "Zona horaria",
"no_visits": "No hay visitas",
"arrival_timezone": "Zona horaria de llegada",
@ -327,7 +327,7 @@
"all": "Todo",
"all_subregions": "Todas las subregiones",
"clear_search": "Borrar búsqueda",
"completely_visited": "Completamente visitado",
"completely_visited": "Visitado completamente",
"no_countries_found": "No se encontraron países",
"not_visited": "No visitado",
"num_countries": "países encontrados",
@ -354,10 +354,10 @@
"password": "Contraseña",
"signup": "Inscribirse",
"username": "Nombre de usuario",
"confirm_password": "confirmar Contraseña",
"confirm_password": "Confirmar Contraseña",
"email": "Correo electrónico",
"first_name": "Nombre de pila",
"last_name": "Apellido",
"first_name": "Nombre",
"last_name": "Apellidos",
"registration_disabled": "El registro está actualmente deshabilitado.",
"profile_picture": "Foto de perfil",
"public_profile": "Perfil público",
@ -396,18 +396,18 @@
"token_required": "Se requieren token y UID para restablecer la contraseña.",
"password_does_not_match": "Las contraseñas no coinciden",
"password_is_required": "Se requiere contraseña",
"submit": "Entregar",
"submit": "Enviar",
"invalid_token": "El token no es válido o ha caducado",
"about_this_background": "Sobre este trasfondo",
"join_discord": "Únete a la discordia",
"join_discord_desc": "para compartir tus propias fotos. \nPublicarlos en el",
"about_this_background": "Sobre este fondo",
"join_discord": "Únete a Discord",
"join_discord_desc": "para compartir tus propias fotos. Publícalos en el canal de #travel-share",
"photo_by": "Foto por",
"change_password_error": "No se puede cambiar la contraseña. \nContraseña actual no válida o contraseña nueva no válida.",
"current_password": "Contraseña actual",
"password_change_lopout_warning": "Se cerrará su sesión después de cambiar su contraseña.",
"generic_error": "Se produjo un error al procesar su solicitud.",
"email_added": "¡Correo electrónico agregado exitosamente!",
"email_added_error": "Error al agregar correo electrónico",
"email_added": "¡Correo electrónico añadido exitosamente!",
"email_added_error": "Error al añadir correo electrónico",
"email_removed": "¡El correo electrónico se eliminó correctamente!",
"email_removed_error": "Error al eliminar el correo electrónico",
"email_set_primary": "¡El correo electrónico se configuró como principal correctamente!",
@ -415,7 +415,7 @@
"make_primary": "Hacer primario",
"no_email_set": "No hay correo electrónico configurado",
"not_verified": "No verificado",
"primary": "Primario",
"primary": "Principal",
"verified": "Verificado",
"verify": "Verificar",
"verify_email_error": "Error al verificar el correo electrónico. \nInténtalo de nuevo en unos minutos.",
@ -508,7 +508,7 @@
"quick_actions": "Acciones rápidas",
"region_updates": "Actualizaciones de región",
"region_updates_desc": "Actualizar regiones y ciudades visitadas",
"regular_user": "Usuario regular",
"regular_user": "Usuario básico",
"social_auth_setup": "Configuración de autenticación social",
"staff_status": "Estado del personal",
"staff_user": "Usuario de personal",
@ -516,16 +516,16 @@
"all_rights_reserved": "Reservados todos los derechos."
},
"checklist": {
"add_item": "Agregar artículo",
"checklist_delete_error": "Error al eliminar la lista de verificación",
"checklist_deleted": "¡Lista de verificación eliminada exitosamente!",
"checklist_editor": "Editor de lista de verificación",
"checklist_public": "Esta lista de verificación es pública porque se encuentra en una colección pública.",
"editing_checklist": "Lista de verificación de edición",
"failed_to_save": "No se pudo guardar la lista de verificación",
"item": "Artículo",
"item_already_exists": "El artículo ya existe",
"item_cannot_be_empty": "El artículo no puede estar vacío",
"add_item": "Añadir elemento",
"checklist_delete_error": "Error al eliminar la lista de tareas",
"checklist_deleted": "¡Lista de tareas eliminada exitosamente!",
"checklist_editor": "Editor de lista de tareas",
"checklist_public": "Esta lista de tareas es pública porque se encuentra en una colección pública.",
"editing_checklist": "Editando lista de tareas",
"failed_to_save": "No se pudo guardar la lista de tareas",
"item": "Elemento",
"item_already_exists": "El elemento ya existe",
"item_cannot_be_empty": "El elemento no puede estar vacío",
"items": "Elementos",
"new_item": "Nuevo artículo",
"save": "Guardar",
@ -545,25 +545,25 @@
"notes": {
"add_a_link": "Agregar un enlace",
"content": "Contenido",
"editing_note": "Nota de edición",
"editing_note": "Editando nota",
"failed_to_save": "No se pudo guardar la nota",
"note_delete_error": "Error al eliminar la nota",
"note_deleted": "¡Nota eliminada exitosamente!",
"note_editor": "Editor de notas",
"note_public": "Esta nota es pública porque está en una colección pública.",
"open": "Abierto",
"open": "Abrir",
"save": "Guardar",
"invalid_url": "URL no válida",
"note_viewer": "Visor de notas"
},
"transportation": {
"date_and_time": "Fecha",
"date_and_time": "Fecha y Hora",
"error_editing_transportation": "Error al editar el transporte",
"modes": {
"bus": "Autobús",
"bike": "Bicicleta",
"boat": "Bote",
"car": "Auto",
"boat": "Barco",
"car": "Coche",
"other": "Otro",
"plane": "Avión",
"train": "Tren",
@ -660,7 +660,7 @@
"integration_fetch_error": "Error al obtener datos de la integración de Immich",
"integration_missing": "Falta la integración de Immich en el backend",
"load_more": "Cargar más",
"no_items_found": "No se encontraron artículos",
"no_items_found": "No se encontraron elementos",
"query_required": "Se requiere consulta",
"server_down": "El servidor Immich está actualmente inactivo o inaccesible",
"server_url": "URL del servidor Immich",
@ -672,34 +672,34 @@
"enable_integration": "Habilitar la integración",
"immich_integration_desc": "Conecte su servidor de administración de fotos de Immich",
"need_help": "¿Necesita ayuda para configurar esto? \nMira el",
"connection_error": "Error conectarse al servidor Immich",
"connection_error": "Error al conectarse al servidor Immich",
"copy_locally": "Copiar imágenes localmente",
"copy_locally_desc": "Copie imágenes al servidor para obtener acceso fuera de línea. \nUtiliza más espacio en disco.",
"error_saving_image": "Imagen de ahorro de errores",
"error_saving_image": "Error al guardar la imagen",
"integration_already_exists": "Ya existe una integración Immich. \nSolo puedes tener una integración a la vez.",
"integration_not_found": "Integración Immich no encontrada. \nPor favor cree una nueva integración.",
"network_error": "Error de red mientras se conecta al servidor Immich. \nVerifique su conexión y vuelva a intentarlo.",
"validation_error": "Se produjo un error al validar la integración de Immich. \nVerifique la URL y la tecla API de su servidor."
},
"recomendations": {
"address": "DIRECCIÓN",
"address": "Dirección",
"contact": "Contacto",
"phone": "Teléfono",
"recommendation": "Recomendación",
"website": "Sitio web",
"recommendations": "Recomendaciones",
"adventure_recommendations": "Recomendaciones de aventura",
"food": "Alimento",
"food": "Comida",
"miles": "Millas",
"tourism": "Turismo"
},
"lodging": {
"apartment": "Departamento",
"apartment": "Apartamento",
"bnb": "Cama y desayuno",
"cabin": "Cabina",
"campground": "Terreno de camping",
"check_in": "Registrarse",
"check_out": "Verificar",
"check_in": "Registro",
"check_out": "Salida",
"date_and_time": "Fecha",
"edit": "Editar",
"error_editing_lodging": "Error de edición de alojamiento",

View file

@ -0,0 +1,729 @@
{
"navbar": {
"adventures": "Приключения",
"collections": "Коллекции",
"worldtravel": "Мировые путешествия",
"map": "Карта",
"users": "Пользователи",
"search": "Поиск",
"profile": "Профиль",
"greeting": "Привет",
"my_adventures": "Мои приключения",
"my_tags": "Мои теги",
"tag": "Тег",
"shared_with_me": "Поделились со мной",
"settings": "Настройки",
"logout": "Выйти",
"about": "О AdventureLog",
"documentation": "Документация",
"discord": "Discord",
"language_selection": "Язык",
"support": "Поддержка",
"calendar": "Календарь",
"theme_selection": "Выбор темы",
"admin_panel": "Панель администратора",
"themes": {
"light": "Светлая",
"dark": "Тёмная",
"night": "Ночная",
"forest": "Лесная",
"aestheticLight": "Эстетическая светлая",
"aestheticDark": "Эстетическая тёмная",
"aqua": "Аква",
"northernLights": "Северное сияние"
}
},
"about": {
"about": "О программе",
"license": "Лицензировано под лицензией GPL-3.0.",
"source_code": "Исходный код",
"message": "Сделано с ❤️ в США.",
"oss_attributions": "Атрибуции открытого исходного кода",
"nominatim_1": "Поиск местоположений и геокодирование предоставляется",
"nominatim_2": "Их данные лицензированы под лицензией ODbL.",
"other_attributions": "Дополнительные атрибуции можно найти в файле README.",
"generic_attributions": "Войдите в AdventureLog, чтобы просмотреть атрибуции для включённых интеграций и сервисов.",
"close": "Закрыть"
},
"home": {
"hero_1": "Откройте для себя самые захватывающие приключения мира",
"hero_2": "Открывайте и планируйте своё следующее приключение с AdventureLog. Исследуйте захватывающие дух места, создавайте персональные маршруты и оставайтесь на связи в пути.",
"go_to": "Перейти к AdventureLog",
"key_features": "Ключевые особенности",
"desc_1": "Открывайте, планируйте и исследуйте с лёгкостью",
"desc_2": "AdventureLog создан для упрощения вашего путешествия, предоставляя вам инструменты и ресурсы для планирования, сборов и навигации в вашем следующем незабываемом приключении.",
"feature_1": "Журнал путешествий",
"feature_1_desc": "Ведите учёт своих приключений с персональным журналом путешествий и делитесь своими впечатлениями с друзьями и семьёй.",
"feature_2": "Планирование поездок",
"feature_2_desc": "Легко создавайте персональные маршруты и получайте подробную разбивку поездки по дням.",
"feature_3": "Карта путешествий",
"feature_3_desc": "Просматривайте свои путешествия по всему миру с интерактивной картой и открывайте новые направления."
},
"adventures": {
"collection_remove_success": "Приключение успешно удалено из коллекции!",
"collection_remove_error": "Ошибка удаления приключения из коллекции",
"collection_link_success": "Приключение успешно связано с коллекцией!",
"invalid_date_range": "Недопустимый диапазон дат",
"timezone": "Часовой пояс",
"no_visits": "Нет посещений",
"departure_timezone": "Часовой пояс отправления",
"arrival_timezone": "Часовой пояс прибытия",
"departure_date": "Дата отправления",
"arrival_date": "Дата прибытия",
"no_image_found": "Изображение не найдено",
"collection_link_error": "Ошибка связывания приключения с коллекцией",
"adventure_delete_confirm": "Вы уверены, что хотите удалить это приключение? Это действие нельзя отменить.",
"checklist_delete_confirm": "Вы уверены, что хотите удалить этот контрольный список? Это действие нельзя отменить.",
"note_delete_confirm": "Вы уверены, что хотите удалить эту заметку? Это действие нельзя отменить.",
"transportation_delete_confirm": "Вы уверены, что хотите удалить этот транспорт? Это действие нельзя отменить.",
"lodging_delete_confirm": "Вы уверены, что хотите удалить это место проживания? Это действие нельзя отменить.",
"delete_checklist": "Удалить контрольный список",
"delete_note": "Удалить заметку",
"delete_transportation": "Удалить транспорт",
"delete_lodging": "Удалить жильё",
"open_details": "Открыть детали",
"edit_adventure": "Редактировать приключение",
"remove_from_collection": "Убрать из коллекции",
"add_to_collection": "Добавить в коллекцию",
"delete": "Удалить",
"not_found": "Приключение не найдено",
"not_found_desc": "Приключение, которое вы искали, не найдено. Попробуйте другое приключение или проверьте позже.",
"homepage": "Главная страница",
"adventure_details": "Детали приключения",
"collection": "Коллекция",
"adventure_type": "Тип приключения",
"longitude": "Долгота",
"latitude": "Широта",
"visit": "Посещение",
"timed": "По времени",
"coordinates": "Координаты",
"copy_coordinates": "Копировать координаты",
"visits": "Посещения",
"create_new": "Создать новое...",
"adventure": "Приключение",
"additional_info": "Дополнительная информация",
"sunrise_sunset": "Восход и закат",
"count_txt": "результатов соответствуют вашему поиску",
"sort": "Сортировка",
"order_by": "Сортировать по",
"order_direction": "Направление сортировки",
"ascending": "По возрастанию",
"descending": "По убыванию",
"updated": "Обновлено",
"name": "Название",
"date": "Дата",
"activity_types": "Типы активности",
"tags": "Теги",
"add_a_tag": "Добавить тег",
"date_constrain": "Ограничить датами коллекции",
"rating": "Рейтинг",
"my_images": "Мои изображения",
"add_an_activity": "Добавить активность",
"show_region_labels": "Показать названия регионов",
"no_images": "Нет изображений",
"distance": "Расстояние",
"upload_images_here": "Загрузите изображения сюда",
"share_adventure": "Поделиться этим приключением!",
"copy_link": "Копировать ссылку",
"sun_times": "Время солнца",
"sunrise": "Восход",
"sunset": "Закат",
"image": "Изображение",
"upload_image": "Загрузить изображение",
"open_in_maps": "Открыть в картах",
"url": "URL",
"fetch_image": "Получить изображение",
"wikipedia": "Википедия",
"add_notes": "Добавить заметки",
"warning": "Предупреждение",
"my_adventures": "Мои приключения",
"no_linkable_adventures": "Не найдено приключений, которые можно связать с этой коллекцией.",
"add": "Добавить",
"save_next": "Сохранить и далее",
"end_date": "Дата окончания",
"my_visits": "Мои посещения",
"start_date": "Дата начала",
"remove": "Удалить",
"location": "Местоположение",
"search_for_location": "Поиск местоположения",
"clear_map": "Очистить карту",
"search_results": "Результаты поиска",
"collection_no_start_end_date": "Добавление дат начала и окончания коллекции разблокирует функции планирования маршрута на странице коллекции.",
"no_results": "Результаты не найдены",
"wiki_desc": "Извлекает отрывок из статьи Википедии, соответствующей названию приключения.",
"attachments": "Вложения",
"attachment": "Вложение",
"images": "Изображения",
"primary": "Основное",
"view_attachment": "Просмотреть вложение",
"generate_desc": "Сгенерировать описание",
"public_adventure": "Публичное приключение",
"location_information": "Информация о местоположении",
"link": "Ссылка",
"links": "Ссылки",
"description": "Описание",
"sources": "Источники",
"collection_adventures": "Включить приключения коллекции",
"filter": "Фильтр",
"category_filter": "Фильтр категории",
"category": "Категория",
"select_adventure_category": "Выберите категорию приключения",
"clear": "Очистить",
"my_collections": "Мои коллекции",
"open_filters": "Открыть фильтры",
"close_filters": "Закрыть фильтры",
"archived_collections": "Архивные коллекции",
"share": "Поделиться",
"private": "Приватное",
"public": "Публичное",
"archived": "Архивное",
"edit_collection": "Редактировать коллекцию",
"unarchive": "Разархивировать",
"archive": "Архивировать",
"no_collections_found": "Не найдено коллекций для добавления этого приключения.",
"not_visited": "Не посещено",
"archived_collection_message": "Коллекция успешно архивирована!",
"unarchived_collection_message": "Коллекция успешно разархивирована!",
"delete_collection_success": "Коллекция успешно удалена!",
"delete_collection_warning": "Вы уверены, что хотите удалить эту коллекцию? Это также удалит все связанные приключения. Это действие нельзя отменить.",
"cancel": "Отмена",
"of": "из",
"delete_collection": "Удалить коллекцию",
"delete_adventure": "Удалить приключение",
"adventure_delete_success": "Приключение успешно удалено!",
"visited": "Посещено",
"planned": "Запланировано",
"duration": "Продолжительность",
"all": "Все",
"image_removed_success": "Изображение успешно удалено!",
"image_removed_error": "Ошибка удаления изображения",
"no_image_url": "Изображение по этому URL не найдено.",
"image_upload_success": "Изображение успешно загружено!",
"image_upload_error": "Ошибка загрузки изображения",
"dates": "Даты",
"wiki_image_error": "Ошибка получения изображения из Википедии",
"start_before_end_error": "Дата начала должна быть раньше даты окончания",
"activity": "Активность",
"actions": "Действия",
"no_end_date": "Пожалуйста, введите дату окончания",
"see_adventures": "Посмотреть приключения",
"image_fetch_failed": "Не удалось получить изображение",
"no_location": "Пожалуйста, введите местоположение",
"no_start_date": "Пожалуйста, введите дату начала",
"no_description_found": "Описание не найдено",
"adventure_created": "Приключение создано",
"adventure_create_error": "Не удалось создать приключение",
"lodging": "Жильё",
"create_adventure": "Создать приключение",
"adventure_updated": "Приключение обновлено",
"adventure_update_error": "Не удалось обновить приключение",
"set_to_pin": "Установить как булавку",
"category_fetch_error": "Ошибка получения категорий",
"new_adventure": "Новое приключение",
"basic_information": "Основная информация",
"no_adventures_to_recommendations": "Приключения не найдены. Добавьте хотя бы одно приключение, чтобы получить рекомендации.",
"display_name": "Отображаемое имя",
"adventure_not_found": "Нет приключений для отображения. Добавьте их, используя кнопку плюс в правом нижнем углу, или попробуйте изменить фильтры!",
"no_adventures_found": "Приключения не найдены",
"mark_region_as_visited": "Отметить регион {region}, {country} как посещённый?",
"mark_visited": "Отметить как посещённое",
"error_updating_regions": "Ошибка обновления регионов",
"regions_updated": "регионов обновлено",
"cities_updated": "городов обновлено",
"visited_region_check": "Проверка посещённых регионов",
"visited_region_check_desc": "Выбрав это, сервер проверит все ваши посещённые приключения и отметит регионы, в которых они находятся, как посещённые в мировых путешествиях.",
"update_visited_regions": "Обновить посещённые регионы",
"update_visited_regions_disclaimer": "Это может занять некоторое время в зависимости от количества ваших посещённых приключений.",
"link_new": "Связать новое...",
"add_new": "Добавить новое...",
"transportation": "Транспорт",
"note": "Заметка",
"checklist": "Контрольный список",
"collection_archived": "Эта коллекция была архивирована.",
"visit_link": "Перейти по ссылке",
"collection_completed": "Вы завершили эту коллекцию!",
"collection_stats": "Статистика коллекции",
"keep_exploring": "Продолжайте исследовать!",
"linked_adventures": "Связанные приключения",
"notes": "Заметки",
"checklists": "Контрольные списки",
"transportations": "Транспорт",
"adventure_calendar": "Календарь приключений",
"day": "День",
"itineary_by_date": "Маршрут по дате",
"nothing_planned": "На этот день ничего не запланировано. Наслаждайтесь путешествием!",
"copied_to_clipboard": "Скопировано в буфер обмена!",
"copy_failed": "Копирование не удалось",
"show": "Показать",
"hide": "Скрыть",
"clear_location": "Очистить местоположение",
"starting_airport": "Аэропорт отправления",
"view_profile": "Просмотреть профиль",
"joined": "Присоединился",
"ending_airport": "Аэропорт прибытия",
"no_location_found": "Местоположение не найдено",
"from": "От",
"to": "До",
"will_be_marked": "будет отмечено как посещённое после сохранения приключения.",
"start": "Начало",
"end": "Конец",
"show_map": "Показать карту",
"emoji_picker": "Выбор эмодзи",
"download_calendar": "Скачать календарь",
"all_day": "Весь день",
"ordered_itinerary": "Упорядоченный маршрут",
"itinerary": "Маршрут",
"all_linked_items": "Все связанные элементы",
"date_itinerary": "Маршрут по дате",
"no_ordered_items": "Добавьте элементы с датами в коллекцию, чтобы увидеть их здесь.",
"date_information": "Информация о дате",
"flight_information": "Информация о рейсе",
"out_of_range": "Не в диапазоне дат маршрута",
"preview": "Предварительный просмотр",
"finding_recommendations": "Поиск скрытых жемчужин для вашего следующего приключения",
"location_details": "Детали местоположения",
"city": "Город",
"region": "Регион",
"md_instructions": "Напишите ваш markdown здесь...",
"days": "дней",
"attachment_upload_success": "Вложение успешно загружено!",
"attachment_upload_error": "Ошибка загрузки вложения",
"upload": "Загрузить",
"attachment_delete_success": "Вложение успешно удалено!",
"attachment_update_success": "Вложение успешно обновлено!",
"attachment_name": "Название вложения",
"gpx_tip": "Загрузите GPX-файлы во вложения, чтобы просматривать их на карте!",
"welcome_map_info": "Публичные приключения на этом сервере",
"attachment_update_error": "Ошибка обновления вложения",
"activities": {
"general": "Общее 🌍",
"outdoor": "На открытом воздухе 🏞️",
"lodging": "Проживание 🛌",
"dining": "Питание 🍽️",
"activity": "Активность 🏄",
"attraction": "Достопримечательность 🎢",
"shopping": "Покупки 🛍️",
"nightlife": "Ночная жизнь 🌃",
"event": "Мероприятие 🎉",
"transportation": "Транспорт 🚗",
"culture": "Культура 🎭",
"water_sports": "Водные виды спорта 🚤",
"hiking": "Пешие походы 🥾",
"wildlife": "Дикая природа 🦒",
"historical_sites": "Исторические места 🏛️",
"music_concerts": "Музыка и концерты 🎶",
"fitness": "Фитнес 🏋️",
"art_museums": "Искусство и музеи 🎨",
"festivals": "Фестивали 🎪",
"spiritual_journeys": "Духовные путешествия 🧘‍♀️",
"volunteer_work": "Волонтёрская работа 🤝",
"other": "Другое"
},
"lodging_information": "Информация о жилье",
"price": "Цена",
"reservation_number": "Номер бронирования"
},
"worldtravel": {
"country_list": "Список стран",
"num_countries": "стран найдено",
"all": "Все",
"partially_visited": "Частично посещённые",
"not_visited": "Не посещённые",
"completely_visited": "Полностью посещённые",
"all_subregions": "Все субрегионы",
"clear_search": "Очистить поиск",
"no_countries_found": "Страны не найдены",
"view_cities": "Просмотреть города",
"no_cities_found": "Города не найдены",
"visit_to": "Посещение",
"region_failed_visited": "Не удалось отметить регион как посещённый",
"failed_to_mark_visit": "Не удалось отметить посещение",
"visit_remove_failed": "Не удалось удалить посещение",
"removed": "удалено",
"failed_to_remove_visit": "Не удалось удалить посещение",
"marked_visited": "отмечено как посещённое",
"regions_in": "Регионы в",
"region_stats": "Статистика регионов",
"all_visited": "Вы посетили все регионы в",
"cities": "городов"
},
"auth": {
"username": "Имя пользователя",
"password": "Пароль",
"forgot_password": "Забыли пароль?",
"signup": "Регистрация",
"login_error": "Не удалось войти с предоставленными учётными данными.",
"login": "Вход",
"email": "Email",
"first_name": "Имя",
"last_name": "Фамилия",
"confirm_password": "Подтвердите пароль",
"registration_disabled": "Регистрация в настоящее время отключена.",
"profile_picture": "Фото профиля",
"public_profile": "Публичный профиль",
"public_tooltip": "С публичным профилем пользователи могут делиться с вами коллекциями и просматривать ваш профиль на странице пользователей.",
"email_required": "Email обязателен",
"new_password": "Новый пароль (6+ символов)",
"both_passwords_required": "Оба пароля обязательны",
"reset_failed": "Не удалось сбросить пароль",
"or_3rd_party": "Или войти через сторонний сервис",
"no_public_adventures": "Публичные приключения не найдены",
"no_public_collections": "Публичные коллекции не найдены",
"user_adventures": "Приключения пользователя",
"user_collections": "Коллекции пользователя"
},
"users": {
"no_users_found": "Пользователи с публичными профилями не найдены."
},
"settings": {
"update_error": "Ошибка обновления настроек",
"update_success": "Настройки успешно обновлены!",
"settings_page": "Страница настроек",
"account_settings": "Настройки учётной записи пользователя",
"update": "Обновить",
"no_verified_email_warning": "У вас должен быть подтверждённый адрес электронной почты для включения двухфакторной аутентификации.",
"social_auth": "Социальная аутентификация",
"social_auth_desc_1": "Управление опциями социального входа и настройками пароля",
"password_auth": "Аутентификация по паролю",
"password_login_enabled": "Вход по паролю включён",
"password_login_disabled": "Вход по паролю отключён",
"password_change": "Изменить пароль",
"new_password": "Новый пароль",
"confirm_new_password": "Подтвердите новый пароль",
"email_change": "Изменить email",
"current_email": "Текущий email",
"no_email_set": "Email не установлен",
"email_management": "Управление email",
"email_management_desc": "Управление вашими адресами электронной почты и статусом подтверждения",
"add_new_email": "Добавить новый email",
"add_new_email_address": "Добавить новый адрес электронной почты",
"enter_new_email": "Введите новый адрес электронной почты",
"new_email": "Новый email",
"change_password": "Изменить пароль",
"login_redir": "Затем вы будете перенаправлены на страницу входа.",
"token_required": "Токен и UID необходимы для сброса пароля.",
"reset_password": "Сбросить пароль",
"possible_reset": "Если адрес электронной почты, который вы указали, связан с учётной записью, вы получите письмо с инструкциями по сбросу пароля!",
"missing_email": "Пожалуйста, введите адрес электронной почты",
"submit": "Отправить",
"password_does_not_match": "Пароли не совпадают",
"password_is_required": "Пароль обязателен",
"invalid_token": "Токен недействителен или истёк",
"about_this_background": "Об этом фоне",
"photo_by": "Фото",
"join_discord": "Присоединиться к Discord",
"join_discord_desc": "чтобы поделиться своими фотографиями. Размещайте их в канале #travel-share.",
"current_password": "Текущий пароль",
"change_password_error": "Не удалось изменить пароль. Неверный текущий пароль или недопустимый новый пароль.",
"password_change_lopout_warning": "Вы будете вылогинены после изменения пароля.",
"generic_error": "Произошла ошибка при обработке вашего запроса.",
"email_removed": "Email успешно удалён!",
"email_removed_error": "Ошибка удаления email",
"verify_email_success": "Подтверждение email успешно отправлено!",
"verify_email_error": "Ошибка подтверждения email. Попробуйте снова через несколько минут.",
"email_added": "Email успешно добавлен!",
"email_added_error": "Ошибка добавления email",
"email_set_primary": "Email успешно установлен как основной!",
"email_set_primary_error": "Ошибка установки email как основного",
"verified": "Подтверждён",
"primary": "Основной",
"not_verified": "Не подтверждён",
"make_primary": "Сделать основным",
"verify": "Подтвердить",
"no_emai_set": "Email не установлен",
"error_change_password": "Ошибка изменения пароля. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова.",
"mfa_disabled": "Многофакторная аутентификация успешно отключена!",
"mfa_page_title": "Многофакторная аутентификация",
"mfa_desc": "Добавьте дополнительный уровень безопасности к вашему аккаунту",
"enable_mfa": "Включить MFA",
"disable_mfa": "Отключить MFA",
"enabled": "Включено",
"disabled": "Отключено",
"mfa_not_enabled": "MFA не включен",
"mfa_is_enabled": "MFA включен",
"mfa_enabled": "Многофакторная аутентификация успешно включена!",
"copy": "Копировать",
"recovery_codes": "Коды восстановления",
"recovery_codes_desc": "Это ваши коды восстановления. Сохраните их в безопасном месте. Вы не сможете увидеть их снова.",
"reset_session_error": "Пожалуйста, выйдите из системы и войдите снова, чтобы обновить сессию и повторите попытку.",
"authenticator_code": "Код аутентификатора",
"email_verified": "Email успешно подтвержден!",
"email_verified_success": "Ваш email был подтвержден. Теперь вы можете войти в систему.",
"email_verified_error": "Ошибка подтверждения email",
"email_verified_erorr_desc": "Ваш email не может быть подтвержден. Пожалуйста, попробуйте еще раз.",
"invalid_code": "Неверный MFA код",
"invalid_credentials": "Неверное имя пользователя или пароль",
"mfa_required": "Требуется многофакторная аутентификация",
"required": "Это поле обязательно",
"add_email_blocked": "Вы не можете добавить email адрес к аккаунту, защищенному двухфакторной аутентификацией.",
"duplicate_email": "Этот email адрес уже используется.",
"csrf_failed": "Не удалось получить CSRF токен",
"email_taken": "Этот email адрес уже используется.",
"username_taken": "Это имя пользователя уже используется.",
"administration_settings": "Настройки администрирования",
"launch_administration_panel": "Запустить панель администрирования",
"administration": "Администрирование",
"admin_panel_desc": "Доступ к полному интерфейсу администрирования",
"region_updates": "Обновления регионов",
"debug_information": "Отладочная информация",
"staff_status": "Статус персонала",
"staff_user": "Сотрудник",
"regular_user": "Обычный пользователь",
"app_version": "Версия приложения",
"quick_actions": "Быстрые действия",
"license": "Лицензия",
"all_rights_reserved": "Все права защищены.",
"region_updates_desc": "Обновить посещенные регионы и города",
"access_restricted": "Доступ ограничен",
"access_restricted_desc": "Административные функции доступны только сотрудникам.",
"advanced_settings": "Расширенные настройки",
"advanced_settings_desc": "Расширенная конфигурация и инструменты разработчика",
"social_auth_setup": "Настройка социальной аутентификации",
"administration_desc": "Административные инструменты и настройки",
"social_oidc_auth": "Социальная и OIDC аутентификация",
"social_auth_desc": "Включите или отключите социальные и OIDC провайдеры аутентификации для вашего аккаунта. Эти подключения позволяют вам входить в систему с помощью самостоятельно размещенных провайдеров идентификации, таких как Authentik, или сторонних провайдеров, таких как GitHub.",
"social_auth_desc_2": "Эти настройки управляются на сервере AdventureLog и должны быть вручную включены администратором.",
"documentation_link": "Ссылка на документацию",
"launch_account_connections": "Запустить подключения аккаунта",
"password_too_short": "Пароль должен содержать не менее 6 символов",
"add_email": "Добавить Email",
"password_disable": "Отключить аутентификацию по паролю",
"password_disable_desc": "Отключение аутентификации по паролю не позволит вам входить в систему с паролем. Вам нужно будет использовать социального или OIDC провайдера для входа. Если ваш социальный провайдер будет отключен, аутентификация по паролю будет автоматически включена, даже если эта настройка отключена.",
"disable_password": "Отключить пароль",
"password_enabled": "Аутентификация по паролю включена",
"password_disabled": "Аутентификация по паролю отключена",
"password_disable_warning": "В настоящее время аутентификация по паролю отключена. Требуется вход через социального или OIDC провайдера.",
"password_disabled_error": "Ошибка отключения аутентификации по паролю. Убедитесь, что к вашему аккаунту привязан социальный или OIDC провайдер.",
"password_enabled_error": "Ошибка включения аутентификации по паролю.",
"settings_menu": "Меню настроек",
"security": "Безопасность",
"emails": "Email адреса",
"integrations": "Интеграции",
"integrations_desc": "Подключите внешние сервисы для улучшения вашего опыта",
"admin": "Админ",
"advanced": "Расширенные",
"profile_info": "Информация профиля",
"profile_info_desc": "Обновите ваши личные данные и фотографию профиля",
"public_profile_desc": "Сделать ваш профиль видимым для других пользователей",
"pass_change_desc": "Обновите пароль вашего аккаунта для лучшей безопасности",
"enter_first_name": "Введите ваше имя",
"enter_last_name": "Введите вашу фамилию",
"enter_username": "Введите ваше имя пользователя",
"enter_current_password": "Введите текущий пароль",
"enter_new_password": "Введите новый пароль",
"connected": "Подключено",
"disconnected": "Отключено",
"confirm_new_password_desc": "Подтвердите новый пароль"
},
"collection": {
"collection_created": "Коллекция успешно создана!",
"error_creating_collection": "Ошибка создания коллекции",
"new_collection": "Новая коллекция",
"create": "Создать",
"collection_edit_success": "Коллекция успешно отредактирована!",
"error_editing_collection": "Ошибка редактирования коллекции",
"edit_collection": "Редактировать коллекцию",
"public_collection": "Публичная коллекция"
},
"notes": {
"note_deleted": "Заметка успешно удалена!",
"note_delete_error": "Ошибка удаления заметки",
"open": "Открыть",
"failed_to_save": "Не удалось сохранить заметку",
"note_editor": "Редактор заметок",
"note_viewer": "Просмотр заметки",
"editing_note": "Редактирование заметки",
"content": "Содержание",
"save": "Сохранить",
"note_public": "Эта заметка публична, потому что находится в публичной коллекции.",
"add_a_link": "Добавить ссылку",
"invalid_url": "Неверный URL"
},
"checklist": {
"checklist_deleted": "Чек-лист успешно удален!",
"checklist_delete_error": "Ошибка удаления чек-листа",
"failed_to_save": "Не удалось сохранить чек-лист",
"checklist_editor": "Редактор чек-листа",
"checklist_viewer": "Просмотр чек-листа",
"editing_checklist": "Редактирование чек-листа",
"new_checklist": "Новый чек-лист",
"item": "Элемент",
"items": "Элементы",
"add_item": "Добавить элемент",
"new_item": "Новый элемент",
"save": "Сохранить",
"checklist_public": "Этот чек-лист публичен, потому что находится в публичной коллекции.",
"item_cannot_be_empty": "Элемент не может быть пустым",
"item_already_exists": "Элемент уже существует"
},
"transportation": {
"transportation_deleted": "Транспорт успешно удален!",
"transportation_delete_error": "Ошибка удаления транспорта",
"provide_start_date": "Пожалуйста, укажите дату начала",
"transport_type": "Тип транспорта",
"type": "Тип",
"transportation_added": "Транспорт успешно добавлен!",
"error_editing_transportation": "Ошибка редактирования транспорта",
"new_transportation": "Новый транспорт",
"date_time": "Дата и время начала",
"end_date_time": "Дата и время окончания",
"flight_number": "Номер рейса",
"from_location": "Откуда",
"to_location": "Куда",
"fetch_location_information": "Получить информацию о местоположении",
"starting_airport_desc": "Введите код аэропорта отправления (например, SVO)",
"ending_airport_desc": "Введите код аэропорта прибытия (например, LED)",
"edit": "Редактировать",
"modes": {
"car": "Автомобиль",
"plane": "Самолет",
"train": "Поезд",
"bus": "Автобус",
"boat": "Лодка",
"bike": "Велосипед",
"walking": "Пешком",
"other": "Другое"
},
"transportation_edit_success": "Транспорт успешно отредактирован!",
"edit_transportation": "Редактировать транспорт",
"start": "Начало",
"date_and_time": "Дата и время"
},
"lodging": {
"lodging_deleted": "Жилье успешно удалено!",
"lodging_delete_error": "Ошибка удаления жилья",
"provide_start_date": "Пожалуйста, укажите дату начала",
"lodging_type": "Тип жилья",
"type": "Тип",
"lodging_added": "Жилье успешно добавлено!",
"error_editing_lodging": "Ошибка редактирования жилья",
"new_lodging": "Новое жилье",
"check_in": "Заезд",
"check_out": "Выезд",
"edit": "Редактировать",
"lodging_edit_success": "Жилье успешно отредактировано!",
"edit_lodging": "Редактировать жилье",
"start": "Начало",
"date_and_time": "Дата и время",
"hotel": "Отель",
"hostel": "Хостел",
"resort": "Курорт",
"bnb": "Гостевой дом",
"campground": "Кемпинг",
"cabin": "Домик",
"apartment": "Квартира",
"house": "Дом",
"villa": "Вилла",
"motel": "Мотель",
"other": "Другое",
"reservation_number": "Номер бронирования",
"current_timezone": "Текущий часовой пояс"
},
"search": {
"adventurelog_results": "Результаты AdventureLog",
"public_adventures": "Публичные приключения",
"online_results": "Онлайн результаты"
},
"map": {
"view_details": "Подробности",
"adventure_map": "Карта приключений",
"map_options": "Настройки карты",
"show_visited_regions": "Показать посещенные регионы",
"add_adventure_at_marker": "Добавить новое приключение в отмеченном месте",
"clear_marker": "Очистить маркер",
"add_adventure": "Добавить новое приключение"
},
"share": {
"shared": "Поделено",
"with": "с",
"unshared": "Не поделено",
"share_desc": "Поделитесь этой коллекцией с другими пользователями.",
"shared_with": "Поделено с",
"no_users_shared": "Ни с кем не поделено",
"not_shared_with": "Не поделено с",
"no_shared_found": "Не найдено коллекций, которыми с вами поделились.",
"set_public": "Чтобы пользователи могли делиться с вами, вам нужно сделать ваш профиль публичным.",
"go_to_settings": "Перейти к настройкам"
},
"languages": {},
"profile": {
"member_since": "Участник с",
"user_stats": "Статистика пользователя",
"visited_countries": "Посещенные страны",
"visited_regions": "Посещенные регионы",
"visited_cities": "Посещенные города"
},
"categories": {
"manage_categories": "Управление категориями",
"no_categories_found": "Категории не найдены.",
"edit_category": "Редактировать категорию",
"icon": "Иконка",
"update_after_refresh": "Карточки приключений будут обновлены после обновления страницы.",
"select_category": "Выбрать категорию",
"category_name": "Название категории",
"add_category": "Добавить категорию",
"add_new_category": "Добавить новую категорию"
},
"dashboard": {
"welcome_back": "Добро пожаловать обратно",
"countries_visited": "Посещенные страны",
"total_adventures": "Всего приключений",
"total_visited_regions": "Всего посещенных регионов",
"total_visited_cities": "Всего посещенных городов",
"recent_adventures": "Недавние приключения",
"no_recent_adventures": "Нет недавних приключений?",
"add_some": "Почему бы не начать планировать ваше следующее приключение? Вы можете добавить новое приключение, нажав на кнопку ниже."
},
"immich": {
"immich": "Immich",
"integration_fetch_error": "Ошибка получения данных из интеграции Immich",
"integration_missing": "Интеграция Immich отсутствует в бэкенде",
"query_required": "Запрос обязателен",
"server_down": "Сервер Immich в настоящее время недоступен",
"no_items_found": "Элементы не найдены",
"imageid_required": "ID изображения обязателен",
"load_more": "Загрузить еще",
"immich_updated": "Настройки Immich успешно обновлены!",
"immich_enabled": "Интеграция Immich успешно включена!",
"immich_error": "Ошибка обновления интеграции Immich",
"immich_disabled": "Интеграция Immich успешно отключена!",
"immich_desc": "Интегрируйте ваш аккаунт Immich с AdventureLog, чтобы искать в вашей библиотеке фотографий и импортировать фото для ваших приключений.",
"integration_enabled": "Интеграция включена",
"disable": "Отключить",
"server_url": "URL сервера Immich",
"api_note": "Примечание: это должен быть URL к API серверу Immich, поэтому он, вероятно, заканчивается на /api, если у вас нет пользовательской конфигурации.",
"api_key": "API ключ Immich",
"enable_immich": "Включить Immich",
"enable_integration": "Включить интеграцию",
"update_integration": "Обновить интеграцию",
"immich_integration": "Интеграция Immich",
"immich_integration_desc": "Подключите ваш сервер управления фотографиями Immich",
"localhost_note": "Примечание: localhost, скорее всего, не будет работать, если вы не настроили сети Docker соответствующим образом. Рекомендуется использовать IP-адрес сервера или доменное имя.",
"documentation": "Документация интеграции Immich",
"api_key_placeholder": "Введите ваш API ключ Immich",
"need_help": "Нужна помощь с настройкой? Посмотрите",
"copy_locally": "Копировать изображения локально",
"copy_locally_desc": "Копировать изображения на сервер для офлайн доступа. Использует больше дискового пространства.",
"error_saving_image": "Ошибка сохранения изображения",
"connection_error": "Ошибка подключения к серверу Immich",
"integration_already_exists": "Интеграция Immich уже существует. Вы можете иметь только одну интеграцию одновременно.",
"integration_not_found": "Интеграция Immich не найдена. Пожалуйста, создайте новую интеграцию.",
"validation_error": "Произошла ошибка при проверке интеграции Immich. Пожалуйста, проверьте URL сервера и API ключ.",
"network_error": "Сетевая ошибка при подключении к серверу Immich. Пожалуйста, проверьте ваше соединение и попробуйте еще раз."
},
"google_maps": {
"google_maps_integration_desc": "Подключите ваш аккаунт Google Maps для получения высококачественных результатов поиска местоположений и рекомендаций."
},
"recomendations": {
"address": "Адрес",
"phone": "Телефон",
"contact": "Контакт",
"website": "Веб-сайт",
"recommendation": "Рекомендация",
"recommendations": "Рекомендации",
"adventure_recommendations": "Рекомендации приключений",
"miles": "Мили",
"food": "Еда",
"tourism": "Туризм"
}
}

View file

@ -16,8 +16,9 @@
register('pl', () => import('../locales/pl.json'));
register('ko', () => import('../locales/ko.json'));
register('no', () => import('../locales/no.json'));
register('ru', () => import('../locales/ru.json'));
let locales = ['en', 'es', 'fr', 'de', 'it', 'zh', 'nl', 'sv', 'pl', 'ko', 'no'];
let locales = ['en', 'es', 'fr', 'de', 'it', 'zh', 'nl', 'sv', 'pl', 'ko', 'no', 'ru'];
if (browser) {
init({

View file

@ -20,22 +20,10 @@ export const load = (async (event) => {
};
} else {
let adventure = (await request.json()) as AdditionalAdventure;
let collection: Collection | null = null;
if (adventure.collection) {
let res2 = await fetch(`${endpoint}/api/collections/${adventure.collection}/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
});
collection = await res2.json();
}
return {
props: {
adventure,
collection
adventure
}
};
}

View file

@ -360,12 +360,18 @@
? `🌍 ${$t('adventures.public')}`
: `🔒 ${$t('adventures.private')}`}
</div>
{#if data.props.collection}
<!-- {#if data.props.collection}
<div class="badge badge-sm badge-outline">
📚 <a href="/collections/{data.props.collection.id}" class="link"
>{data.props.collection.name}</a
>
</div>
{/if} -->
{#if adventure.collections && adventure.collections.length > 0}
<div class="badge badge-sm badge-outline">
📚
<p>{adventure.collections.length} {$t('navbar.collections')}</p>
</div>
{/if}
</div>
</div>

View file

@ -362,12 +362,21 @@
} else {
let adventure = event.detail;
// add the collection id to the adventure collections array
if (!adventure.collections) {
adventure.collections = [collection.id];
} else {
if (!adventure.collections.includes(collection.id)) {
adventure.collections.push(collection.id);
}
}
let res = await fetch(`/api/adventures/${adventure.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ collection: collection.id.toString() })
body: JSON.stringify({ collections: adventure.collections })
});
if (res.ok) {
@ -550,6 +559,7 @@
on:close={() => {
isShowingLinkModal = false;
}}
collectionId={collection.id}
on:add={addAdventure}
/>
{/if}