From 2cab28e921249cb3a74fece49f2822bc0b743faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Wed, 11 Jun 2025 14:49:55 +0700 Subject: [PATCH 1/6] Create ru.json --- frontend/src/locales/ru.json | 729 +++++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 frontend/src/locales/ru.json diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json new file mode 100644 index 0000000..dda5e30 --- /dev/null +++ b/frontend/src/locales/ru.json @@ -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": "Туризм" + } +} From 871e265001030d75486e261a3fb79eff2c97bd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Wed, 11 Jun 2025 14:58:55 +0700 Subject: [PATCH 2/6] Update Navbar.svelte --- frontend/src/lib/components/Navbar.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index ee75fa5..f655454 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -60,7 +60,8 @@ zh: '中文', pl: 'Polski', ko: '한국어', - no: 'Norsk' + no: 'Norsk', + ru: 'Русский' }; let query: string = ''; From 3f89fad67f28ac93813b321bbc98b31b768931f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Wed, 11 Jun 2025 14:59:41 +0700 Subject: [PATCH 3/6] Update +layout.svelte --- frontend/src/routes/+layout.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 27dbca4..963dfcf 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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({ From 164627b35bd5327185f8ecfe8bc4a6e30d89dfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20C=C3=A1novas?= Date: Wed, 11 Jun 2025 17:17:47 +0200 Subject: [PATCH 4/6] Update es.json Modified some translations with more contextual ones. --- frontend/src/locales/es.json | 158 +++++++++++++++++------------------ 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index fa01b4c..079e304 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -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", @@ -72,18 +72,18 @@ "delete": "Eliminar", "activities": { "activity": "Actividad 🏄", - "art_museums": "Arte", + "art_museums": "Arte y Museos 🎨", "attraction": "Atracción 🎢", "culture": "Cultura 🎭", "dining": "Cenar 🍽️", "event": "Evento 🎉", "festivals": "Festivales 🎪", - "fitness": "Fitness 🏋️", - "general": "Generales 🌍", + "fitness": "Ejercicio 🏋️", + "general": "General 🌍", "hiking": "Senderismo 🥾", "historical_sites": "Sitios Históricos 🏛️", "lodging": "Alojamiento 🛌", - "music_concerts": "Música", + "music_concerts": "Música y Conciertos 🎶", "nightlife": "Vida nocturna 🌃", "other": "Otro", "outdoor": "Al aire libre 🏞️", @@ -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", @@ -152,7 +152,7 @@ "no_image_url": "No se encontró ninguna imagen en esa URL.", "start_before_end_error": "La fecha de inicio debe ser anterior a la fecha de finalización.", "wiki_image_error": "Error al obtener la imagen de Wikipedia", - "actions": "Comportamiento", + "actions": "Acciones", "activity": "Actividad", "see_adventures": "Ver Aventuras", "activity_types": "Tipos de actividad", @@ -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": "Obtener imagen", @@ -186,7 +186,7 @@ "no_start_date": "Por favor ingrese una fecha de inicio", "public_adventure": "Aventura pública", "remove": "Eliminar", - "save_next": "Ahorrar", + "save_next": "Guardar y continuar", "search_for_location": "Buscar una ubicación", "search_results": "Resultados de búsqueda", "select_adventure_category": "Seleccionar categoría de aventura", @@ -199,13 +199,13 @@ "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": "Marcos visitó", + "mark_visited": "Marcar como visitado", "not_visited": "No visitado", "all": "Todo", "error_updating_regions": "Error al actualizar regiones", @@ -215,17 +215,17 @@ "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": "Campo de golf", + "links": "Vínculos", "no_end_date": "Por favor ingresa una fecha de finalización", "note": "Nota", "notes": "Notas", @@ -236,21 +236,21 @@ "day": "Día", "add_a_tag": "Agregar una etiqueta", "tags": "Etiquetas", - "set_to_pin": "Establecer en Fijar", + "set_to_pin": "Establecer en Marcador", "category_fetch_error": "Error al buscar categorías", "copied_to_clipboard": "¡Copiado al portapapeles!", "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", @@ -312,8 +312,8 @@ "arrival_date": "Fecha de llegada", "departure_date": "Fecha de salida", "coordinates": "Coordenadas", - "copy_coordinates": "Coordenadas de copia", - "sun_times": "Sol Times", + "copy_coordinates": "Copiar Coordenadas", + "sun_times": "Tiempos de Sol", "sunrise": "Amanecer", "sunset": "Atardecer", "timed": "Cronometrado", @@ -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", @@ -342,7 +342,7 @@ "region_failed_visited": "No se pudo marcar la región como visitada", "region_stats": "Estadísticas de la región", "regions_in": "Regiones en", - "removed": "remoto", + "removed": "eliminado", "view_cities": "Ver ciudades", "visit_remove_failed": "No se pudo eliminar la visita", "visit_to": "Visita a" @@ -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,26 +396,26 @@ "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!", "email_set_primary_error": "Error al configurar el correo electrónico como principal", - "make_primary": "Hacer primario", + "make_primary": "Hacer principal", "no_emai_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,21 +516,21 @@ "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": "Ahorrar", - "checklist_viewer": "Visor de lista de verificación", - "new_checklist": "Nueva lista de verificación" + "new_item": "Nuevo elemento", + "save": "Guardar", + "checklist_viewer": "Visor de lista de tareas", + "new_checklist": "Nueva lista de tareas" }, "collection": { "collection_created": "¡Colección creada con éxito!", @@ -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", - "save": "Ahorrar", + "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", @@ -650,7 +650,7 @@ "disable": "Desactivar", "enable_immich": "Habilitar Immich", "imageid_required": "Se requiere identificación con imagen", - "immich": "immicha", + "immich": "Immich", "immich_desc": "Integre su cuenta de Immich con AdventureLog para permitirle buscar en su biblioteca de fotos e importar fotos para sus aventuras.", "immich_disabled": "¡La integración de Immich se deshabilitó exitosamente!", "immich_enabled": "¡La integración de Immich se habilitó exitosamente!", @@ -660,46 +660,46 @@ "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", "update_integration": "Integración de actualización", "immich_integration": "Integración Immich", "documentation": "Documentación de integración de Immich", - "localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio.", - "api_key_placeholder": "Ingrese su clave de API IMICH", + "localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes docker correctamente. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio.", + "api_key_placeholder": "Ingrese su clave de API Immich", "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", - "integration_already_exists": "Ya existe una integración IMICH. \nSolo puedes tener una integración a la vez.", - "integration_not_found": "Integración IMACH no encontrada. \nPor favor cree una nueva integración.", + "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", From f72fb50eb0dfe54b6cf104ecf8ae363921e2d8b6 Mon Sep 17 00:00:00 2001 From: Sean Morley <98704938+seanmorley15@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:11:23 -0400 Subject: [PATCH 5/6] Update ru.json --- frontend/src/locales/ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index dda5e30..eec8bed 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -434,7 +434,7 @@ "error_change_password": "Ошибка изменения пароля. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова.", "mfa_disabled": "Многофакторная аутентификация успешно отключена!", "mfa_page_title": "Многофакторная аутентификация", - "mfa_desc": "Добавьте дополнительный уровень безопасности к вашему аккаунту",, + "mfa_desc": "Добавьте дополнительный уровень безопасности к вашему аккаунту", "enable_mfa": "Включить MFA", "disable_mfa": "Отключить MFA", "enabled": "Включено", From 3f9a6767bdda7ba61e7dcda991bd5e146210ea37 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 12 Jun 2025 15:54:01 -0400 Subject: [PATCH 6/6] feat: Enhance Adventure and Collection Management - Added support for multiple collections in AdventureSerializer, allowing adventures to be linked to multiple collections. - Implemented validation to ensure collections belong to the current user during adventure creation and updates. - Introduced a signal to update adventure publicity based on the public status of linked collections. - Updated file permission checks to consider multiple collections when determining access rights. - Modified AdventureImageViewSet and AttachmentViewSet to check access against collections instead of a single collection. - Enhanced AdventureViewSet to support filtering and sorting adventures based on collections. - Updated frontend components to manage collections more effectively, including linking and unlinking adventures from collections. - Adjusted API endpoints and data structures to accommodate the new collections feature. - Improved user experience with appropriate notifications for collection actions. --- backend/server/adventures/apps.py | 6 +- backend/server/adventures/managers.py | 9 +- ...enture_collection_adventure_collections.py | 59 +++ backend/server/adventures/models.py | 59 ++- backend/server/adventures/permissions.py | 109 +++-- backend/server/adventures/serializers.py | 49 ++- backend/server/adventures/signals.py | 23 ++ .../adventures/utils/file_permissions.py | 25 +- .../adventures/views/adventure_image_view.py | 18 +- .../server/adventures/views/adventure_view.py | 378 ++++++++++++------ .../adventures/views/attachment_view.py | 26 +- .../adventures/views/collection_view.py | 33 +- documentation/docs/install/getting_started.md | 2 +- .../src/lib/components/AdventureCard.svelte | 83 ++-- .../src/lib/components/AdventureLink.svelte | 14 +- .../src/lib/components/CollectionCard.svelte | 25 +- .../src/lib/components/CollectionLink.svelte | 18 +- frontend/src/lib/types.ts | 2 +- frontend/src/locales/en.json | 3 +- .../routes/adventures/[id]/+page.server.ts | 14 +- .../src/routes/adventures/[id]/+page.svelte | 8 +- .../src/routes/collections/[id]/+page.svelte | 12 +- 22 files changed, 686 insertions(+), 289 deletions(-) create mode 100644 backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py create mode 100644 backend/server/adventures/signals.py diff --git a/backend/server/adventures/apps.py b/backend/server/adventures/apps.py index 37a5920..e706a17 100644 --- a/backend/server/adventures/apps.py +++ b/backend/server/adventures/apps.py @@ -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' \ No newline at end of file + name = 'adventures' + + def ready(self): + import adventures.signals # Import signals when the app is ready \ No newline at end of file diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py index 6d8d43c..4a12194 100644 --- a/backend/server/adventures/managers.py +++ b/backend/server/adventures/managers.py @@ -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() diff --git a/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py b/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py new file mode 100644 index 0000000..59d3580 --- /dev/null +++ b/backend/server/adventures/migrations/0035_remove_adventure_collection_adventure_collections.py @@ -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', + ), + ] \ No newline at end of file diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 91be2d7..40bb680 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -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 diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 941fbc0..ce38f74 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -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 \ No newline at end of file + return False diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 2708ab3..4985d2d 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -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', '�') + 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') diff --git a/backend/server/adventures/signals.py b/backend/server/adventures/signals.py new file mode 100644 index 0000000..b8501c8 --- /dev/null +++ b/backend/server/adventures/signals.py @@ -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']) diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py index 02971bc..b9a63f0 100644 --- a/backend/server/adventures/utils/file_permissions.py +++ b/backend/server/adventures/utils/file_permissions.py @@ -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: diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py index 52839de..ab7f8e1 100644 --- a/backend/server/adventures/views/adventure_image_view.py +++ b/backend/server/adventures/views/adventure_image_view.py @@ -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): diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py index a936f86..43ca0c4 100644 --- a/backend/server/adventures/views/adventure_view.py +++ b/backend/server/adventures/views/adventure_view.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py index 0292b16..2ca4770 100644 --- a/backend/server/adventures/views/attachment_view.py +++ b/backend/server/adventures/views/attachment_view.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index 1765342..fca9986 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -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) diff --git a/documentation/docs/install/getting_started.md b/documentation/docs/install/getting_started.md index 87c5bee..ff8d4b5 100644 --- a/documentation/docs/install/getting_started.md +++ b/documentation/docs/install/getting_started.md @@ -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 diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index dbdf5e4..c1e3be3 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -88,38 +88,61 @@ } } - async function removeFromCollection() { + async function linkCollection(event: CustomEvent) { + 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) { + async function removeFromCollection(event: CustomEvent) { 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 @@ {#if isCollectionModalOpen} - (isCollectionModalOpen = false)} /> + linkCollection(e)} + on:unlink={(e) => removeFromCollection(e)} + on:close={() => (isCollectionModalOpen = false)} + linkedCollectionList={adventure.collections} + /> {/if} {#if isWarningModalOpen} @@ -269,23 +297,14 @@ - {#if adventure.collection && user?.uuid == adventure.user_id} -
  • - -
  • - {/if} - - {#if !adventure.collection} + {#if user?.uuid == adventure.user_id}
  • {/if} diff --git a/frontend/src/lib/components/AdventureLink.svelte b/frontend/src/lib/components/AdventureLink.svelte index 9d7d094..74cfd13 100644 --- a/frontend/src/lib/components/AdventureLink.svelte +++ b/frontend/src/lib/components/AdventureLink.svelte @@ -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; }); diff --git a/frontend/src/lib/components/CollectionCard.svelte b/frontend/src/lib/components/CollectionCard.svelte index 0ca06c9..e103adb 100644 --- a/frontend/src/lib/components/CollectionCard.svelte +++ b/frontend/src/lib/components/CollectionCard.svelte @@ -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 @@
    {#if type == 'link'} - + {#if linkedCollectionList && linkedCollectionList + .map(String) + .includes(String(collection.id))} + + {:else} + + {/if} {:else}