From 128c33d9a18f19f2db65a3f96170f583ab97c74c Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Mon, 6 Jan 2025 14:25:57 -0500 Subject: [PATCH 01/35] feat: add social authentication support with enabled providers endpoint and UI integration --- backend/server/main/settings.py | 16 +++++++++--- backend/server/main/urls.py | 4 ++- backend/server/requirements.txt | 6 ++--- backend/server/users/views.py | 31 ++++++++++++++++++++++- frontend/src/locales/en.json | 3 ++- frontend/src/routes/login/+page.server.ts | 10 +++++++- frontend/src/routes/login/+page.svelte | 25 ++++++++++++++++-- 7 files changed, 83 insertions(+), 12 deletions(-) diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 5acecb7..c135d79 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', + "allauth_ui", 'rest_framework', 'rest_framework.authtoken', 'allauth', @@ -49,8 +50,8 @@ INSTALLED_APPS = ( 'allauth.mfa', 'allauth.headless', 'allauth.socialaccount', - # "widget_tweaks", - # "slippers", + 'allauth.socialaccount.providers.github', + 'allauth.socialaccount.providers.openid_connect', 'drf_yasg', 'corsheaders', 'adventures', @@ -58,6 +59,9 @@ INSTALLED_APPS = ( 'users', 'integrations', 'django.contrib.gis', + 'widget_tweaks', + 'slippers', + ) MIDDLEWARE = ( @@ -75,6 +79,8 @@ MIDDLEWARE = ( # disable verifications for new users ACCOUNT_EMAIL_VERIFICATION = 'none' +ALLAUTH_UI_THEME = "night" + CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', @@ -120,7 +126,7 @@ USE_L10N = True USE_TZ = True - +SESSION_COOKIE_SAMESITE = None # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ @@ -143,6 +149,7 @@ STORAGES = { } } +SILENCED_SYSTEM_CHECKS = ["slippers.E001"] TEMPLATES = [ { @@ -175,6 +182,9 @@ SESSION_SAVE_EVERY_REQUEST = True FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000') +# Set login redirect URL to the frontend +LOGIN_REDIRECT_URL = FRONTEND_URL + HEADLESS_FRONTEND_URLS = { "account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}", "account_reset_password": f"{FRONTEND_URL}/user/reset-password", diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index ab1e084..9cf7532 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.views.generic import RedirectView, TemplateView from django.conf import settings from django.conf.urls.static import static -from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView +from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView from .views import get_csrf_token from drf_yasg.views import get_schema_view @@ -27,6 +27,8 @@ urlpatterns = [ path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'), + path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), + path('csrf/', get_csrf_token, name='get_csrf_token'), path('', TemplateView.as_view(template_name='home.html')), diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index bae189f..228249f 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -13,8 +13,8 @@ django-geojson setuptools gunicorn==23.0.0 qrcode==8.0 -# slippers==0.6.2 -# django-allauth-ui==1.5.1 -# django-widget-tweaks==1.5.0 +slippers==0.6.2 +django-allauth-ui==1.5.1 +django-widget-tweaks==1.5.0 django-ical==1.9.2 icalendar==6.1.0 \ No newline at end of file diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 109d04b..b03760e 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -1,3 +1,4 @@ +from os import getenv from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -9,6 +10,7 @@ from django.conf import settings from django.shortcuts import get_object_or_404 from django.contrib.auth import get_user_model from .serializers import CustomUserDetailsSerializer as PublicUserSerializer +from allauth.socialaccount.models import SocialApp User = get_user_model() @@ -120,4 +122,31 @@ class UpdateUserMetadataView(APIView): if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class EnabledSocialProvidersView(APIView): + """ + Get enabled social providers for social authentication. This is used to determine which buttons to show on the frontend. Also returns a URL for each to start the authentication flow. + """ + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Enabled social providers'), + 400: 'Bad Request' + }, + operation_description="Get enabled social providers." + ) + def get(self, request): + social_providers = SocialApp.objects.filter(sites=settings.SITE_ID) + providers = [] + for provider in social_providers: + if provider.provider == 'openid_connect': + new_provider = f'oidc/{provider.client_id}' + else: + new_provider = provider.provider + providers.append({ + 'provider': provider.provider, + 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", + 'name': provider.name + }) + return Response(providers, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a7e668e..050ae37 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -295,7 +295,8 @@ "email_required": "Email is required", "new_password": "New Password (6+ characters)", "both_passwords_required": "Both passwords are required", - "reset_failed": "Failed to reset password" + "reset_failed": "Failed to reset password", + "or_3rd_party": "Or login with a third-party service" }, "users": { "no_users_found": "No users found with public profiles." diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index adb0905..a57865c 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -4,6 +4,7 @@ import type { Actions, PageServerLoad, RouteParams } from './$types'; import { getRandomBackground, getRandomQuote } from '$lib'; import { fetchCSRFToken } from '$lib/index.server'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load: PageServerLoad = async (event) => { if (event.locals.user) { @@ -12,10 +13,17 @@ export const load: PageServerLoad = async (event) => { const quote = getRandomQuote(); const background = getRandomBackground(); + let socialProviderFetch = await event.fetch(`${serverEndpoint}/auth/social-providers/`); + if (!socialProviderFetch.ok) { + return fail(500, { message: 'settings.social_providers_error' }); + } + let socialProviders = await socialProviderFetch.json(); + return { props: { quote, - background + background, + socialProviders } }; } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3bf6c98..d6ba6b7 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -9,14 +9,19 @@ let isImageInfoModalOpen: boolean = false; + let socialProviders = data.props?.socialProviders ?? []; + + import GitHub from '~icons/mdi/github'; + import OpenIdConnect from '~icons/mdi/openid'; + import { page } from '$app/stores'; import ImageInfoModal from '$lib/components/ImageInfoModal.svelte'; import type { Background } from '$lib/types.js'; - let quote: { quote: string; author: string } = data.props.quote; + let quote: { quote: string; author: string } = data.props?.quote ?? { quote: '', author: '' }; - let background: Background = data.props.background; + let background: Background = data.props?.background ?? { url: '' }; {#if isImageInfoModalOpen} @@ -62,6 +67,22 @@ {/if} + {#if socialProviders.length > 0} +
{$t('auth.or_3rd_party')}
+
+ {#each socialProviders as provider} + + {#if provider.provider === 'github'} + + {:else if provider.provider === 'openid_connect'} + + {/if} + {provider.name} + + {/each} +
+ {/if} +

{$t('auth.signup')}

From 59b41c01dfd887ff95ff904ae07d77b7b1d9e74a Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Mon, 6 Jan 2025 14:49:23 -0500 Subject: [PATCH 02/35] feat: enhance CSRF token handling and add format=json to API requests --- .../src/routes/_allauth/[...path]/+server.ts | 25 +++++--- frontend/src/routes/api/[...path]/+server.ts | 9 ++- frontend/src/routes/auth/[...path]/+server.ts | 62 ++++++++++++------- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/frontend/src/routes/_allauth/[...path]/+server.ts b/frontend/src/routes/_allauth/[...path]/+server.ts index 681a3fa..4d6bc32 100644 --- a/frontend/src/routes/_allauth/[...path]/+server.ts +++ b/frontend/src/routes/_allauth/[...path]/+server.ts @@ -12,23 +12,23 @@ export async function GET(event) { /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PATCH({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PUT({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function DELETE({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } async function handleRequest( @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 33c2e2a..c77044c 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, diff --git a/frontend/src/routes/auth/[...path]/+server.ts b/frontend/src/routes/auth/[...path]/+server.ts index 7e0c8b0..270b233 100644 --- a/frontend/src/routes/auth/[...path]/+server.ts +++ b/frontend/src/routes/auth/[...path]/+server.ts @@ -1,69 +1,84 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; +import { fetchCSRFToken } from '$lib/index.server'; import { json } from '@sveltejs/kit'; /** @type {import('./$types').RequestHandler} */ -export async function GET({ url, params, request, fetch, cookies }) { - // add the param format = json to the url or add additional if anothre param is already present - if (url.search) { - url.search = url.search + '&format=json'; - } else { - url.search = '?format=json'; - } - return handleRequest(url, params, request, fetch, cookies); +export async function GET(event) { + const { url, params, request, fetch, cookies } = event; + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam); } /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PATCH({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function PUT({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } export async function DELETE({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + return handleRequest(url, params, request, fetch, cookies, searchParam, true); } -// Implement other HTTP methods as needed (PUT, DELETE, etc.) - async function handleRequest( url: any, params: any, request: any, fetch: any, cookies: any, + searchParam: string, requreTrailingSlash: boolean | undefined = false ) { const path = params.path; - let targetUrl = `${endpoint}/auth/${path}${url.search}`; + let targetUrl = `${endpoint}/auth/${path}`; + // Ensure the path ends with a trailing slash if (requreTrailingSlash && !targetUrl.endsWith('/')) { targetUrl += '/'; } + // Append query parameters to the path correctly + targetUrl += searchParam; // This will add ?format=json or &format=json to the URL + const headers = new Headers(request.headers); - const authCookie = cookies.get('auth'); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); - if (authCookie) { - headers.set('Cookie', `${authCookie}`); + // Generate a new csrf token (using your existing fetchCSRFToken function) + const csrfToken = await fetchCSRFToken(); + if (!csrfToken) { + return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, - headers: headers, - body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined + headers: { + ...Object.fromEntries(headers), + 'X-CSRFToken': csrfToken, + Cookie: cookieHeader + }, + body: + request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, + credentials: 'include' // This line ensures cookies are sent with the request }); if (response.status === 204) { - // For 204 No Content, return a response with no body return new Response(null, { status: 204, headers: response.headers @@ -71,10 +86,13 @@ async function handleRequest( } const responseData = await response.text(); + // Create a new Headers object without the 'set-cookie' header + const cleanHeaders = new Headers(response.headers); + cleanHeaders.delete('set-cookie'); return new Response(responseData, { status: response.status, - headers: response.headers + headers: cleanHeaders }); } catch (error) { console.error('Error forwarding request:', error); From e19781d7ac2a3870f4d43e80e0afaee79127b29d Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Mon, 6 Jan 2025 18:53:08 -0500 Subject: [PATCH 03/35] feat: add public URL endpoint and update user type to include password status --- backend/server/main/urls.py | 3 +- backend/server/main/views.py | 4 + backend/server/users/serializers.py | 46 ++++++---- documentation/.vitepress/config.mts | 22 +++++ .../docs/configuration/social_auth.md | 15 ++++ .../configuration/social_auth/authentik.md | 52 ++++++++++++ .../docs/configuration/social_auth/github.md | 0 .../docs/configuration/social_auth/oidc.md | 0 documentation/public/authentik_settings.png | Bin 0 -> 83405 bytes frontend/src/app.d.ts | 1 + frontend/src/lib/components/Avatar.svelte | 5 +- frontend/src/lib/types.ts | 1 + frontend/src/routes/dashboard/+page.svelte | 6 +- frontend/src/routes/settings/+page.server.ts | 65 ++++++++++---- frontend/src/routes/settings/+page.svelte | 79 +++++++++++++++--- 15 files changed, 248 insertions(+), 51 deletions(-) create mode 100644 documentation/docs/configuration/social_auth.md create mode 100644 documentation/docs/configuration/social_auth/authentik.md create mode 100644 documentation/docs/configuration/social_auth/github.md create mode 100644 documentation/docs/configuration/social_auth/oidc.md create mode 100644 documentation/public/authentik_settings.png diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 9cf7532..571946e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -4,7 +4,7 @@ from django.views.generic import RedirectView, TemplateView from django.conf import settings from django.conf.urls.static import static from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView -from .views import get_csrf_token +from .views import get_csrf_token, get_public_url from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -30,6 +30,7 @@ urlpatterns = [ path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), path('csrf/', get_csrf_token, name='get_csrf_token'), + path('public-url/', get_public_url, name='get_public_url'), path('', TemplateView.as_view(template_name='home.html')), diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 7a7507d..f21082b 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -1,6 +1,10 @@ from django.http import JsonResponse from django.middleware.csrf import get_token +from os import getenv def get_csrf_token(request): csrf_token = get_token(request) return JsonResponse({'csrfToken': csrf_token}) + +def get_public_url(request): + return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index b85608c..0e0828f 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -36,7 +36,7 @@ import os class UserDetailsSerializer(serializers.ModelSerializer): """ - User model w/o password + User model without exposing the password. """ @staticmethod @@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer): return username class Meta: + model = CustomUser extra_fields = ['profile_pic', 'uuid', 'public_profile'] - profile_pic = serializers.ImageField(required=False) if hasattr(UserModel, 'USERNAME_FIELD'): extra_fields.append(UserModel.USERNAME_FIELD) @@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') - if hasattr(UserModel, 'public_profile'): - extra_fields.append('public_profile') - class Meta: - model = CustomUser - fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') - - model = UserModel - fields = ('pk', *extra_fields) + fields = ['pk', *extra_fields] read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') def handle_public_profile_change(self, instance, validated_data): - """Remove user from `shared_with` if public profile is set to False.""" + """ + Remove user from `shared_with` if public profile is set to False. + """ if 'public_profile' in validated_data and not validated_data['public_profile']: for collection in Collection.objects.filter(shared_with=instance): collection.shared_with.remove(instance) @@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer): class CustomUserDetailsSerializer(UserDetailsSerializer): + """ + Custom serializer to add additional fields and logic for the user details. + """ + has_password = serializers.SerializerMethodField() class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') - read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',) + fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password') + + @staticmethod + def get_has_password(instance): + """ + Computes whether the user has a usable password set. + """ + return instance.has_usable_password() def to_representation(self, instance): + """ + Customizes the serialized output to modify `profile_pic` URL and add computed fields. + """ representation = super().to_representation(instance) + + # Construct profile picture URL if it exists if instance.profile_pic: public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') - #print(public_url) - # remove any ' from the url - public_url = public_url.replace("'", "") + public_url = public_url.replace("'", "") # Sanitize URL representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}" - del representation['pk'] # remove the pk field from the response + + # Remove `pk` field from the response + representation.pop('pk', None) + return representation diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 4f1264c..7ec0abb 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -91,6 +91,28 @@ export default defineConfig({ text: "Immich Integration", link: "/docs/configuration/immich_integration", }, + { + text: "Social Auth", + link: "/docs/configuration/social_auth", + }, + { + text: "Authentication Providers", + collapsed: false, + items: [ + { + text: "Authentik", + link: "/docs/configuration/social_auth/authentik", + }, + { + text: "GitHub", + link: "/docs/configuration/social_auth/github", + }, + { + text: "Open ID Connect", + link: "/docs/configuration/social_auth/oidc", + }, + ], + }, { text: "Update App", link: "/docs/configuration/updating", diff --git a/documentation/docs/configuration/social_auth.md b/documentation/docs/configuration/social_auth.md new file mode 100644 index 0000000..9da70e1 --- /dev/null +++ b/documentation/docs/configuration/social_auth.md @@ -0,0 +1,15 @@ +# Social Authentication + +AdventureLog support autentication via 3rd party services and self-hosted identity providers. Once these services are enabled, users can log in to AdventureLog using their accounts from these services and link exising AdventureLog accounts to these services for easier access. + +The steps for each service varies so please refer to the specific service's documentation for more information. + +## Supported Services + +- [Authentik](social_auth/authentik.md) (self-hosted) +- [GitHub](social_auth/github.md) +- [Open ID Connect](social_auth/oidc.md) + +## Linking Existing Accounts + +If you already have an AdventureLog account and would like to link it to a 3rd party service, you can do so by logging in to AdventureLog and navigating to the `Account Settings` page. From there, scroll down to `Social and OIDC Authentication` and click the `Launch Account Connections` button. If identity providers have been enabled on your instance, you will see a list of available services to link to. diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md new file mode 100644 index 0000000..bf9c5f4 --- /dev/null +++ b/documentation/docs/configuration/social_auth/authentik.md @@ -0,0 +1,52 @@ +# Authentik Social Authentication + +Authentik Logo + +Authentik is a self-hosted identity provider that supports OpenID Connect and OAuth2. AdventureLog can be configured to use Authentik as an identity provider for social authentication. Learn more about Authentik at [goauthentik.io](https://goauthentik.io/). + +Once Authentik is configured by the administrator, users can log in to AdventureLog using their Authentik account and link existing AdventureLog accounts to Authentik for easier access. + +# Configuration + +To enable Authentik as an identity provider, the administrator must first configure Authentik to allow AdventureLog to authenticate users. + +### Authentik Configuration + +1. Log in to Authentik and navigate to the `Providers` page and create a new provider. +2. Select `OAuth2/OpenID Provider` as the provider type. +3. Name it `AdventureLog` or any other name you prefer. +4. Set the `Redirect URI` of type `Regex` to `^http:///accounts/oidc/.*$` where `` is the URL of your AdventureLog Server service. +5. Copy the `Client ID` and `Client Secret` generated by Authentik, you will need these to configure AdventureLog. +6. Create an application in Authentik and assign the provider to it, name the `slug` `adventurelog` or any other name you prefer. + +### AdventureLog Configuration + +This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server. + +1. Login to AdventureLog as an administrator and navigate to the `Settings` page. +2. Scroll down to the `Administration Settings` and launch the admin panel. +3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields: + + - Provider: `OpenID Connect` + - Provider ID: Autnentik Client ID + - Name: `Authentik` + - Client ID: Authentik Client ID + - Secret Key: Authentik Client Secret + - Key: can be left blank + - Settings: (make sure http/https is set correctly) + + ```json + { + "server_url": "http:///application/o/[YOUR_SLUG]/" + } + ``` + + - Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/authentik_settings.png) + +4. Save the configuration and restart the AdventureLog server. + +Ensure that the Authentik server is running and accessible by AdventureLog. Users should now be able to log in to AdventureLog using their Authentik account. diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md new file mode 100644 index 0000000..e69de29 diff --git a/documentation/docs/configuration/social_auth/oidc.md b/documentation/docs/configuration/social_auth/oidc.md new file mode 100644 index 0000000..e69de29 diff --git a/documentation/public/authentik_settings.png b/documentation/public/authentik_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..d48e2c397db567d8ea9fd30c35f8fea28bcbd283 GIT binary patch literal 83405 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 z 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 literal 0 HcmV?d00001 diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 166ff33..56c5f94 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -15,6 +15,7 @@ declare global { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; } | null; locale: string; } diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index e9c7033..a94a8eb 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -30,8 +30,9 @@

- {$t('navbar.greeting')}, {user.first_name} - {user.last_name} + {$t('navbar.greeting')}, {user.first_name + ? `${user.first_name} ${user.last_name}` + : user.username}

  • diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea9e1fb..afac6a1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,7 @@ export type User = { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; }; export type Adventure = { diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 00139e5..43c32d2 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -17,7 +17,11 @@
    -

    {$t('dashboard.welcome_back')}, {user?.first_name}!

    +

    + {$t('dashboard.welcome_back')}, {user?.first_name + ? `${user.first_name} ${user.last_name}` + : user?.username}! +

    diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index ca29ee6..20bdb24 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -66,12 +66,22 @@ export const load: PageServerLoad = async (event) => { immichIntegration = await immichIntegrationsFetch.json(); } + let publicUrlFetch = await fetch(`${endpoint}/public-url/`); + let publicUrl = ''; + if (!publicUrlFetch.ok) { + return redirect(302, '/'); + } else { + let publicUrlJson = await publicUrlFetch.json(); + publicUrl = publicUrlJson.PUBLIC_URL; + } + return { props: { user, emails, authenticators, - immichIntegration + immichIntegration, + publicUrl } }; }; @@ -179,33 +189,52 @@ export const actions: Actions = { const password1 = formData.get('password1') as string | null | undefined; const password2 = formData.get('password2') as string | null | undefined; - const current_password = formData.get('current_password') as string | null | undefined; + let current_password = formData.get('current_password') as string | null | undefined; if (password1 !== password2) { return fail(400, { message: 'settings.password_does_not_match' }); } if (!current_password) { - return fail(400, { message: 'settings.password_is_required' }); + current_password = null; } let csrfToken = await fetchCSRFToken(); - let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - current_password, - new_password: password1 - }) - }); - if (!res.ok) { - return fail(res.status, { message: 'settings.error_change_password' }); + if (current_password) { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password, + new_password: password1 + }) + }); + if (!res.ok) { + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; + } else { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + new_password: password1 + }) + }); + if (!res.ok) { + console.log('Error:', await res.json()); + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; } - return { success: true }; }, changeEmail: async (event) => { if (!event.locals.user) { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 6c2b97f..65b0905 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ import TotpModal from '$lib/components/TOTPModal.svelte'; import { appTitle, appVersion } from '$lib/config.js'; import ImmichLogo from '$lib/assets/immich.svg'; + import { goto } from '$app/navigation'; export let data; console.log(data); @@ -20,7 +21,7 @@ } let new_email: string = ''; - + let public_url: string = data.props.publicUrl; let immichIntegration = data.props.immichIntegration; let newImmichIntegration: ImmichIntegration = { @@ -307,17 +308,19 @@
    -
    - - -
    + {#if user.has_password} +
    + + +
    + {/if}
    diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index afac6a1..aa244d3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -57,13 +57,21 @@ export type Country = { }; export type Region = { - id: number; + id: string; name: string; - country: number; + country: string; latitude: number; longitude: number; }; +export type City = { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + region: string; +}; + export type VisitedRegion = { id: number; region: number; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a46986d..9f8c2e9 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -275,7 +275,8 @@ "completely_visited": "Completely Visited", "all_subregions": "All Subregions", "clear_search": "Clear Search", - "no_countries_found": "No countries found" + "no_countries_found": "No countries found", + "view_cities": "View Cities" }, "auth": { "username": "Username", diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts new file mode 100644 index 0000000..79a0d95 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -0,0 +1,65 @@ +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +import type { City, Country, Region, VisitedRegion } from '$lib/types'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load = (async (event) => { + const id = event.params.id.toUpperCase(); + + let cities: City[] = []; + let region = {} as Region; + + let sessionId = event.cookies.get('sessionid'); + + if (!sessionId) { + return redirect(302, '/login'); + } + + let res = await fetch(`${endpoint}/api/regions/${id}/cities/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch regions'); + return redirect(302, '/404'); + } else { + cities = (await res.json()) as City[]; + } + + // res = await fetch(`${endpoint}/api/${id}/visits/`, { + // method: 'GET', + // headers: { + // Cookie: `sessionid=${sessionId}` + // } + // }); + // if (!res.ok) { + // console.error('Failed to fetch visited regions'); + // return { status: 500 }; + // } else { + // visitedRegions = (await res.json()) as VisitedRegion[]; + // } + + res = await fetch(`${endpoint}/api/regions/${id}/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch country'); + return { status: 500 }; + } else { + region = (await res.json()) as Region; + } + + return { + props: { + cities, + region + } + }; +}) satisfies PageServerLoad; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte new file mode 100644 index 0000000..80b4108 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -0,0 +1,148 @@ + + +

    {$t('worldtravel.country_list')}

    + +

    + {allCities.length} + Cities Found +

    + + + +
    + + {#if searchQuery.length > 0} + +
    + +
    + {/if} +
    + +
    + + + + {#each filteredCities as city} + {#if city.latitude && city.longitude} + + + {city.name} + + + {/if} + {/each} + +
    + +
    + {#each filteredCities as city} + + {/each} +
    + +{#if filteredCities.length === 0} +

    {$t('worldtravel.no_countries_found')}

    +{/if} + + + Countries | World Travel + + From 80cec30fda19c819e36ad42d900477fdd430ff00 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 9 Jan 2025 12:38:29 -0500 Subject: [PATCH 09/35] feat: add VisitedCity model and serializer, update admin interface, and enhance city visit tracking functionality --- backend/server/adventures/admin.py | 3 +- .../migrations/0013_visitedcity.py | 24 +++ backend/server/worldtravel/models.py | 19 +- backend/server/worldtravel/serializers.py | 12 +- backend/server/worldtravel/urls.py | 4 +- backend/server/worldtravel/views.py | 53 +++++- frontend/src/lib/components/CityCard.svelte | 59 ++++-- frontend/src/lib/components/RegionCard.svelte | 40 ++-- frontend/src/lib/types.ts | 11 +- frontend/src/locales/en.json | 3 +- frontend/src/routes/api/[...path]/+server.ts | 2 +- .../src/routes/worldtravel/+page.server.ts | 77 -------- .../src/routes/worldtravel/[id]/+page.svelte | 62 +++--- .../worldtravel/[id]/[id]/+page.server.ts | 32 ++-- .../routes/worldtravel/[id]/[id]/+page.svelte | 177 +++++++++++++----- 15 files changed, 344 insertions(+), 234 deletions(-) create mode 100644 backend/server/worldtravel/migrations/0013_visitedcity.py diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index e809aa4..5c39301 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -2,7 +2,7 @@ import os from django.contrib import admin from django.utils.html import mark_safe from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category -from worldtravel.models import Country, Region, VisitedRegion, City +from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login admin.autodiscover() @@ -138,6 +138,7 @@ admin.site.register(ChecklistItem) admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(City, CityAdmin) +admin.site.register(VisitedCity) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/worldtravel/migrations/0013_visitedcity.py b/backend/server/worldtravel/migrations/0013_visitedcity.py new file mode 100644 index 0000000..3b1e294 --- /dev/null +++ b/backend/server/worldtravel/migrations/0013_visitedcity.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.8 on 2025-01-09 17:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0012_city'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VisitedCity', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index 296b3bd..9e83f59 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -60,4 +60,21 @@ class VisitedRegion(models.Model): def save(self, *args, **kwargs): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): raise ValidationError("Region already visited by user.") - super().save(*args, **kwargs) \ No newline at end of file + super().save(*args, **kwargs) + +class VisitedCity(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + city = models.ForeignKey(City, on_delete=models.CASCADE) + + def __str__(self): + return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}' + + def save(self, *args, **kwargs): + if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists(): + raise ValidationError("City already visited by user.") + super().save(*args, **kwargs) + + class Meta: + verbose_name_plural = "Visited Cities" \ No newline at end of file diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 134658a..cccf754 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Country, Region, VisitedRegion, City +from .models import Country, Region, VisitedRegion, City, VisitedCity from rest_framework import serializers from main.utils import CustomModelSerializer @@ -52,4 +52,14 @@ class VisitedRegionSerializer(CustomModelSerializer): class Meta: model = VisitedRegion fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name'] + read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] + +class VisitedCitySerializer(CustomModelSerializer): + longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True) + latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True) + name = serializers.CharField(source='city.name', read_only=True) + + class Meta: + model = VisitedCity + fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name'] read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] \ No newline at end of file diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index e20717c..f28beda 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,15 +2,17 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region +from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region router = DefaultRouter() router.register(r'countries', CountryViewSet, basename='countries') router.register(r'regions', RegionViewSet, basename='regions') router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') +router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity') urlpatterns = [ path('', include(router.urls)), path('/regions/', regions_by_country, name='regions-by-country'), path('/visits/', visits_by_country, name='visits-by-country'), path('regions//cities/', cities_by_region, name='cities-by-region'), + path('regions//cities/visits/', visits_by_region, name='visits-by-region'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 0c41aa1..6cb2575 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render -from .models import Country, Region, VisitedRegion, City -from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer +from .models import Country, Region, VisitedRegion, City, VisitedCity +from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from django.shortcuts import get_object_or_404 @@ -41,6 +41,15 @@ def cities_by_region(request, region_id): serializer = CitySerializer(cities, many=True) return Response(serializer.data) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def visits_by_region(request, region_id): + region = get_object_or_404(Region, id=region_id) + visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id) + + serializer = VisitedCitySerializer(visits, many=True) + return Response(serializer.data) + class CountryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Country.objects.all().order_by('name') serializer_class = CountrySerializer @@ -101,4 +110,42 @@ class VisitedRegionViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, **kwargs): + # delete by region id + region = get_object_or_404(Region, id=kwargs['pk']) + visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region) + if visited_region.exists(): + visited_region.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND) + +class VisitedCityViewSet(viewsets.ModelViewSet): + serializer_class = VisitedCitySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return VisitedCity.objects.filter(user_id=self.request.user.id) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) + + def create(self, request, *args, **kwargs): + request.data['user_id'] = request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, **kwargs): + # delete by city id + city = get_object_or_404(City, id=kwargs['pk']) + visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city) + if visited_city.exists(): + visited_city.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte index 619a27a..20619e0 100644 --- a/frontend/src/lib/components/CityCard.svelte +++ b/frontend/src/lib/components/CityCard.svelte @@ -1,13 +1,42 @@

    {city.name}

    -
    {city.region}
    - +
    {city.id}
    + {#if !visited} + + {/if} + {#if visited} + + {/if}
    diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index f8fc333..5351c84 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -9,55 +9,39 @@ export let region: Region; export let visited: boolean; - export let visit_id: number | undefined | null; - function goToCity() { console.log(region); goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`); } async function markVisited() { - let res = await fetch(`/worldtravel?/markVisited`, { + let res = await fetch(`/api/visitedregion/`, { + headers: { 'Content-Type': 'application/json' }, method: 'POST', - body: JSON.stringify({ regionId: region.id }) + body: JSON.stringify({ region: region.id }) }); if (res.ok) { - // visited = true; - const result = await res.json(); - const data = JSON.parse(result.data); - if (data[1] !== undefined) { - console.log('New adventure created with id:', data[3]); - let visit_id = data[3]; - let region_id = data[5]; - let user_id = data[4]; - - let newVisit: VisitedRegion = { - id: visit_id, - region: region_id, - user_id: user_id, - longitude: 0, - latitude: 0, - name: '' - }; - addToast('success', `Visit to ${region.name} marked`); - dispatch('visit', newVisit); - } + visited = true; + let data = await res.json(); + addToast('success', `Visit to ${region.name} marked`); + dispatch('visit', data); } else { console.error('Failed to mark region as visited'); addToast('error', `Failed to mark visit to ${region.name}`); } } async function removeVisit() { - let res = await fetch(`/worldtravel?/removeVisited`, { - method: 'POST', - body: JSON.stringify({ visitId: visit_id }) + let res = await fetch(`/api/visitedregion/${region.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE' }); if (res.ok) { visited = false; addToast('info', `Visit to ${region.name} removed`); - dispatch('remove', null); + dispatch('remove', region); } else { console.error('Failed to remove visit'); + addToast('error', `Failed to remove visit to ${region.name}`); } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index aa244d3..ac153ca 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -74,7 +74,16 @@ export type City = { export type VisitedRegion = { id: number; - region: number; + region: string; + user_id: string; + longitude: number; + latitude: number; + name: string; +}; + +export type VisitedCity = { + id: number; + city: string; user_id: string; longitude: number; latitude: number; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9f8c2e9..90deaef 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -276,7 +276,8 @@ "all_subregions": "All Subregions", "clear_search": "Clear Search", "no_countries_found": "No countries found", - "view_cities": "View Cities" + "view_cities": "View Cities", + "no_cities_found": "No cities found" }, "auth": { "username": "Username", diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index c77044c..815d4a7 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -12,7 +12,7 @@ export async function GET(event) { /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + const searchParam = url.search ? `${url.search}` : ''; return handleRequest(url, params, request, fetch, cookies, searchParam, true); } diff --git a/frontend/src/routes/worldtravel/+page.server.ts b/frontend/src/routes/worldtravel/+page.server.ts index ec696bf..b84ae39 100644 --- a/frontend/src/routes/worldtravel/+page.server.ts +++ b/frontend/src/routes/worldtravel/+page.server.ts @@ -30,80 +30,3 @@ export const load = (async (event) => { } } }) satisfies PageServerLoad; - -export const actions: Actions = { - markVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.regionId) { - return { - status: 400 - }; - } - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ region: body.regionId }) - }); - - if (!res.ok) { - console.error('Failed to mark country as visited'); - return { status: 500 }; - } else { - return { - status: 200, - data: await res.json() - }; - } - }, - removeVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.visitId) { - return { - status: 400 - }; - } - - const visitId = body.visitId as number; - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, { - method: 'DELETE', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - } - }); - - if (res.status !== 204) { - console.error('Failed to remove country from visited'); - return { status: 500 }; - } else { - return { - status: 200 - }; - } - } -}; diff --git a/frontend/src/routes/worldtravel/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/+page.svelte index 53224e1..8092df9 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/+page.svelte @@ -24,7 +24,7 @@ visitedRegions = visitedRegions.filter( (visitedRegion) => visitedRegion.region !== region.id ); - removeVisit(region, visitedRegion.id); + removeVisit(region); } else { markVisited(region); } @@ -32,48 +32,32 @@ } async function markVisited(region: Region) { - let res = await fetch(`/worldtravel?/markVisited`, { + let res = await fetch(`/api/visitedregion/`, { method: 'POST', - body: JSON.stringify({ regionId: region.id }) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ region: region.id }) }); - if (res.ok) { - // visited = true; - const result = await res.json(); - const data = JSON.parse(result.data); - if (data[1] !== undefined) { - console.log('New adventure created with id:', data[3]); - let visit_id = data[3]; - let region_id = data[5]; - let user_id = data[4]; - - visitedRegions = [ - ...visitedRegions, - { - id: visit_id, - region: region_id, - user_id: user_id, - longitude: 0, - latitude: 0, - name: '' - } - ]; - - addToast('success', `Visit to ${region.name} marked`); - } - } else { + if (!res.ok) { console.error('Failed to mark region as visited'); addToast('error', `Failed to mark visit to ${region.name}`); + return; + } else { + visitedRegions = [...visitedRegions, await res.json()]; + addToast('success', `Visit to ${region.name} marked`); } } - async function removeVisit(region: Region, visitId: number) { - let res = await fetch(`/worldtravel?/removeVisited`, { - method: 'POST', - body: JSON.stringify({ visitId: visitId }) + async function removeVisit(region: Region) { + let res = await fetch(`/api/visitedregion/${region.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE' }); - if (res.ok) { - addToast('info', `Visit to ${region.name} removed`); - } else { + if (!res.ok) { console.error('Failed to remove visit'); + addToast('error', `Failed to remove visit to ${region.name}`); + return; + } else { + visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id); + addToast('info', `Visit to ${region.name} removed`); } } @@ -110,8 +94,12 @@ visitedRegions = [...visitedRegions, e.detail]; numVisitedRegions++; }} - visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id} - on:remove={() => numVisitedRegions--} + on:remove={() => { + visitedRegions = visitedRegions.filter( + (visitedRegion) => visitedRegion.region !== region.id + ); + numVisitedRegions--; + }} /> {/each}
    diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts index 79a0d95..b92b47a 100644 --- a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -1,5 +1,5 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -import type { City, Country, Region, VisitedRegion } from '$lib/types'; +import type { City, Country, Region, VisitedCity, VisitedRegion } from '$lib/types'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -10,6 +10,7 @@ export const load = (async (event) => { let cities: City[] = []; let region = {} as Region; + let visitedCities: VisitedCity[] = []; let sessionId = event.cookies.get('sessionid'); @@ -30,19 +31,6 @@ export const load = (async (event) => { cities = (await res.json()) as City[]; } - // res = await fetch(`${endpoint}/api/${id}/visits/`, { - // method: 'GET', - // headers: { - // Cookie: `sessionid=${sessionId}` - // } - // }); - // if (!res.ok) { - // console.error('Failed to fetch visited regions'); - // return { status: 500 }; - // } else { - // visitedRegions = (await res.json()) as VisitedRegion[]; - // } - res = await fetch(`${endpoint}/api/regions/${id}/`, { method: 'GET', headers: { @@ -56,10 +44,24 @@ export const load = (async (event) => { region = (await res.json()) as Region; } + res = await fetch(`${endpoint}/api/regions/${region.id}/cities/visits/`, { + method: 'GET', + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (!res.ok) { + console.error('Failed to fetch visited regions'); + return { status: 500 }; + } else { + visitedCities = (await res.json()) as VisitedCity[]; + } + return { props: { cities, - region + region, + visitedCities } }; }) satisfies PageServerLoad; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte index 80b4108..aa230e5 100644 --- a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -1,8 +1,7 @@ -

    {$t('worldtravel.country_list')}

    +

    Cities in {data.props?.region.name}

    {allCities.length} Cities Found

    +
    +
    +
    +
    City Stats
    +
    {numVisitedCities}/{numCities} Visited
    + {#if numCities === numVisitedCities} +
    You've visited all cities in {data.props?.region.name} 🎉!
    + {:else} +
    Keep exploring!
    + {/if} +
    +
    +
    + -
    - - {#if searchQuery.length > 0} - -
    - -
    - {/if} -
    +{#if allCities.length > 0} +
    + + {#if searchQuery.length > 0} + +
    + +
    + {/if} +
    -
    - +
    + - - {#each filteredCities as city} - {#if city.latitude && city.longitude} - - - {city.name} - - - {/if} - {/each} - -
    + + {#each filteredCities as city} + {#if city.latitude && city.longitude} + + + {city.name} + + + {/if} + {/each} + +
    +{/if}
    {#each filteredCities as city} - + visitedCity.city === city.id)} + on:visit={(e) => { + visitedCities = [...visitedCities, e.detail]; + }} + on:remove={() => { + visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id); + }} + /> {/each}
    {#if filteredCities.length === 0} -

    {$t('worldtravel.no_countries_found')}

    +

    {$t('worldtravel.no_cities_found')}

    {/if} From abe870506f74991464e01711b01edb2c456d9a73 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 9 Jan 2025 13:53:16 -0500 Subject: [PATCH 10/35] feat: enhance region visit tracking with improved toast messages, update localization, and modify page titles --- .../management/commands/download-countries.py | 2 +- .../0014_alter_visitedcity_options.py | 17 ++++++++++++++ frontend/src/lib/components/RegionCard.svelte | 15 +++++++----- frontend/src/locales/en.json | 12 +++++++++- .../src/routes/worldtravel/[id]/+page.svelte | 23 +++++++++++-------- .../routes/worldtravel/[id]/[id]/+page.svelte | 2 +- 6 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index 1b741bf..a11cd9c 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -57,7 +57,7 @@ class Command(BaseCommand): elif os.path.getsize(countries_json_path) == 0: self.stdout.write(self.style.ERROR('countries+regions+states.json is empty')) else: - self.stdout.write(self.style.SUCCESS('countries+regions+states.json already exists')) + self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.')) return with open(countries_json_path, 'r') as f: diff --git a/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py new file mode 100644 index 0000000..f2ea944 --- /dev/null +++ b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.8 on 2025-01-09 18:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0013_visitedcity'), + ] + + operations = [ + migrations.AlterModelOptions( + name='visitedcity', + options={'verbose_name_plural': 'Visited Cities'}, + ), + ] diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index 5351c84..536427b 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -23,11 +23,14 @@ if (res.ok) { visited = true; let data = await res.json(); - addToast('success', `Visit to ${region.name} marked`); + addToast( + 'success', + `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}` + ); dispatch('visit', data); } else { - console.error('Failed to mark region as visited'); - addToast('error', `Failed to mark visit to ${region.name}`); + console.error($t('worldtravel.region_failed_visited')); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); } } async function removeVisit() { @@ -37,11 +40,11 @@ }); if (res.ok) { visited = false; - addToast('info', `Visit to ${region.name} removed`); + addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`); dispatch('remove', region); } else { - console.error('Failed to remove visit'); - addToast('error', `Failed to remove visit to ${region.name}`); + console.error($t('worldtravel.visit_remove_failed')); + addToast('error', `${$t('worldtravel.failed_to_remove_visit')} ${region.name}`); } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 90deaef..5ac5367 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -277,7 +277,17 @@ "clear_search": "Clear Search", "no_countries_found": "No countries found", "view_cities": "View Cities", - "no_cities_found": "No cities found" + "no_cities_found": "No cities found", + "visit_to": "Visit to", + "region_failed_visited": "Failed to mark region as visited", + "failed_to_mark_visit": "Failed to mark visit to", + "visit_remove_failed": "Failed to remove visit", + "removed": "removed", + "failed_to_remove_visit": "Failed to remove visit to", + "marked_visited": "marked as visited", + "regions_in": "Regions in", + "region_stats": "Region Stats", + "all_visited": "You've visited all regions in" }, "auth": { "username": "Username", diff --git a/frontend/src/routes/worldtravel/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/+page.svelte index 8092df9..f0ca9fb 100644 --- a/frontend/src/routes/worldtravel/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/+page.svelte @@ -39,11 +39,14 @@ }); if (!res.ok) { console.error('Failed to mark region as visited'); - addToast('error', `Failed to mark visit to ${region.name}`); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); return; } else { visitedRegions = [...visitedRegions, await res.json()]; - addToast('success', `Visit to ${region.name} marked`); + addToast( + 'success', + `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}` + ); } } async function removeVisit(region: Region) { @@ -52,12 +55,12 @@ method: 'DELETE' }); if (!res.ok) { - console.error('Failed to remove visit'); - addToast('error', `Failed to remove visit to ${region.name}`); + console.error($t('worldtravel.region_failed_visited')); + addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`); return; } else { visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id); - addToast('info', `Visit to ${region.name} removed`); + addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`); } } @@ -70,16 +73,16 @@ ); -

    Regions in {country?.name}

    +

    {$t('worldtravel.regions_in')} {country?.name}

    -
    Region Stats
    -
    {numVisitedRegions}/{numRegions} Visited
    +
    {$t('worldtravel.region_stats')}
    +
    {numVisitedRegions}/{numRegions} {$t('adventures.visited')}
    {#if numRegions === numVisitedRegions} -
    You've visited all regions in {country?.name} 🎉!
    +
    {$t('worldtravel.all_visited')} {country?.name} 🎉!
    {:else} -
    Keep exploring!
    +
    {$t('adventures.keep_exploring')}
    {/if}
    diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte index aa230e5..9801823 100644 --- a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -218,6 +218,6 @@ {/if} - Countries | World Travel + Cities in {data.props?.region.name} | World Travel From 22790ae7c0b50307941e5793b4a4bfc9e0f68ae8 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 9 Jan 2025 14:58:45 -0500 Subject: [PATCH 11/35] feat: add num_cities field to RegionSerializer, update RegionCard to display city count, and enhance CSRF token handling --- backend/server/worldtravel/serializers.py | 6 +++++- frontend/src/hooks.server.ts | 1 + frontend/src/lib/components/RegionCard.svelte | 17 +++++++++++++---- frontend/src/lib/types.ts | 1 + frontend/src/locales/en.json | 3 ++- .../src/routes/_allauth/[...path]/+server.ts | 9 ++++++++- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index cccf754..99c7379 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -33,10 +33,14 @@ class CountrySerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer): + num_cities = serializers.SerializerMethodField() class Meta: model = Region fields = '__all__' - read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude'] + read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities'] + + def get_num_cities(self, obj): + return City.objects.filter(region=obj).count() class CitySerializer(serializers.ModelSerializer): class Meta: diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 91e1b60..12cd017 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; export const authHook: Handle = async ({ event, resolve }) => { + event.cookies.delete('csrftoken', { path: '/' }); try { let sessionid = event.cookies.get('sessionid'); diff --git a/frontend/src/lib/components/RegionCard.svelte b/frontend/src/lib/components/RegionCard.svelte index 536427b..5acd3a9 100644 --- a/frontend/src/lib/components/RegionCard.svelte +++ b/frontend/src/lib/components/RegionCard.svelte @@ -54,7 +54,14 @@ >

    {region.name}

    -

    {region.id}

    +
    +
    +

    {region.id}

    +
    +
    +

    {region.num_cities} {$t('worldtravel.cities')}

    +
    +
    {#if !visited} @@ -65,9 +72,11 @@ {#if visited} {/if} - + {#if region.num_cities > 0} + + {/if}
    diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ac153ca..e1e7627 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -62,6 +62,7 @@ export type Region = { country: string; latitude: number; longitude: number; + num_cities: number; }; export type City = { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 5ac5367..f3f8b49 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -287,7 +287,8 @@ "marked_visited": "marked as visited", "regions_in": "Regions in", "region_stats": "Region Stats", - "all_visited": "You've visited all regions in" + "all_visited": "You've visited all regions in", + "cities": "cities" }, "auth": { "username": "Username", diff --git a/frontend/src/routes/_allauth/[...path]/+server.ts b/frontend/src/routes/_allauth/[...path]/+server.ts index 681a3fa..9b09205 100644 --- a/frontend/src/routes/_allauth/[...path]/+server.ts +++ b/frontend/src/routes/_allauth/[...path]/+server.ts @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, From 013a2cc751caadd1607f4e90bd237dca4d55ef3e Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 9 Jan 2025 18:25:51 -0500 Subject: [PATCH 12/35] feat: add password validation and error messages, enhance CityCard styling, and update localization for email and password prompts --- frontend/src/lib/components/CityCard.svelte | 8 +++----- frontend/src/locales/de.json | 19 +++++++++++++++++-- frontend/src/locales/en.json | 4 +++- frontend/src/locales/es.json | 19 +++++++++++++++++-- frontend/src/locales/fr.json | 19 +++++++++++++++++-- frontend/src/locales/it.json | 19 +++++++++++++++++-- frontend/src/locales/nl.json | 19 +++++++++++++++++-- frontend/src/locales/pl.json | 19 +++++++++++++++++-- frontend/src/locales/sv.json | 19 +++++++++++++++++-- frontend/src/locales/zh.json | 19 +++++++++++++++++-- frontend/src/routes/settings/+page.server.ts | 5 +++++ frontend/src/routes/settings/+page.svelte | 9 +++++++-- 12 files changed, 154 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte index 20619e0..5678af6 100644 --- a/frontend/src/lib/components/CityCard.svelte +++ b/frontend/src/lib/components/CityCard.svelte @@ -45,7 +45,9 @@

    {city.name}

    -
    {city.id}
    +
    {city.id}
    +
    +
    {#if !visited} {$t('adventures.remove')} {/if}
    - -
    - -
    diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index e8f982d..526a96e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -299,7 +299,20 @@ "no_countries_found": "Keine Länder gefunden", "not_visited": "Nicht besucht", "num_countries": "Länder gefunden", - "partially_visited": "Teilweise besucht" + "partially_visited": "Teilweise besucht", + "all_visited": "Sie haben alle Regionen in besucht", + "cities": "Städte", + "failed_to_mark_visit": "Der Besuch konnte nicht markiert werden", + "failed_to_remove_visit": "Der Besuch von konnte nicht entfernt werden", + "marked_visited": "als besucht markiert", + "no_cities_found": "Keine Städte gefunden", + "region_failed_visited": "Die Region konnte nicht als besucht markiert werden", + "region_stats": "Regionsstatistiken", + "regions_in": "Regionen in", + "removed": "ENTFERNT", + "view_cities": "Städte anzeigen", + "visit_remove_failed": "Der Besuch konnte nicht entfernt werden", + "visit_to": "Besuch bei" }, "settings": { "account_settings": "Benutzerkontoeinstellungen", @@ -378,7 +391,9 @@ "no_verified_email_warning": "Sie müssen über eine verifizierte E-Mail-Adresse verfügen, um die Zwei-Faktor-Authentifizierung zu aktivieren.", "social_auth_desc": "Aktivieren oder deaktivieren Sie soziale und OIDC-Authentifizierungsanbieter für Ihr Konto. \nMit diesen Verbindungen können Sie sich bei selbst gehosteten Authentifizierungsidentitätsanbietern wie Authentik oder Drittanbietern wie GitHub anmelden.", "social_auth_desc_2": "Diese Einstellungen werden auf dem AdventureLog-Server verwaltet und müssen vom Administrator manuell aktiviert werden.", - "social_oidc_auth": "Soziale und OIDC-Authentifizierung" + "social_oidc_auth": "Soziale und OIDC-Authentifizierung", + "add_email": "E-Mail hinzufügen", + "password_too_short": "Das Passwort muss mindestens 6 Zeichen lang sein" }, "checklist": { "add_item": "Artikel hinzufügen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f3f8b49..4e98a1a 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -391,7 +391,9 @@ "social_auth_desc": "Enable or disable social and OIDC authentication providers for your account. These connections allow you to sign in with self hosted authentication identity providers like Authentik or 3rd party providers like GitHub.", "social_auth_desc_2": "These settings are managed in the AdventureLog server and must be manually enabled by the administrator.", "documentation_link": "Documentation Link", - "launch_account_connections": "Launch Account Connections" + "launch_account_connections": "Launch Account Connections", + "password_too_short": "Password must be at least 6 characters", + "add_email": "Add Email" }, "collection": { "collection_created": "Collection created successfully!", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index f8d33ab..f0a488b 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -275,7 +275,20 @@ "not_visited": "No visitado", "num_countries": "países encontrados", "partially_visited": "Parcialmente visitado", - "country_list": "Lista de países" + "country_list": "Lista de países", + "all_visited": "Has visitado todas las regiones en", + "cities": "ciudades", + "failed_to_mark_visit": "No se pudo marcar la visita a", + "failed_to_remove_visit": "No se pudo eliminar la visita a", + "marked_visited": "marcado como visitado", + "no_cities_found": "No se encontraron ciudades", + "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", + "view_cities": "Ver ciudades", + "visit_remove_failed": "No se pudo eliminar la visita", + "visit_to": "Visita a" }, "auth": { "forgot_password": "¿Has olvidado tu contraseña?", @@ -378,7 +391,9 @@ "no_verified_email_warning": "Debe tener una dirección de correo electrónico verificada para habilitar la autenticación de dos factores.", "social_auth_desc": "Habilite o deshabilite los proveedores de autenticación social y OIDC para su cuenta. \nEstas conexiones le permiten iniciar sesión con proveedores de identidad de autenticación autohospedados como Authentik o proveedores externos como GitHub.", "social_auth_desc_2": "Estas configuraciones se administran en el servidor AdventureLog y el administrador debe habilitarlas manualmente.", - "social_oidc_auth": "Autenticación social y OIDC" + "social_oidc_auth": "Autenticación social y OIDC", + "add_email": "Agregar correo electrónico", + "password_too_short": "La contraseña debe tener al menos 6 caracteres." }, "checklist": { "add_item": "Agregar artículo", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 5d43237..d793779 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -299,7 +299,20 @@ "no_countries_found": "Aucun pays trouvé", "not_visited": "Non visité", "num_countries": "pays trouvés", - "partially_visited": "Partiellement visité" + "partially_visited": "Partiellement visité", + "all_visited": "Vous avez visité toutes les régions de", + "cities": "villes", + "failed_to_mark_visit": "Échec de la notation de la visite à", + "failed_to_remove_visit": "Échec de la suppression de la visite à", + "marked_visited": "marqué comme visité", + "no_cities_found": "Aucune ville trouvée", + "region_failed_visited": "Échec du marquage de la région comme visitée", + "region_stats": "Statistiques de la région", + "regions_in": "Régions dans", + "removed": "supprimé", + "view_cities": "Voir les villes", + "visit_remove_failed": "Échec de la suppression de la visite", + "visit_to": "Visite à" }, "settings": { "account_settings": "Paramètres du compte utilisateur", @@ -378,7 +391,9 @@ "no_verified_email_warning": "Vous devez disposer d'une adresse e-mail vérifiée pour activer l'authentification à deux facteurs.", "social_auth_desc": "Activez ou désactivez les fournisseurs d'authentification sociale et OIDC pour votre compte. \nCes connexions vous permettent de vous connecter avec des fournisseurs d'identité d'authentification auto-hébergés comme Authentik ou des fournisseurs tiers comme GitHub.", "social_auth_desc_2": "Ces paramètres sont gérés sur le serveur AdventureLog et doivent être activés manuellement par l'administrateur.", - "social_oidc_auth": "Authentification sociale et OIDC" + "social_oidc_auth": "Authentification sociale et OIDC", + "add_email": "Ajouter un e-mail", + "password_too_short": "Le mot de passe doit contenir au moins 6 caractères" }, "checklist": { "add_item": "Ajouter un article", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 025f9e0..85bb8ca 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -299,7 +299,20 @@ "no_countries_found": "Nessun paese trovato", "not_visited": "Non visitato", "num_countries": "paesi trovati", - "partially_visited": "Parzialmente visitato" + "partially_visited": "Parzialmente visitato", + "all_visited": "Hai visitato tutte le regioni in", + "cities": "città", + "failed_to_mark_visit": "Impossibile contrassegnare la visita a", + "failed_to_remove_visit": "Impossibile rimuovere la visita a", + "marked_visited": "contrassegnato come visitato", + "no_cities_found": "Nessuna città trovata", + "region_failed_visited": "Impossibile contrassegnare la regione come visitata", + "region_stats": "Statistiche della regione", + "regions_in": "Regioni dentro", + "removed": "RIMOSSO", + "view_cities": "Visualizza città", + "visit_remove_failed": "Impossibile rimuovere la visita", + "visit_to": "Visita a" }, "settings": { "account_settings": "Impostazioni dell'account utente", @@ -378,7 +391,9 @@ "no_verified_email_warning": "È necessario disporre di un indirizzo e-mail verificato per abilitare l'autenticazione a due fattori.", "social_auth_desc": "Abilita o disabilita i provider di autenticazione social e OIDC per il tuo account. \nQueste connessioni ti consentono di accedere con provider di identità di autenticazione self-hosted come Authentik o provider di terze parti come GitHub.", "social_auth_desc_2": "Queste impostazioni sono gestite nel server AdventureLog e devono essere abilitate manualmente dall'amministratore.", - "social_oidc_auth": "Autenticazione sociale e OIDC" + "social_oidc_auth": "Autenticazione sociale e OIDC", + "add_email": "Aggiungi e-mail", + "password_too_short": "La password deve contenere almeno 6 caratteri" }, "checklist": { "add_item": "Aggiungi articolo", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 750bbd7..30660fa 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -299,7 +299,20 @@ "no_countries_found": "Geen landen gevonden", "not_visited": "Niet bezocht", "num_countries": "landen gevonden", - "partially_visited": "Gedeeltelijk bezocht" + "partially_visited": "Gedeeltelijk bezocht", + "all_visited": "Je hebt alle regio's in bezocht", + "cities": "steden", + "failed_to_mark_visit": "Kan bezoek aan niet markeren", + "failed_to_remove_visit": "Kan bezoek aan niet verwijderen", + "marked_visited": "gemarkeerd als bezocht", + "no_cities_found": "Geen steden gevonden", + "region_failed_visited": "Kan de regio niet als bezocht markeren", + "region_stats": "Regiostatistieken", + "regions_in": "Regio's binnen", + "removed": "VERWIJDERD", + "view_cities": "Steden bekijken", + "visit_remove_failed": "Kan bezoek niet verwijderen", + "visit_to": "Bezoek aan" }, "settings": { "account_settings": "Gebruikersaccount instellingen", @@ -378,7 +391,9 @@ "no_verified_email_warning": "U moet een geverifieerd e-mailadres hebben om tweefactorauthenticatie in te schakelen.", "social_auth_desc": "Schakel sociale en OIDC-authenticatieproviders in of uit voor uw account. \nMet deze verbindingen kunt u inloggen met zelfgehoste authenticatie-identiteitsproviders zoals Authentik of externe providers zoals GitHub.", "social_auth_desc_2": "Deze instellingen worden beheerd op de AdventureLog-server en moeten handmatig worden ingeschakeld door de beheerder.", - "social_oidc_auth": "Sociale en OIDC-authenticatie" + "social_oidc_auth": "Sociale en OIDC-authenticatie", + "add_email": "E-mail toevoegen", + "password_too_short": "Wachtwoord moet minimaal 6 tekens lang zijn" }, "checklist": { "add_item": "Artikel toevoegen", diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 189be2c..d70b73e 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -275,7 +275,20 @@ "completely_visited": "Całkowicie odwiedzone", "all_subregions": "Wszystkie podregiony", "clear_search": "Wyczyść wyszukiwanie", - "no_countries_found": "Nie znaleziono krajów" + "no_countries_found": "Nie znaleziono krajów", + "all_visited": "Odwiedziłeś wszystkie regiony w", + "cities": "miasta", + "failed_to_mark_visit": "Nie udało się oznaczyć wizyty w", + "failed_to_remove_visit": "Nie udało się usunąć wizyty w", + "marked_visited": "oznaczone jako odwiedzone", + "no_cities_found": "Nie znaleziono żadnych miast", + "region_failed_visited": "Nie udało się oznaczyć regionu jako odwiedzony", + "region_stats": "Statystyki regionu", + "regions_in": "Regiony w", + "removed": "REMOVED", + "view_cities": "Zobacz Miasta", + "visit_remove_failed": "Nie udało się usunąć wizyty", + "visit_to": "Wizyta w" }, "auth": { "username": "Nazwa użytkownika", @@ -378,7 +391,9 @@ "no_verified_email_warning": "Aby włączyć uwierzytelnianie dwuskładnikowe, musisz mieć zweryfikowany adres e-mail.", "social_auth_desc": "Włącz lub wyłącz dostawców uwierzytelniania społecznościowego i OIDC dla swojego konta. \nPołączenia te umożliwiają logowanie się za pośrednictwem dostawców tożsamości uwierzytelniających, takich jak Authentik, lub dostawców zewnętrznych, takich jak GitHub.", "social_auth_desc_2": "Ustawienia te są zarządzane na serwerze AdventureLog i muszą zostać włączone ręcznie przez administratora.", - "social_oidc_auth": "Uwierzytelnianie społecznościowe i OIDC" + "social_oidc_auth": "Uwierzytelnianie społecznościowe i OIDC", + "add_email": "Dodaj e-mail", + "password_too_short": "Hasło musi mieć co najmniej 6 znaków" }, "collection": { "collection_created": "Kolekcja została pomyślnie utworzona!", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 7f7baab..c9afb14 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -275,7 +275,20 @@ "no_countries_found": "Inga länder hittades", "not_visited": "Ej besökta", "num_countries": "länder hittades", - "partially_visited": "Delvis besökta" + "partially_visited": "Delvis besökta", + "all_visited": "Du har besökt alla regioner i", + "cities": "städer", + "failed_to_mark_visit": "Det gick inte att markera besök till", + "failed_to_remove_visit": "Det gick inte att ta bort besök på", + "marked_visited": "markerad som besökt", + "no_cities_found": "Inga städer hittades", + "region_failed_visited": "Det gick inte att markera regionen som besökt", + "region_stats": "Regionstatistik", + "regions_in": "Regioner i", + "removed": "tas bort", + "view_cities": "Visa städer", + "visit_remove_failed": "Det gick inte att ta bort besöket", + "visit_to": "Besök till" }, "auth": { "confirm_password": "Bekräfta lösenord", @@ -378,7 +391,9 @@ "no_verified_email_warning": "Du måste ha en verifierad e-postadress för att aktivera tvåfaktorsautentisering.", "social_auth_desc": "Aktivera eller inaktivera sociala och OIDC-autentiseringsleverantörer för ditt konto. \nDessa anslutningar gör att du kan logga in med leverantörer av autentiseringsidentitetsidentitet som är värd för dig som Authentik eller tredjepartsleverantörer som GitHub.", "social_auth_desc_2": "Dessa inställningar hanteras i AdventureLog-servern och måste aktiveras manuellt av administratören.", - "social_oidc_auth": "Social och OIDC-autentisering" + "social_oidc_auth": "Social och OIDC-autentisering", + "add_email": "Lägg till e-post", + "password_too_short": "Lösenordet måste bestå av minst 6 tecken" }, "checklist": { "add_item": "Lägg till objekt", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index d9b5be1..99a5fad 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -296,7 +296,20 @@ "no_countries_found": "没有找到国家", "not_visited": "未访问过", "num_countries": "找到的国家", - "partially_visited": "部分访问" + "partially_visited": "部分访问", + "all_visited": "您已访问过所有地区", + "cities": "城市", + "failed_to_mark_visit": "无法标记访问", + "failed_to_remove_visit": "无法删除对的访问", + "marked_visited": "标记为已访问", + "no_cities_found": "没有找到城市", + "region_failed_visited": "无法将区域标记为已访问", + "region_stats": "地区统计", + "regions_in": "地区位于", + "removed": "已删除", + "view_cities": "查看城市", + "visit_remove_failed": "删除访问失败", + "visit_to": "参观" }, "users": { "no_users_found": "未找到具有公开个人资料的用户。" @@ -378,7 +391,9 @@ "no_verified_email_warning": "您必须拥有经过验证的电子邮件地址才能启用双因素身份验证。", "social_auth_desc": "为您的帐户启用或禁用社交和 OIDC 身份验证提供商。\n这些连接允许您使用自托管身份验证身份提供商(如 Authentik)或第三方提供商(如 GitHub)登录。", "social_auth_desc_2": "这些设置在 AdventureLog 服务器中进行管理,并且必须由管理员手动启用。", - "social_oidc_auth": "社交和 OIDC 身份验证" + "social_oidc_auth": "社交和 OIDC 身份验证", + "add_email": "添加电子邮件", + "password_too_short": "密码必须至少为 6 个字符" }, "checklist": { "add_item": "添加项目", diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index 20bdb24..44bfe97 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -194,10 +194,15 @@ export const actions: Actions = { if (password1 !== password2) { return fail(400, { message: 'settings.password_does_not_match' }); } + if (!current_password) { current_password = null; } + if (password1 && password1?.length < 6) { + return fail(400, { message: 'settings.password_too_short' }); + } + let csrfToken = await fetchCSRFToken(); if (current_password) { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 245b957..0fbe15c 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -345,6 +345,11 @@ class="block w-full mt-1 input input-bordered input-primary" />
    + {#if $page.form?.message} +
    + {$t($page.form?.message)} +
    + {/if}
    - + - +
    From de8764499b2551f31e32c865f3febe2cea372a90 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Thu, 9 Jan 2025 19:40:23 -0500 Subject: [PATCH 13/35] feat: enhance city and region visit tracking, update AdventureModal and serializers for improved data handling --- backend/server/adventures/views.py | 39 ++++++----- backend/server/worldtravel/views.py | 4 ++ .../src/lib/components/AdventureModal.svelte | 65 ++++++++++++++----- frontend/src/lib/types.ts | 7 +- 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 5c0c593..1ce81f5 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -8,7 +8,7 @@ from django.db.models.functions import Lower from rest_framework.response import Response from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category from django.core.exceptions import PermissionDenied -from worldtravel.models import VisitedRegion, Region, Country +from worldtravel.models import VisitedCity, VisitedRegion, Region, Country, City from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer from rest_framework.permissions import IsAuthenticated from django.db.models import Q @@ -1159,41 +1159,48 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): Returns a dictionary containing the region name, country name, and ISO code if found. """ iso_code = None - town = None - city = None - county = None + town_city_or_county = None display_name = None country_code = None + city = None + + # town = None + # city = None + # county = None + if 'address' in data.keys(): keys = data['address'].keys() for key in keys: if key.find("ISO") != -1: iso_code = data['address'][key] if 'town' in keys: - town = data['address']['town'] + town_city_or_county = data['address']['town'] if 'county' in keys: - county = data['address']['county'] + town_city_or_county = data['address']['county'] if 'city' in keys: - city = data['address']['city'] + town_city_or_county = data['address']['city'] if not iso_code: return {"error": "No region found"} + region = Region.objects.filter(id=iso_code).first() visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first() - is_visited = False + + region_visited = False + city_visited = False country_code = iso_code[:2] if region: - if city: - display_name = f"{city}, {region.name}, {country_code}" - elif town: - display_name = f"{town}, {region.name}, {country_code}" - elif county: - display_name = f"{county}, {region.name}, {country_code}" + if town_city_or_county: + display_name = f"{town_city_or_county}, {region.name}, {country_code}" + city = City.objects.filter(name__contains=town_city_or_county, region=region).first() + visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first() if visited_region: - is_visited = True + region_visited = True + if visited_city: + city_visited = True if region: - return {"id": iso_code, "region": region.name, "country": region.country.name, "is_visited": is_visited, "display_name": display_name} + return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited} return {"error": "No region found"} @action(detail=False, methods=['get']) diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 6cb2575..c77309d 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -137,6 +137,10 @@ class VisitedCityViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + # if the region is not visited, visit it + region = serializer.validated_data['city'].region + if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists(): + VisitedRegion.objects.create(user_id=request.user, region=region) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index eda9df6..9e014e8 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -365,15 +365,31 @@ async function markVisited() { console.log(reverseGeocodePlace); if (reverseGeocodePlace) { - let res = await fetch(`/worldtravel?/markVisited`, { - method: 'POST', - body: JSON.stringify({ regionId: reverseGeocodePlace.id }) - }); - if (res.ok) { - reverseGeocodePlace.is_visited = true; - addToast('success', `Visit to ${reverseGeocodePlace.region} marked`); - } else { - addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`); + if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) { + let region_res = await fetch(`/api/visitedregion`, { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ region: reverseGeocodePlace.region_id }) + }); + if (region_res.ok) { + reverseGeocodePlace.region_visited = true; + addToast('success', `Visit to ${reverseGeocodePlace.region} marked`); + } else { + addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`); + } + } + if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) { + let city_res = await fetch(`/api/visitedcity`, { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ city: reverseGeocodePlace.city_id }) + }); + if (city_res.ok) { + reverseGeocodePlace.city_visited = true; + addToast('success', `Visit to ${reverseGeocodePlace.city} marked`); + } else { + addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`); + } } } } @@ -542,7 +558,10 @@ addToast('error', $t('adventures.adventure_update_error')); } } - if (adventure.is_visited && !reverseGeocodePlace?.is_visited) { + if ( + (adventure.is_visited && !reverseGeocodePlace?.region_visited) || + !reverseGeocodePlace?.city_visited + ) { markVisited(); } imageSearch = adventure.name; @@ -785,19 +804,33 @@ it would also work to just use on:click on the MapLibre component itself. --> {#if reverseGeocodePlace}
    -

    {reverseGeocodePlace.region}, {reverseGeocodePlace.country}

    - {reverseGeocodePlace.is_visited + {reverseGeocodePlace.city + ? reverseGeocodePlace.city + ', ' + : ''}{reverseGeocodePlace.region}, + {reverseGeocodePlace.country} +

    +

    + {reverseGeocodePlace.region}: + {reverseGeocodePlace.region_visited ? $t('adventures.visited') : $t('adventures.not_visited')}

    + {#if reverseGeocodePlace.city} +

    + {reverseGeocodePlace.city}: + {reverseGeocodePlace.city_visited + ? $t('adventures.visited') + : $t('adventures.not_visited')} +

    + {/if}
    - {#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited} + {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} {/if} - {#if !reverseGeocodePlace.is_visited && willBeMarkedVisited} + {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && willBeMarkedVisited)}