mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-18 20:39:36 +02:00
Merge branch 'development' into fix-spanish-translations
This commit is contained in:
commit
c60ced09c4
26 changed files with 1477 additions and 349 deletions
|
@ -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'
|
||||
name = 'adventures'
|
||||
|
||||
def ready(self):
|
||||
import adventures.signals # Import signals when the app is ready
|
|
@ -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()
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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():
|
||||
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():
|
||||
return True
|
||||
# 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
|
||||
|
||||
# Read permissions are allowed if the object is public
|
||||
# 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 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):
|
||||
|
||||
# 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
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return request.method in permissions.SAFE_METHODS and obj.is_public
|
||||
|
||||
# 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
|
||||
|
|
|
@ -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,14 +181,23 @@ 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()
|
||||
|
||||
|
@ -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')
|
||||
|
|
23
backend/server/adventures/signals.py
Normal file
23
backend/server/adventures/signals.py
Normal 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'])
|
|
@ -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():
|
||||
return True
|
||||
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():
|
||||
return True
|
||||
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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
|
@ -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):
|
||||
serializer.save(user_id=self.request.user)
|
||||
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)
|
|
@ -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)
|
||||
|
||||
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 = []
|
||||
|
||||
# do the same for transportations
|
||||
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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: collectionId })
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log('Adventure linked to collection');
|
||||
addToast('info', `${$t('adventures.collection_link_success')}`);
|
||||
isCollectionModalOpen = false;
|
||||
dispatch('delete', adventure.id);
|
||||
} else {
|
||||
addToast('error', `${$t('adventures.collection_link_error')}`);
|
||||
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({ collections: updatedCollections })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Only update adventure.collections after server confirms success
|
||||
adventure.collections = updatedCollections;
|
||||
addToast('info', `${$t('adventures.collection_remove_success')}`);
|
||||
} else {
|
||||
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}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
@ -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)}>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('adventures.add_to_collection')}
|
||||
</button>
|
||||
{#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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -60,7 +60,8 @@
|
|||
zh: '中文',
|
||||
pl: 'Polski',
|
||||
ko: '한국어',
|
||||
no: 'Norsk'
|
||||
no: 'Norsk',
|
||||
ru: 'Русский'
|
||||
};
|
||||
|
||||
let query: string = '';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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",
|
||||
|
|
729
frontend/src/locales/ru.json
Normal file
729
frontend/src/locales/ru.json
Normal 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": "Туризм"
|
||||
}
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue