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

OIDC Auth and City Visits Data

This commit is contained in:
Sean Morley 2025-01-13 17:21:32 -05:00 committed by GitHub
commit ec3ada986d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1776 additions and 484 deletions

205
README.md
View file

@ -1,139 +1,144 @@
# AdventureLog: Embark, Explore, Remember. 🌍 <div align="center">
### _"Never forget an adventure with AdventureLog - Your ultimate travel companion!"_ <img src="brand/adventurelog.png" alt="logo" width="200" height="auto" />
<h1>AdventureLog</h1>
<p>
The ultimate travel companion for the modern-day explorer.
</p>
<h4>
<a href="https://demo.adventurelog.app">View Demo</a>
<span> · </span>
<a href="https://adventurelog.app">Documentation</a>
<span> · </span>
<a href="https://discord.gg/wRbQ9Egr8C">Discord</a>
<span> · </span>
<a href="https://buymeacoffee.com/seanmorley15">Support 💖</a>
</h4>
</div>
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15) <br />
- **[Documentation](https://adventurelog.app)** <!-- Table of Contents -->
- **[Demo](https://demo.adventurelog.app)**
- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)**
# Table of Contents # Table of Contents
- [Installation](#installation) - [About the Project](#-about-the-project)
- [Docker 🐋](#docker-) - [Screenshots](#-screenshots)
- [Prerequisites](#prerequisites) - [Tech Stack](#-tech-stack)
- [Getting Started](#getting-started) - [Features](#-features)
- [Configuration](#configuration) - [Roadmap](#-roadmap)
- [Frontend Container (web)](#frontend-container-web) - [Contributing](#-contributing)
- [Backend Container (server)](#backend-container-server) - [License](#-license)
- [Proxy Container (nginx) Configuration](#proxy-container-nginx-configuration) - [Contact](#-contact)
- [Running the Containers](#running-the-containers) - [Acknowledgements](#-acknowledgements)
- [Screenshots 🖼️](#screenshots)
- [About AdventureLog](#about-adventurelog)
- [Attribution](#attribution)
# Installation <!-- About the Project -->
# Docker 🐋 ## ⭐ About the Project
Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers. Starting from a simple idea of tracking travel locations (called adventures), AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family.
**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems.
## Prerequisites AdventureLog was created to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone.
- Docker installed on your machine/server. You can learn how to download it [here](https://docs.docker.com/engine/install/). <!-- Screenshots -->
## Getting Started ### 📷 Screenshots
Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine: <div align="center">
<img src="./brand/screenshots/adventures.png" alt="Adventures" />
<p>Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.</p>
<img src="./brand/screenshots/details.png" alt="Adventure Details" />
<p>Shows specific details about an adventure, including the name, date, location, description, and rating.</p>
<img src="./brand/screenshots/edit.png" alt="Edit Modal" />
<img src="./brand/screenshots/map.png" alt="Adventure Details" />
<p>View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map</p>
<img src="./brand/screenshots/dashboard.png" alt="Dashboard" />
<p>Displays a summary of your adventures, including your world travel stats.</p>
<img src="./brand/screenshots/itinerary.png" alt="Itinerary" />
<p>Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.</p>
<img src="./brand/screenshots/countries.png" alt="Countries" />
<p>Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.</p>
<img src="./brand/screenshots/regions.png" alt="Regions" />
<p>Displays the regions for a specific country, includes a map view to visually select regions.</p>
</div>
```bash <!-- TechStack -->
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
```
## Configuration ### 🚀 Tech Stack
Here is a summary of the configuration options available in the `docker-compose.yml` file: <details>
<summary>Client</summary>
<ul>
<li><a href="https://svelte.dev/">SvelteKit</a></li>
<li><a href="https://tailwindcss.com/">TailwindCSS</a></li>
<li><a href="https://daisyui.com/">DaisyUI</a></li>
<li><a href="https://github.com/dimfeld/svelte-maplibre/">Svelte MapLibre</a></li>
</ul>
</details>
<!-- make a table with colum name, is required, other --> <details>
<summary>Server</summary>
<ul>
<li><a href="https://www.djangoproject.com/">Django</a></li>
<li><a href="https://postgis.net/">PostGIS</a></li>
<li><a href="https://www.django-rest-framework.org/">Django REST Framework</a></li>
<li><a href="https://allauth.org/">AllAuth</a></li>
</ul>
</details>
<!-- Features -->
### Frontend Container (web) ### 🎯 Features
| Name | Required | Description | Default Value | - **Track Your Adventures** 🌍: Log your adventures and keep track of where you've been on the world map.
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | - Adventures can store a variety of information, including the location, date, and description.
| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | http://server:8000 | - Adventures can be sorted into custom categories for easy organization.
| `ORIGIN` | Sometimes | Not needed if using HTTPS. If not, set it to the domain of what you will acess the app from. | http://localhost:8015 | - Adventures can be marked as private or public, allowing you to share your adventures with friends and family.
| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **kiliobytes**. | Infinity | - Keep track of the countries and regions you've visited with the world travel book.
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
- Itineraries can be created for any number of days and can include multiple destinations.
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
- Itineraries can be shared with friends and family for collaborative planning.
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
- Adventures and itineraries can be shared via a public link or directly with other AdventureLog users.
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
### Backend Container (server) <!-- Roadmap -->
| Name | Required | Description | Default Value | ## 🧭 Roadmap
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `PGHOST` | Yes | Databse host. | db |
| `PGDATABASE` | Yes | Database. | database |
| `PGUSER` | Yes | Database user. | adventure |
| `PGPASSWORD` | Yes | Database password. | changeme123 |
| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | admin |
| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after inital login. | admin |
| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com |
| `PUBLIC_URL` | Yes | This needs to match the outward port of the server and be accessible from where the app is used. It is used for the creation of image urls. | http://localhost:8016 |
| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | http://localhost:8016 |
| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | http://localhost:8015 |
## Running the Containers The AdventureLog Roadmap can be found [here](https://github.com/users/seanmorley15/projects/5)
To start the containers, run the following command: <!-- Contributing -->
```bash ## 👋 Contributing
docker compose up -d
```
Enjoy AdventureLog! 🎉 <a href="https://github.com/seanmorley15/AdventureLog/graphs/contributors">
<img src="https://contrib.rocks/image?repo=seanmorley15/AdventureLog" />
</a>
# Screenshots Contributions are always welcome!
![Adventure Page](brand/screenshots/adventures.png) See `contributing.md` for ways to get started.
Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
![Detail Page](brand/screenshots/details.png) <!-- License -->
Shows specific details about an adventure, including the name, date, location, description, and rating.
![Edit](brand/screenshots/edit.png) ## 📃 License
![Map Page](brand/screenshots/map.png) Distributed under the GNU General Public License v3.0. See `LICENSE` for more information.
View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map.
![Dashboard Page](brand/screenshots/dashboard.png) <!-- Contact -->
Displays a summary of your adventures, including your world travel stats.
![Itinerary Page](brand/screenshots/itinerary.png) ## 🤝 Contact
Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
![Country Page](brand/screenshots/countries.png) Sean Morley - [website](https://seanmorley.com)
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
![Region Page](brand/screenshots/regions.png) Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software developer with a passion for travel and adventure. I created AdventureLog to help people like me document their adventures and plan new ones effortlessly. As a student, I am always looking for more opportunities to learn and grow, so feel free to reach out via the contact on my website if you would like to collaborate or chat!
Displays the regions for a specific country, includes a map view to visually select regions.
# About AdventureLog <!-- Acknowledgments -->
AdventureLog is a Svelte Kit and Django application that utilizes a PostgreSQL database. Users can log the adventures they have experienced, as well as plan future ones. Key features include: ## 💎 Acknowledgements
- Logging past adventures with fields like name, date, location, description, and rating.
- Planning future adventures with similar fields.
- Tagging different activity types for better organization.
- Viewing countries, regions, and marking visited regions.
AdventureLog aims to be your ultimate travel companion, helping you document your adventures and plan new ones effortlessly.
AdventureLog is licensed under the GNU General Public License v3.0.
<!-- ## Screenshots 🖼️
![Visited Log](https://github.com/seanmorley15/AdventureLog/blob/main/brand/screenshots/visited.png?raw=true)
![Planner Log](https://github.com/seanmorley15/AdventureLog/blob/main/brand/screenshots/ideas.png?raw=true)
![Country List](https://github.com/seanmorley15/AdventureLog/blob/main/brand/screenshots/countrylist.png?raw=true)
![Region List for the United States](https://github.com/seanmorley15/AdventureLog/blob/main/brand/screenshots/regions.png?raw=true)
## Roadmap 🛣️
- Improved mobile device support
- Password reset functionality
- Improved error handling
- Handling of adventure cards with variable width -->
# Attribution
- Logo Design by [nordtechtiger](https://github.com/nordtechtiger) - Logo Design by [nordtechtiger](https://github.com/nordtechtiger)
- WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database) - WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database)

View file

@ -20,21 +20,45 @@ done
python manage.py migrate python manage.py migrate
# Create superuser if environment variables are set and there are no users present at all. # Create superuser if environment variables are set and there are no users present at all.
if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ]; then if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ] && [ -n "$DJANGO_ADMIN_EMAIL" ]; then
echo "Creating superuser..." echo "Creating superuser..."
python manage.py shell << EOF python manage.py shell << EOF
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from allauth.account.models import EmailAddress
User = get_user_model() User = get_user_model()
if User.objects.count() == 0:
User.objects.create_superuser('$DJANGO_ADMIN_USERNAME', '$DJANGO_ADMIN_EMAIL', '$DJANGO_ADMIN_PASSWORD') # Check if the user already exists
if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists():
# Create the superuser
superuser = User.objects.create_superuser(
username='$DJANGO_ADMIN_USERNAME',
email='$DJANGO_ADMIN_EMAIL',
password='$DJANGO_ADMIN_PASSWORD'
)
print("Superuser created successfully.") print("Superuser created successfully.")
# Create the EmailAddress object for AllAuth
EmailAddress.objects.create(
user=superuser,
email='$DJANGO_ADMIN_EMAIL',
verified=True,
primary=True
)
print("EmailAddress object created successfully for AllAuth.")
else: else:
print("Superuser already exists.") print("Superuser already exists.")
EOF EOF
fi fi
# Sync the countries and world travel regions
# Sync the countries and world travel regions # Sync the countries and world travel regions
python manage.py download-countries python manage.py download-countries
if [ $? -eq 137 ]; then
>&2 echo "WARNING: The download-countries command was interrupted. This is likely due to lack of memory allocated to the container or the host. Please try again with more memory."
exit 1
fi
cat /code/adventurelog.txt cat /code/adventurelog.txt

View file

@ -38,4 +38,4 @@ http {
alias /code/media/; # Serve media files directly alias /code/media/; # Serve media files directly
} }
} }
} }

View file

@ -2,7 +2,7 @@ import os
from django.contrib import admin from django.contrib import admin
from django.utils.html import mark_safe from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
from worldtravel.models import Country, Region, VisitedRegion from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login from allauth.account.decorators import secure_admin_login
admin.autodiscover() admin.autodiscover()
@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin):
number_of_visits.short_description = 'Number of Visits' number_of_visits.short_description = 'Number of Visits'
class CityAdmin(admin.ModelAdmin):
list_display = ('name', 'region', 'country')
list_filter = ('region', 'region__country')
search_fields = ('name', 'region__name', 'region__country__name')
def country(self, obj):
return obj.region.country.name
country.short_description = 'Country'
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from users.models import CustomUser from users.models import CustomUser
@ -127,6 +137,8 @@ admin.site.register(Checklist)
admin.site.register(ChecklistItem) admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin) admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.site_header = 'AdventureLog Admin' admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site' admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -20,4 +20,23 @@ class PrintCookiesMiddleware:
def __call__(self, request): def __call__(self, request):
print(request.COOKIES) print(request.COOKIES)
response = self.get_response(request) response = self.get_response(request)
return response return response
# middlewares.py
import os
from django.http import HttpRequest
class OverrideHostMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
# Override the host with the PUBLIC_URL environment variable
public_url = os.getenv('PUBLIC_URL', None)
if public_url:
# Split the public URL to extract the host and port (if available)
host = public_url.split("//")[-1].split("/")[0]
request.META['HTTP_HOST'] = host # Override the HTTP_HOST header
response = self.get_response(request)
return response

View file

@ -8,7 +8,7 @@ from django.db.models.functions import Lower
from rest_framework.response import Response from rest_framework.response import Response
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category
from django.core.exceptions import PermissionDenied 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 .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.db.models import Q from django.db.models import Q
@ -20,6 +20,7 @@ from django.contrib.auth import get_user_model
from icalendar import Calendar, Event, vText, vCalAddress from icalendar import Calendar, Event, vText, vCalAddress
from django.http import HttpResponse from django.http import HttpResponse
from datetime import datetime from datetime import datetime
from django.db.models import Min
User = get_user_model() User = get_user_model()
@ -44,17 +45,25 @@ class AdventureViewSet(viewsets.ModelViewSet):
order_direction = self.request.query_params.get('order_direction', 'asc') order_direction = self.request.query_params.get('order_direction', 'asc')
include_collections = self.request.query_params.get('include_collections', 'true') include_collections = self.request.query_params.get('include_collections', 'true')
valid_order_by = ['name', 'type', 'start_date', 'rating', 'updated_at'] valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
if order_by not in valid_order_by: if order_by not in valid_order_by:
order_by = 'name' order_by = 'name'
if order_direction not in ['asc', 'desc']: if order_direction not in ['asc', 'desc']:
order_direction = 'asc' order_direction = 'asc'
if order_by == 'date':
# order by the earliest visit object associated with the adventure
queryset = queryset.annotate(earliest_visit=Min('visits__start_date'))
queryset = queryset.filter(earliest_visit__isnull=False)
ordering = 'earliest_visit'
# Apply case-insensitive sorting for the 'name' field # Apply case-insensitive sorting for the 'name' field
if order_by == 'name': elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name')) queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name' ordering = 'lower_name'
elif order_by == 'rating':
queryset = queryset.filter(rating__isnull=False)
ordering = 'rating'
else: else:
ordering = order_by ordering = order_by
@ -1150,41 +1159,48 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
Returns a dictionary containing the region name, country name, and ISO code if found. Returns a dictionary containing the region name, country name, and ISO code if found.
""" """
iso_code = None iso_code = None
town = None town_city_or_county = None
city = None
county = None
display_name = None display_name = None
country_code = None country_code = None
city = None
# town = None
# city = None
# county = None
if 'address' in data.keys(): if 'address' in data.keys():
keys = data['address'].keys() keys = data['address'].keys()
for key in keys: for key in keys:
if key.find("ISO") != -1: if key.find("ISO") != -1:
iso_code = data['address'][key] iso_code = data['address'][key]
if 'town' in keys: if 'town' in keys:
town = data['address']['town'] town_city_or_county = data['address']['town']
if 'county' in keys: if 'county' in keys:
county = data['address']['county'] town_city_or_county = data['address']['county']
if 'city' in keys: if 'city' in keys:
city = data['address']['city'] town_city_or_county = data['address']['city']
if not iso_code: if not iso_code:
return {"error": "No region found"} return {"error": "No region found"}
region = Region.objects.filter(id=iso_code).first() region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).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] country_code = iso_code[:2]
if region: if region:
if city: if town_city_or_county:
display_name = f"{city}, {region.name}, {country_code}" display_name = f"{town_city_or_county}, {region.name}, {country_code}"
elif town: city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
display_name = f"{town}, {region.name}, {country_code}" visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
elif county:
display_name = f"{county}, {region.name}, {country_code}"
if visited_region: if visited_region:
is_visited = True region_visited = True
if visited_city:
city_visited = True
if region: 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"} return {"error": "No region found"}
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])

View file

@ -42,6 +42,7 @@ INSTALLED_APPS = (
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sites', 'django.contrib.sites',
# "allauth_ui",
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'allauth', 'allauth',
@ -49,8 +50,8 @@ INSTALLED_APPS = (
'allauth.mfa', 'allauth.mfa',
'allauth.headless', 'allauth.headless',
'allauth.socialaccount', 'allauth.socialaccount',
# "widget_tweaks", 'allauth.socialaccount.providers.github',
# "slippers", 'allauth.socialaccount.providers.openid_connect',
'drf_yasg', 'drf_yasg',
'corsheaders', 'corsheaders',
'adventures', 'adventures',
@ -58,6 +59,9 @@ INSTALLED_APPS = (
'users', 'users',
'integrations', 'integrations',
'django.contrib.gis', 'django.contrib.gis',
# 'widget_tweaks',
# 'slippers',
) )
MIDDLEWARE = ( MIDDLEWARE = (
@ -65,6 +69,7 @@ MIDDLEWARE = (
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'adventures.middleware.OverrideHostMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
@ -75,6 +80,8 @@ MIDDLEWARE = (
# disable verifications for new users # disable verifications for new users
ACCOUNT_EMAIL_VERIFICATION = 'none' ACCOUNT_EMAIL_VERIFICATION = 'none'
ALLAUTH_UI_THEME = "night"
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
@ -120,7 +127,7 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
SESSION_COOKIE_SAMESITE = None
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/ # https://docs.djangoproject.com/en/1.7/howto/static-files/
@ -143,6 +150,7 @@ STORAGES = {
} }
} }
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
TEMPLATES = [ TEMPLATES = [
{ {
@ -175,6 +183,11 @@ SESSION_SAVE_EVERY_REQUEST = True
FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000') FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000')
# Set login redirect URL to the frontend
LOGIN_REDIRECT_URL = FRONTEND_URL
SOCIALACCOUNT_LOGIN_ON_GET = True
HEADLESS_FRONTEND_URLS = { HEADLESS_FRONTEND_URLS = {
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}", "account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
"account_reset_password": f"{FRONTEND_URL}/user/reset-password", "account_reset_password": f"{FRONTEND_URL}/user/reset-password",
@ -263,4 +276,4 @@ LOGGING = {
}, },
} }
# https://github.com/dr5hn/countries-states-cities-database/tags # https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v2.4' COUNTRY_REGION_JSON_VERSION = 'v2.5'

View file

@ -3,8 +3,8 @@ from django.contrib import admin
from django.views.generic import RedirectView, TemplateView from django.views.generic import RedirectView, TemplateView
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static 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 .views import get_csrf_token, get_public_url
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from drf_yasg import openapi from drf_yasg import openapi
@ -27,7 +27,10 @@ urlpatterns = [
path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'), 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('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')), path('', TemplateView.as_view(template_name='home.html')),

View file

@ -1,6 +1,10 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from os import getenv
def get_csrf_token(request): def get_csrf_token(request):
csrf_token = get_token(request) csrf_token = get_token(request)
return JsonResponse({'csrfToken': csrf_token}) return JsonResponse({'csrfToken': csrf_token})
def get_public_url(request):
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})

View file

@ -13,8 +13,10 @@ django-geojson
setuptools setuptools
gunicorn==23.0.0 gunicorn==23.0.0
qrcode==8.0 qrcode==8.0
# slippers==0.6.2 slippers==0.6.2
# django-allauth-ui==1.5.1 django-allauth-ui==1.5.1
# django-widget-tweaks==1.5.0 django-widget-tweaks==1.5.0
django-ical==1.9.2 django-ical==1.9.2
icalendar==6.1.0 icalendar==6.1.0
ijson==3.3.0
tqdm==4.67.1

View file

@ -36,7 +36,7 @@ import os
class UserDetailsSerializer(serializers.ModelSerializer): class UserDetailsSerializer(serializers.ModelSerializer):
""" """
User model w/o password User model without exposing the password.
""" """
@staticmethod @staticmethod
@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer):
return username return username
class Meta: class Meta:
model = CustomUser
extra_fields = ['profile_pic', 'uuid', 'public_profile'] extra_fields = ['profile_pic', 'uuid', 'public_profile']
profile_pic = serializers.ImageField(required=False)
if hasattr(UserModel, 'USERNAME_FIELD'): if hasattr(UserModel, 'USERNAME_FIELD'):
extra_fields.append(UserModel.USERNAME_FIELD) extra_fields.append(UserModel.USERNAME_FIELD)
@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer):
extra_fields.append('date_joined') extra_fields.append('date_joined')
if hasattr(UserModel, 'is_staff'): if hasattr(UserModel, 'is_staff'):
extra_fields.append('is_staff') extra_fields.append('is_staff')
if hasattr(UserModel, 'public_profile'):
extra_fields.append('public_profile')
class Meta: fields = ['pk', *extra_fields]
model = CustomUser
fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
model = UserModel
fields = ('pk', *extra_fields)
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
def handle_public_profile_change(self, instance, validated_data): 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']: if 'public_profile' in validated_data and not validated_data['public_profile']:
for collection in Collection.objects.filter(shared_with=instance): for collection in Collection.objects.filter(shared_with=instance):
collection.shared_with.remove(instance) collection.shared_with.remove(instance)
@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer):
class CustomUserDetailsSerializer(UserDetailsSerializer): class CustomUserDetailsSerializer(UserDetailsSerializer):
"""
Custom serializer to add additional fields and logic for the user details.
"""
has_password = serializers.SerializerMethodField()
class Meta(UserDetailsSerializer.Meta): class Meta(UserDetailsSerializer.Meta):
model = CustomUser model = CustomUser
fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password']
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',) 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): def to_representation(self, instance):
"""
Customizes the serialized output to modify `profile_pic` URL and add computed fields.
"""
representation = super().to_representation(instance) representation = super().to_representation(instance)
# Construct profile picture URL if it exists
if instance.profile_pic: if instance.profile_pic:
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
#print(public_url) public_url = public_url.replace("'", "") # Sanitize URL
# remove any ' from the url
public_url = public_url.replace("'", "")
representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}" 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 return representation

View file

@ -1,3 +1,4 @@
from os import getenv
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
@ -9,6 +10,7 @@ from django.conf import settings
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
from allauth.socialaccount.models import SocialApp
User = get_user_model() User = get_user_model()
@ -120,4 +122,31 @@ class UpdateUserMetadataView(APIView):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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)

View file

@ -1,9 +1,10 @@
import os import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
import requests import requests
from worldtravel.models import Country, Region from worldtravel.models import Country, Region, City
from django.db import transaction from django.db import transaction
import json from tqdm import tqdm
import ijson
from django.conf import settings from django.conf import settings
@ -37,33 +38,54 @@ def saveCountryFlag(country_code):
class Command(BaseCommand): class Command(BaseCommand):
help = 'Imports the world travel data' help = 'Imports the world travel data'
def handle(self, *args, **options): def add_arguments(self, parser):
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json') parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
if not os.path.exists(countries_json_path):
res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.json') def handle(self, **options):
force = options['force']
batch_size = 100
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
if not os.path.exists(countries_json_path) or force:
res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json')
if res.status_code == 200: if res.status_code == 200:
with open(countries_json_path, 'w') as f: with open(countries_json_path, 'w') as f:
f.write(res.text) f.write(res.text)
self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully'))
else: else:
self.stdout.write(self.style.ERROR('Error downloading countries+regions.json')) self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json'))
return return
elif not os.path.isfile(countries_json_path):
self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file'))
return
elif os.path.getsize(countries_json_path) == 0:
self.stdout.write(self.style.ERROR('countries+regions+states.json is empty'))
elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.'))
else:
self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.'))
return
with open(countries_json_path, 'r') as f: with open(countries_json_path, 'r') as f:
data = json.load(f) f = open(countries_json_path, 'rb')
parser = ijson.items(f, 'item')
with transaction.atomic(): with transaction.atomic():
existing_countries = {country.country_code: country for country in Country.objects.all()} existing_countries = {country.country_code: country for country in Country.objects.all()}
existing_regions = {region.id: region for region in Region.objects.all()} existing_regions = {region.id: region for region in Region.objects.all()}
existing_cities = {city.id: city for city in City.objects.all()}
countries_to_create = [] countries_to_create = []
regions_to_create = [] regions_to_create = []
countries_to_update = [] countries_to_update = []
regions_to_update = [] regions_to_update = []
cities_to_create = []
cities_to_update = []
processed_country_codes = set() processed_country_codes = set()
processed_region_ids = set() processed_region_ids = set()
processed_city_ids = set()
for country in data: for country in parser:
country_code = country['iso2'] country_code = country['iso2']
country_name = country['name'] country_name = country['name']
country_subregion = country['subregion'] country_subregion = country['subregion']
@ -93,7 +115,6 @@ class Command(BaseCommand):
countries_to_create.append(country_obj) countries_to_create.append(country_obj)
saveCountryFlag(country_code) saveCountryFlag(country_code)
self.stdout.write(self.style.SUCCESS(f'Country {country_name} prepared'))
if country['states']: if country['states']:
for state in country['states']: for state in country['states']:
@ -102,6 +123,11 @@ class Command(BaseCommand):
latitude = round(float(state['latitude']), 6) if state['latitude'] else None latitude = round(float(state['latitude']), 6) if state['latitude'] else None
longitude = round(float(state['longitude']), 6) if state['longitude'] else None longitude = round(float(state['longitude']), 6) if state['longitude'] else None
# Check for duplicate regions
if state_id in processed_region_ids:
# self.stdout.write(self.style.ERROR(f'State {state_id} already processed'))
continue
processed_region_ids.add(state_id) processed_region_ids.add(state_id)
if state_id in existing_regions: if state_id in existing_regions:
@ -120,7 +146,40 @@ class Command(BaseCommand):
latitude=latitude latitude=latitude
) )
regions_to_create.append(region_obj) regions_to_create.append(region_obj)
self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared')) # self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
if 'cities' in state and len(state['cities']) > 0:
for city in state['cities']:
city_id = f"{state_id}-{city['id']}"
city_name = city['name']
latitude = round(float(city['latitude']), 6) if city['latitude'] else None
longitude = round(float(city['longitude']), 6) if city['longitude'] else None
# Check for duplicate cities
if city_id in processed_city_ids:
# self.stdout.write(self.style.ERROR(f'City {city_id} already processed'))
continue
processed_city_ids.add(city_id)
if city_id in existing_cities:
city_obj = existing_cities[city_id]
city_obj.name = city_name
city_obj.region = region_obj
city_obj.longitude = longitude
city_obj.latitude = latitude
cities_to_update.append(city_obj)
else:
city_obj = City(
id=city_id,
name=city_name,
region=region_obj,
longitude=longitude,
latitude=latitude
)
cities_to_create.append(city_obj)
# self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared'))
else: else:
state_id = f"{country_code}-00" state_id = f"{country_code}-00"
processed_region_ids.add(state_id) processed_region_ids.add(state_id)
@ -136,18 +195,35 @@ class Command(BaseCommand):
country=country_obj country=country_obj
) )
regions_to_create.append(region_obj) regions_to_create.append(region_obj)
self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}')) # self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
for i in tqdm(range(0, len(countries_to_create), batch_size), desc="Processing countries"):
batch = countries_to_create[i:i + batch_size]
Country.objects.bulk_create(batch)
# Bulk create new countries and regions for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"):
Country.objects.bulk_create(countries_to_create) batch = regions_to_create[i:i + batch_size]
Region.objects.bulk_create(regions_to_create) Region.objects.bulk_create(batch)
# Bulk update existing countries and regions for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"):
Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital']) batch = cities_to_create[i:i + batch_size]
Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude']) City.objects.bulk_create(batch)
# Delete countries and regions that are no longer in the data # Process updates in batches
for i in range(0, len(countries_to_update), batch_size):
batch = countries_to_update[i:i + batch_size]
for i in tqdm(range(0, len(countries_to_update), batch_size), desc="Updating countries"):
batch = countries_to_update[i:i + batch_size]
Country.objects.bulk_update(batch, ['name', 'subregion', 'capital', 'longitude', 'latitude'])
for i in tqdm(range(0, len(regions_to_update), batch_size), desc="Updating regions"):
batch = regions_to_update[i:i + batch_size]
Region.objects.bulk_update(batch, ['name', 'country', 'longitude', 'latitude'])
for i in tqdm(range(0, len(cities_to_update), batch_size), desc="Updating cities"):
batch = cities_to_update[i:i + batch_size]
City.objects.bulk_update(batch, ['name', 'region', 'longitude', 'latitude'])
Country.objects.exclude(country_code__in=processed_country_codes).delete() Country.objects.exclude(country_code__in=processed_country_codes).delete()
Region.objects.exclude(id__in=processed_region_ids).delete() Region.objects.exclude(id__in=processed_region_ids).delete()
City.objects.exclude(id__in=processed_city_ids).delete()
self.stdout.write(self.style.SUCCESS('All data imported successfully')) self.stdout.write(self.style.SUCCESS('All data imported successfully'))

View file

@ -0,0 +1,27 @@
# Generated by Django 5.0.8 on 2025-01-09 15:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0011_country_latitude_country_longitude'),
]
operations = [
migrations.CreateModel(
name='City',
fields=[
('id', models.CharField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.region')),
],
options={
'verbose_name_plural': 'Cities',
},
),
]

View file

@ -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)),
],
),
]

View file

@ -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'},
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.0.8 on 2025-01-13 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('worldtravel', '0014_alter_visitedcity_options'),
]
operations = [
migrations.AddField(
model_name='city',
name='insert_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name='country',
name='insert_id',
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name='region',
name='insert_id',
field=models.UUIDField(blank=True, null=True),
),
]

View file

@ -17,6 +17,7 @@ class Country(models.Model):
capital = models.CharField(max_length=100, blank=True, null=True) capital = models.CharField(max_length=100, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
insert_id = models.UUIDField(unique=False, blank=True, null=True)
class Meta: class Meta:
verbose_name = "Country" verbose_name = "Country"
@ -31,6 +32,21 @@ class Region(models.Model):
country = models.ForeignKey(Country, on_delete=models.CASCADE) country = models.ForeignKey(Country, on_delete=models.CASCADE)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
insert_id = models.UUIDField(unique=False, blank=True, null=True)
def __str__(self):
return self.name
class City(models.Model):
id = models.CharField(primary_key=True)
name = models.CharField(max_length=100)
region = models.ForeignKey(Region, on_delete=models.CASCADE)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
insert_id = models.UUIDField(unique=False, blank=True, null=True)
class Meta:
verbose_name_plural = "Cities"
def __str__(self): def __str__(self):
return self.name return self.name
@ -47,4 +63,21 @@ class VisitedRegion(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
raise ValidationError("Region already visited by user.") raise ValidationError("Region already visited by user.")
super().save(*args, **kwargs) 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"

View file

@ -1,5 +1,5 @@
import os import os
from .models import Country, Region, VisitedRegion from .models import Country, Region, VisitedRegion, City, VisitedCity
from rest_framework import serializers from rest_framework import serializers
from main.utils import CustomModelSerializer from main.utils import CustomModelSerializer
@ -33,10 +33,20 @@ class CountrySerializer(serializers.ModelSerializer):
class RegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer):
num_cities = serializers.SerializerMethodField()
class Meta: class Meta:
model = Region model = Region
fields = '__all__' 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:
model = City
fields = '__all__'
read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude']
class VisitedRegionSerializer(CustomModelSerializer): class VisitedRegionSerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True)
@ -46,4 +56,14 @@ class VisitedRegionSerializer(CustomModelSerializer):
class Meta: class Meta:
model = VisitedRegion model = VisitedRegion
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name'] 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'] read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']

View file

@ -2,15 +2,17 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
router = DefaultRouter() router = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries') router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions') router.register(r'regions', RegionViewSet, basename='regions')
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'), path('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country') path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'),
] ]

View file

@ -1,6 +1,6 @@
from django.shortcuts import render from django.shortcuts import render
from .models import Country, Region, VisitedRegion from .models import Country, Region, VisitedRegion, City, VisitedCity
from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -33,6 +33,23 @@ def visits_by_country(request, country_code):
serializer = VisitedRegionSerializer(visits, many=True) serializer = VisitedRegionSerializer(visits, many=True)
return Response(serializer.data) return Response(serializer.data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def cities_by_region(request, region_id):
region = get_object_or_404(Region, id=region_id)
cities = City.objects.filter(region=region).order_by('name')
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): class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name') queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer serializer_class = CountrySerializer
@ -93,4 +110,46 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
self.perform_create(serializer) self.perform_create(serializer)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 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)
# 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)
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)

View file

@ -92,6 +92,28 @@ export default defineConfig({
text: "Immich Integration", text: "Immich Integration",
link: "/docs/configuration/immich_integration", link: "/docs/configuration/immich_integration",
}, },
{
text: "Social Auth and OIDC",
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", text: "Update App",
link: "/docs/configuration/updating", link: "/docs/configuration/updating",

View file

@ -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.

View file

@ -0,0 +1,56 @@
# Authentik OIDC Authentication
<img src="https://repository-images.githubusercontent.com/230885748/19f01d00-8e26-11eb-9a14-cf0d28a1b68d" alt="Authentik Logo" width="400" />
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://<adventurelog-server-url>/accounts/oidc/.*$` where `<adventurelog-url>` 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.
7. If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png).
### 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://<authentik_url>/application/o/[YOUR_SLUG]/"
}
```
::: warning
`localhost` is most likely not a valid `server_url` for Authentik in this instance because `localhost` is the server running AdventureLog, not Authentik. You should use the IP address of the server running Authentik or the domain name if you have one.
- 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.
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.

View file

@ -0,0 +1,44 @@
# GitHub Social Authentication
AdventureLog can be configured to use GitHub as an identity provider for social authentication. Users can then log in to AdventureLog using their GitHub account.
# Configuration
To enable GitHub as an identity provider, the administrator must first configure GitHub to allow AdventureLog to authenticate users.
### GitHub Configuration
1. Visit the GitHub OAuth Apps Settings page at [https://github.com/settings/developers](https://github.com/settings/developers).
2. Click on `New OAuth App`.
3. Fill in the following fields:
- Application Name: `AdventureLog` or any other name you prefer.
- Homepage URL: `<adventurelog-frontend-url>` where `<adventurelog-frontend-url>` is the URL of your AdventureLog Frontend service.
- Application Description: `AdventureLog` or any other description you prefer.
- Authorization callback URL: `http://<adventurelog-backend-url>/accounts/github/login/callback/` where `<adventurelog-backend-url>` is the URL of your AdventureLog Backend service.
- If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png).
### 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: `GitHub`
- Provider ID: GitHub Client ID
- Name: `GitHub`
- Client ID: GitHub Client ID
- Secret Key: GitHub Client Secret
- Key: can be left blank
- Settings: can be left blank
- 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](/github_settings.png)
4. Save the configuration.
Users should now be able to log in to AdventureLog using their GitHub account, and link it to exisiting accounts.

View file

@ -0,0 +1,7 @@
# OIDC Social Authentication
AdventureLog can be configured to use OpenID Connect (OIDC) as an identity provider for social authentication. Users can then log in to AdventureLog using their OIDC account.
The configuration is basically the same as [Authentik](./authentik.md), but you replace the client and secret with the OIDC client and secret provided by your OIDC provider. The `server_url` should be the URL of your OIDC provider where you can find the OIDC configuration.
Each provider has a different configuration, so you will need to check the documentation of your OIDC provider to find the correct configuration.

View file

@ -20,5 +20,5 @@ docker exec -it <container> bash
Once you are in the container run the following command to resync the region data. Once you are in the container run the following command to resync the region data.
```bash ```bash
python manage.py download-countries python manage.py download-countries --force
``` ```

View file

@ -11,6 +11,12 @@ Docker is the preferred way to run AdventureLog on your local machine. It is a l
Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine: Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine:
::: tip
If running on an ARM based machine, you will need to use a different PostGIS Image. It is recommended to use the `tobi312/rpi-postgresql-postgis:15-3.3-alpine-arm` image or a custom version found [here](https://hub.docker.com/r/tobi312/rpi-postgresql-postgis/tags). The AdventureLog containers are ARM compatible.
:::
```bash ```bash
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
``` ```

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -15,6 +15,7 @@ declare global {
profile_pic: string | null; profile_pic: string | null;
uuid: string; uuid: string;
public_profile: boolean; public_profile: boolean;
has_password: boolean;
} | null; } | null;
locale: string; locale: string;
} }

View file

@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
export const authHook: Handle = async ({ event, resolve }) => { export const authHook: Handle = async ({ event, resolve }) => {
event.cookies.delete('csrftoken', { path: '/' });
try { try {
let sessionid = event.cookies.get('sessionid'); let sessionid = event.cookies.get('sessionid');

View file

@ -228,6 +228,37 @@
} }
} }
async function handleMultipleFiles(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (const file of files) {
await uploadImage(file);
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
console.log(newData);
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
console.log(newImage);
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
async function fetchImage() { async function fetchImage() {
try { try {
let res = await fetch(url); let res = await fetch(url);
@ -241,39 +272,10 @@
formData.append('image', file); formData.append('image', file);
formData.append('adventure', adventure.id); formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, { await uploadImage(file);
method: 'POST', url = '';
body: formData } catch (e) {
}); imageError = $t('adventures.image_fetch_failed');
let data2 = await res2.json();
if (data2.type === 'success') {
console.log('Response Data:', data2);
// Deserialize the nested data
let rawData = JSON.parse(data2.data); // Parse the data field
console.log('Deserialized Data:', rawData);
// Assuming the first object in the array is the new image
let newImage = {
id: rawData[1],
image: rawData[2], // This is the URL for the image
is_primary: false
};
console.log('New Image:', newImage);
// Update images and adventure
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
url = '';
} else {
addToast('error', $t('adventures.image_upload_error'));
}
} catch (error) {
console.error('Error in fetchImage:', error);
addToast('error', $t('adventures.image_upload_error'));
} }
} }
@ -365,15 +367,31 @@
async function markVisited() { async function markVisited() {
console.log(reverseGeocodePlace); console.log(reverseGeocodePlace);
if (reverseGeocodePlace) { if (reverseGeocodePlace) {
let res = await fetch(`/worldtravel?/markVisited`, { if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) {
method: 'POST', let region_res = await fetch(`/api/visitedregion`, {
body: JSON.stringify({ regionId: reverseGeocodePlace.id }) headers: { 'Content-Type': 'application/json' },
}); method: 'POST',
if (res.ok) { body: JSON.stringify({ region: reverseGeocodePlace.region_id })
reverseGeocodePlace.is_visited = true; });
addToast('success', `Visit to ${reverseGeocodePlace.region} marked`); if (region_res.ok) {
} else { reverseGeocodePlace.region_visited = true;
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`); 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 +560,10 @@
addToast('error', $t('adventures.adventure_update_error')); 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(); markVisited();
} }
imageSearch = adventure.name; imageSearch = adventure.name;
@ -785,19 +806,33 @@ it would also work to just use on:click on the MapLibre component itself. -->
</MapLibre> </MapLibre>
{#if reverseGeocodePlace} {#if reverseGeocodePlace}
<div class="mt-2"> <div class="mt-2">
<p>{reverseGeocodePlace.region}, {reverseGeocodePlace.country}</p>
<p> <p>
{reverseGeocodePlace.is_visited {reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region},
{reverseGeocodePlace.country}
</p>
<p>
{reverseGeocodePlace.region}:
{reverseGeocodePlace.region_visited
? $t('adventures.visited') ? $t('adventures.visited')
: $t('adventures.not_visited')} : $t('adventures.not_visited')}
</p> </p>
{#if reverseGeocodePlace.city}
<p>
{reverseGeocodePlace.city}:
{reverseGeocodePlace.city_visited
? $t('adventures.visited')
: $t('adventures.not_visited')}
</p>
{/if}
</div> </div>
{#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited} {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-neutral" on:click={markVisited}> <button type="button" class="btn btn-neutral" on:click={markVisited}>
{$t('adventures.mark_visited')} {$t('adventures.mark_visited')}
</button> </button>
{/if} {/if}
{#if !reverseGeocodePlace.is_visited && willBeMarkedVisited} {#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div role="alert" class="alert alert-info mt-2"> <div role="alert" class="alert alert-info mt-2">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -813,7 +848,9 @@ it would also work to just use on:click on the MapLibre component itself. -->
></path> ></path>
</svg> </svg>
<span <span
>{reverseGeocodePlace.region}, >{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region},
{reverseGeocodePlace.country} {reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}</span {$t('adventures.will_be_marked')}</span
> >
@ -1022,11 +1059,13 @@ it would also work to just use on:click on the MapLibre component itself. -->
bind:this={fileInput} bind:this={fileInput}
accept="image/*" accept="image/*"
id="image" id="image"
multiple
on:change={handleMultipleFiles}
/> />
<input type="hidden" name="adventure" value={adventure.id} id="adventure" /> <input type="hidden" name="adventure" value={adventure.id} id="adventure" />
<button class="btn btn-neutral w-full max-w-sm" type="submit"> <!-- <button class="btn btn-neutral w-full max-w-sm" type="submit">
{$t('adventures.upload_image')} {$t('adventures.upload_image')}
</button> </button> -->
</form> </form>
</div> </div>

View file

@ -30,8 +30,9 @@
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
<!-- svelte-ignore a11y-missing-attribute --> <!-- svelte-ignore a11y-missing-attribute -->
<p class="text-lg ml-4 font-bold"> <p class="text-lg ml-4 font-bold">
{$t('navbar.greeting')}, {user.first_name} {$t('navbar.greeting')}, {user.first_name
{user.last_name} ? `${user.first_name} ${user.last_name}`
: user.username}
</p> </p>
<li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li> <li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li>
<li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li> <li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li>

View file

@ -86,7 +86,7 @@
bind:value={new_category.icon} bind:value={new_category.icon}
/> />
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary"> <button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary">
{isEmojiPickerVisible ? $t('adventures.hide') : $t('adventures.hide')} {!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')}
{$t('adventures.emoji_picker')} {$t('adventures.emoji_picker')}
</button> </button>
<button on:click={custom_category} type="button" class="btn btn-primary"> <button on:click={custom_category} type="button" class="btn btn-primary">

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { addToast } from '$lib/toasts';
import type { City } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
export let city: City;
export let visited: boolean;
async function markVisited() {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (res.ok) {
visited = true;
let data = await res.json();
addToast('success', `Visit to ${city.name} marked`);
dispatch('visit', data);
} else {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
}
}
async function removeVisit() {
let res = await fetch(`/api/visitedcity/${city.id}`, {
method: 'DELETE'
});
if (res.ok) {
visited = false;
addToast('info', `Visit to ${city.name} removed`);
dispatch('remove', city);
} else {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${city.name}`);
}
}
</script>
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
>
<div class="card-body">
<h2 class="card-title overflow-ellipsis">{city.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-neutral-300">{city.id}</div>
</div>
<div class="card-actions justify-end">
{#if !visited}
<button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button
>
{/if}
{#if visited}
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if}
</div>
</div>
</div>

View file

@ -4,54 +4,47 @@
import type { Region, VisitedRegion } from '$lib/types'; import type { Region, VisitedRegion } from '$lib/types';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
export let region: Region; export let region: Region;
export let visited: boolean; 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() { async function markVisited() {
let res = await fetch(`/worldtravel?/markVisited`, { let res = await fetch(`/api/visitedregion/`, {
headers: { 'Content-Type': 'application/json' },
method: 'POST', method: 'POST',
body: JSON.stringify({ regionId: region.id }) body: JSON.stringify({ region: region.id })
}); });
if (res.ok) { if (res.ok) {
// visited = true; visited = true;
const result = await res.json(); let data = await res.json();
const data = JSON.parse(result.data); addToast(
if (data[1] !== undefined) { 'success',
console.log('New adventure created with id:', data[3]); `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}`
let visit_id = data[3]; );
let region_id = data[5]; dispatch('visit', data);
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);
}
} else { } else {
console.error('Failed to mark region as visited'); console.error($t('worldtravel.region_failed_visited'));
addToast('error', `Failed to mark visit to ${region.name}`); addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`);
} }
} }
async function removeVisit() { async function removeVisit() {
let res = await fetch(`/worldtravel?/removeVisited`, { let res = await fetch(`/api/visitedregion/${region.id}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitId: visit_id }) method: 'DELETE'
}); });
if (res.ok) { if (res.ok) {
visited = false; visited = false;
addToast('info', `Visit to ${region.name} removed`); addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`);
dispatch('remove', null); dispatch('remove', region);
} else { } else {
console.error('Failed to remove visit'); console.error($t('worldtravel.visit_remove_failed'));
addToast('error', `${$t('worldtravel.failed_to_remove_visit')} ${region.name}`);
} }
} }
</script> </script>
@ -61,14 +54,28 @@
> >
<div class="card-body"> <div class="card-body">
<h2 class="card-title overflow-ellipsis">{region.name}</h2> <h2 class="card-title overflow-ellipsis">{region.name}</h2>
<p>{region.id}</p> <div>
<div class="badge badge-primary">
<p>{region.id}</p>
</div>
<div class="badge badge-neutral-300">
<p>{region.num_cities} {$t('worldtravel.cities')}</p>
</div>
</div>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> --> <!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
{#if !visited} {#if !visited}
<button class="btn btn-primary" on:click={markVisited}>Mark Visited</button> <button class="btn btn-primary" on:click={markVisited}
>{$t('adventures.mark_visited')}</button
>
{/if} {/if}
{#if visited} {#if visited}
<button class="btn btn-warning" on:click={removeVisit}>Remove</button> <button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
{/if}
{#if region.num_cities > 0}
<button class="btn btn-neutral-300" on:click={goToCity}
>{$t('worldtravel.view_cities')}</button
>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -9,6 +9,7 @@ export type User = {
profile_pic: string | null; profile_pic: string | null;
uuid: string; uuid: string;
public_profile: boolean; public_profile: boolean;
has_password: boolean;
}; };
export type Adventure = { export type Adventure = {
@ -56,16 +57,34 @@ export type Country = {
}; };
export type Region = { export type Region = {
id: number; id: string;
name: string; name: string;
country: number; country: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
num_cities: number;
};
export type City = {
id: string;
name: string;
latitude: number | null;
longitude: number | null;
region: string;
}; };
export type VisitedRegion = { export type VisitedRegion = {
id: number; 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; user_id: string;
longitude: number; longitude: number;
latitude: number; latitude: number;
@ -182,11 +201,14 @@ export type Background = {
}; };
export type ReverseGeocode = { export type ReverseGeocode = {
id: string; region_id: string;
region: string; region: string;
country: string; country: string;
is_visited: boolean; region_visited: boolean;
city_visited: boolean;
display_name: string; display_name: string;
city: string;
city_id: string;
}; };
export type Category = { export type Category = {

View file

@ -284,7 +284,8 @@
"email_required": "E-Mail ist erforderlich", "email_required": "E-Mail ist erforderlich",
"both_passwords_required": "Beide Passwörter sind erforderlich", "both_passwords_required": "Beide Passwörter sind erforderlich",
"new_password": "Neues Passwort", "new_password": "Neues Passwort",
"reset_failed": "Passwort konnte nicht zurückgesetzt werden" "reset_failed": "Passwort konnte nicht zurückgesetzt werden",
"or_3rd_party": "Oder melden Sie sich bei einem Drittanbieter an"
}, },
"users": { "users": {
"no_users_found": "Keine Benutzer mit öffentlichen Profilen gefunden." "no_users_found": "Keine Benutzer mit öffentlichen Profilen gefunden."
@ -298,7 +299,20 @@
"no_countries_found": "Keine Länder gefunden", "no_countries_found": "Keine Länder gefunden",
"not_visited": "Nicht besucht", "not_visited": "Nicht besucht",
"num_countries": "Länder gefunden", "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": { "settings": {
"account_settings": "Benutzerkontoeinstellungen", "account_settings": "Benutzerkontoeinstellungen",
@ -369,7 +383,17 @@
"csrf_failed": "CSRF-Token konnte nicht abgerufen werden", "csrf_failed": "CSRF-Token konnte nicht abgerufen werden",
"duplicate_email": "Diese E-Mail-Adresse wird bereits verwendet.", "duplicate_email": "Diese E-Mail-Adresse wird bereits verwendet.",
"email_taken": "Diese E-Mail-Adresse wird bereits verwendet.", "email_taken": "Diese E-Mail-Adresse wird bereits verwendet.",
"username_taken": "Dieser Benutzername wird bereits verwendet." "username_taken": "Dieser Benutzername wird bereits verwendet.",
"administration_settings": "Verwaltungseinstellungen",
"documentation_link": "Dokumentationslink",
"launch_account_connections": "Kontoverbindungen starten",
"launch_administration_panel": "Starten Sie das Administrationspanel",
"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",
"add_email": "E-Mail hinzufügen",
"password_too_short": "Das Passwort muss mindestens 6 Zeichen lang sein"
}, },
"checklist": { "checklist": {
"add_item": "Artikel hinzufügen", "add_item": "Artikel hinzufügen",

View file

@ -275,7 +275,20 @@
"completely_visited": "Completely Visited", "completely_visited": "Completely Visited",
"all_subregions": "All Subregions", "all_subregions": "All Subregions",
"clear_search": "Clear Search", "clear_search": "Clear Search",
"no_countries_found": "No countries found" "no_countries_found": "No countries found",
"view_cities": "View Cities",
"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",
"cities": "cities"
}, },
"auth": { "auth": {
"username": "Username", "username": "Username",
@ -295,7 +308,8 @@
"email_required": "Email is required", "email_required": "Email is required",
"new_password": "New Password (6+ characters)", "new_password": "New Password (6+ characters)",
"both_passwords_required": "Both passwords are required", "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": { "users": {
"no_users_found": "No users found with public profiles." "no_users_found": "No users found with public profiles."
@ -306,6 +320,7 @@
"settings_page": "Settings Page", "settings_page": "Settings Page",
"account_settings": "User Account Settings", "account_settings": "User Account Settings",
"update": "Update", "update": "Update",
"no_verified_email_warning": "You must have a verified email address to enable two-factor authentication.",
"password_change": "Change Password", "password_change": "Change Password",
"new_password": "New Password", "new_password": "New Password",
"confirm_new_password": "Confirm New Password", "confirm_new_password": "Confirm New Password",
@ -369,7 +384,16 @@
"duplicate_email": "This email address is already in use.", "duplicate_email": "This email address is already in use.",
"csrf_failed": "Failed to fetch CSRF token", "csrf_failed": "Failed to fetch CSRF token",
"email_taken": "This email address is already in use.", "email_taken": "This email address is already in use.",
"username_taken": "This username is already in use." "username_taken": "This username is already in use.",
"administration_settings": "Administration Settings",
"launch_administration_panel": "Launch Administration Panel",
"social_oidc_auth": "Social and OIDC Authentication",
"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",
"password_too_short": "Password must be at least 6 characters",
"add_email": "Add Email"
}, },
"collection": { "collection": {
"collection_created": "Collection created successfully!", "collection_created": "Collection created successfully!",

View file

@ -275,7 +275,20 @@
"not_visited": "No visitado", "not_visited": "No visitado",
"num_countries": "países encontrados", "num_countries": "países encontrados",
"partially_visited": "Parcialmente visitado", "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": { "auth": {
"forgot_password": "¿Has olvidado tu contraseña?", "forgot_password": "¿Has olvidado tu contraseña?",
@ -295,7 +308,8 @@
"email_required": "Se requiere correo electrónico", "email_required": "Se requiere correo electrónico",
"both_passwords_required": "Se requieren ambas contraseñas", "both_passwords_required": "Se requieren ambas contraseñas",
"new_password": "Nueva contraseña", "new_password": "Nueva contraseña",
"reset_failed": "No se pudo restablecer la contraseña" "reset_failed": "No se pudo restablecer la contraseña",
"or_3rd_party": "O inicie sesión con un servicio de terceros"
}, },
"users": { "users": {
"no_users_found": "No se encontraron usuarios con perfiles públicos." "no_users_found": "No se encontraron usuarios con perfiles públicos."
@ -369,7 +383,17 @@
"csrf_failed": "No se pudo recuperar el token CSRF", "csrf_failed": "No se pudo recuperar el token CSRF",
"duplicate_email": "Esta dirección de correo electrónico ya está en uso.", "duplicate_email": "Esta dirección de correo electrónico ya está en uso.",
"email_taken": "Esta dirección de correo electrónico ya está en uso.", "email_taken": "Esta dirección de correo electrónico ya está en uso.",
"username_taken": "Este nombre de usuario ya está en uso." "username_taken": "Este nombre de usuario ya está en uso.",
"administration_settings": "Configuración de administración",
"documentation_link": "Enlace de documentación",
"launch_account_connections": "Iniciar conexiones de cuenta",
"launch_administration_panel": "Iniciar el panel de administración",
"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",
"add_email": "Agregar correo electrónico",
"password_too_short": "La contraseña debe tener al menos 6 caracteres."
}, },
"checklist": { "checklist": {
"add_item": "Agregar artículo", "add_item": "Agregar artículo",

View file

@ -284,7 +284,8 @@
"email_required": "L'e-mail est requis", "email_required": "L'e-mail est requis",
"both_passwords_required": "Les deux mots de passe sont requis", "both_passwords_required": "Les deux mots de passe sont requis",
"new_password": "Nouveau mot de passe", "new_password": "Nouveau mot de passe",
"reset_failed": "Échec de la réinitialisation du mot de passe" "reset_failed": "Échec de la réinitialisation du mot de passe",
"or_3rd_party": "Ou connectez-vous avec un service tiers"
}, },
"users": { "users": {
"no_users_found": "Aucun utilisateur trouvé avec des profils publics." "no_users_found": "Aucun utilisateur trouvé avec des profils publics."
@ -298,7 +299,20 @@
"no_countries_found": "Aucun pays trouvé", "no_countries_found": "Aucun pays trouvé",
"not_visited": "Non visité", "not_visited": "Non visité",
"num_countries": "pays trouvés", "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": { "settings": {
"account_settings": "Paramètres du compte utilisateur", "account_settings": "Paramètres du compte utilisateur",
@ -369,7 +383,17 @@
"csrf_failed": "Échec de la récupération du jeton CSRF", "csrf_failed": "Échec de la récupération du jeton CSRF",
"duplicate_email": "Cette adresse e-mail est déjà utilisée.", "duplicate_email": "Cette adresse e-mail est déjà utilisée.",
"email_taken": "Cette adresse e-mail est déjà utilisée.", "email_taken": "Cette adresse e-mail est déjà utilisée.",
"username_taken": "Ce nom d'utilisateur est déjà utilisé." "username_taken": "Ce nom d'utilisateur est déjà utilisé.",
"administration_settings": "Paramètres d'administration",
"documentation_link": "Lien vers la documentation",
"launch_account_connections": "Lancer les connexions au compte",
"launch_administration_panel": "Lancer le panneau d'administration",
"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",
"add_email": "Ajouter un e-mail",
"password_too_short": "Le mot de passe doit contenir au moins 6 caractères"
}, },
"checklist": { "checklist": {
"add_item": "Ajouter un article", "add_item": "Ajouter un article",

View file

@ -284,7 +284,8 @@
"email_required": "L'e-mail è obbligatoria", "email_required": "L'e-mail è obbligatoria",
"both_passwords_required": "Sono necessarie entrambe le password", "both_passwords_required": "Sono necessarie entrambe le password",
"new_password": "Nuova parola d'ordine", "new_password": "Nuova parola d'ordine",
"reset_failed": "Impossibile reimpostare la password" "reset_failed": "Impossibile reimpostare la password",
"or_3rd_party": "Oppure accedi con un servizio di terze parti"
}, },
"users": { "users": {
"no_users_found": "Nessun utente trovato con profili pubblici." "no_users_found": "Nessun utente trovato con profili pubblici."
@ -298,7 +299,20 @@
"no_countries_found": "Nessun paese trovato", "no_countries_found": "Nessun paese trovato",
"not_visited": "Non visitato", "not_visited": "Non visitato",
"num_countries": "paesi trovati", "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": { "settings": {
"account_settings": "Impostazioni dell'account utente", "account_settings": "Impostazioni dell'account utente",
@ -369,7 +383,17 @@
"csrf_failed": "Impossibile recuperare il token CSRF", "csrf_failed": "Impossibile recuperare il token CSRF",
"duplicate_email": "Questo indirizzo email è già in uso.", "duplicate_email": "Questo indirizzo email è già in uso.",
"email_taken": "Questo indirizzo email è già in uso.", "email_taken": "Questo indirizzo email è già in uso.",
"username_taken": "Questo nome utente è già in uso." "username_taken": "Questo nome utente è già in uso.",
"administration_settings": "Impostazioni di amministrazione",
"documentation_link": "Collegamento alla documentazione",
"launch_account_connections": "Avvia Connessioni account",
"launch_administration_panel": "Avvia il pannello di amministrazione",
"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",
"add_email": "Aggiungi e-mail",
"password_too_short": "La password deve contenere almeno 6 caratteri"
}, },
"checklist": { "checklist": {
"add_item": "Aggiungi articolo", "add_item": "Aggiungi articolo",

View file

@ -284,7 +284,8 @@
"email_required": "E-mail is vereist", "email_required": "E-mail is vereist",
"both_passwords_required": "Beide wachtwoorden zijn vereist", "both_passwords_required": "Beide wachtwoorden zijn vereist",
"new_password": "Nieuw wachtwoord", "new_password": "Nieuw wachtwoord",
"reset_failed": "Kan het wachtwoord niet opnieuw instellen" "reset_failed": "Kan het wachtwoord niet opnieuw instellen",
"or_3rd_party": "Of log in met een service van derden"
}, },
"users": { "users": {
"no_users_found": "Er zijn geen gebruikers gevonden met openbare profielen." "no_users_found": "Er zijn geen gebruikers gevonden met openbare profielen."
@ -298,7 +299,20 @@
"no_countries_found": "Geen landen gevonden", "no_countries_found": "Geen landen gevonden",
"not_visited": "Niet bezocht", "not_visited": "Niet bezocht",
"num_countries": "landen gevonden", "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": { "settings": {
"account_settings": "Gebruikersaccount instellingen", "account_settings": "Gebruikersaccount instellingen",
@ -369,7 +383,17 @@
"csrf_failed": "Kan CSRF-token niet ophalen", "csrf_failed": "Kan CSRF-token niet ophalen",
"duplicate_email": "Dit e-mailadres is al in gebruik.", "duplicate_email": "Dit e-mailadres is al in gebruik.",
"email_taken": "Dit e-mailadres is al in gebruik.", "email_taken": "Dit e-mailadres is al in gebruik.",
"username_taken": "Deze gebruikersnaam is al in gebruik." "username_taken": "Deze gebruikersnaam is al in gebruik.",
"administration_settings": "Beheerinstellingen",
"documentation_link": "Documentatielink",
"launch_account_connections": "Start Accountverbindingen",
"launch_administration_panel": "Start het Beheerpaneel",
"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",
"add_email": "E-mail toevoegen",
"password_too_short": "Wachtwoord moet minimaal 6 tekens lang zijn"
}, },
"checklist": { "checklist": {
"add_item": "Artikel toevoegen", "add_item": "Artikel toevoegen",

View file

@ -275,7 +275,20 @@
"completely_visited": "Całkowicie odwiedzone", "completely_visited": "Całkowicie odwiedzone",
"all_subregions": "Wszystkie podregiony", "all_subregions": "Wszystkie podregiony",
"clear_search": "Wyczyść wyszukiwanie", "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": { "auth": {
"username": "Nazwa użytkownika", "username": "Nazwa użytkownika",
@ -295,7 +308,8 @@
"email_required": "Adres e-mail jest wymagany", "email_required": "Adres e-mail jest wymagany",
"both_passwords_required": "Obydwa hasła są wymagane", "both_passwords_required": "Obydwa hasła są wymagane",
"new_password": "Nowe hasło", "new_password": "Nowe hasło",
"reset_failed": "Nie udało się zresetować hasła" "reset_failed": "Nie udało się zresetować hasła",
"or_3rd_party": "Lub zaloguj się za pomocą usługi strony trzeciej"
}, },
"users": { "users": {
"no_users_found": "Nie znaleziono użytkowników z publicznymi profilami." "no_users_found": "Nie znaleziono użytkowników z publicznymi profilami."
@ -369,7 +383,17 @@
"csrf_failed": "Nie udało się pobrać tokena CSRF", "csrf_failed": "Nie udało się pobrać tokena CSRF",
"duplicate_email": "Ten adres e-mail jest już używany.", "duplicate_email": "Ten adres e-mail jest już używany.",
"email_taken": "Ten adres e-mail jest już używany.", "email_taken": "Ten adres e-mail jest już używany.",
"username_taken": "Ta nazwa użytkownika jest już używana." "username_taken": "Ta nazwa użytkownika jest już używana.",
"administration_settings": "Ustawienia administracyjne",
"documentation_link": "Link do dokumentacji",
"launch_account_connections": "Uruchom Połączenia kont",
"launch_administration_panel": "Uruchom Panel administracyjny",
"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",
"add_email": "Dodaj e-mail",
"password_too_short": "Hasło musi mieć co najmniej 6 znaków"
}, },
"collection": { "collection": {
"collection_created": "Kolekcja została pomyślnie utworzona!", "collection_created": "Kolekcja została pomyślnie utworzona!",

View file

@ -275,7 +275,20 @@
"no_countries_found": "Inga länder hittades", "no_countries_found": "Inga länder hittades",
"not_visited": "Ej besökta", "not_visited": "Ej besökta",
"num_countries": "länder hittades", "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": { "auth": {
"confirm_password": "Bekräfta lösenord", "confirm_password": "Bekräfta lösenord",
@ -295,7 +308,8 @@
"email_required": "E-post krävs", "email_required": "E-post krävs",
"both_passwords_required": "Båda lösenorden krävs", "both_passwords_required": "Båda lösenorden krävs",
"new_password": "Nytt lösenord", "new_password": "Nytt lösenord",
"reset_failed": "Det gick inte att återställa lösenordet" "reset_failed": "Det gick inte att återställa lösenordet",
"or_3rd_party": "Eller logga in med en tredjepartstjänst"
}, },
"users": { "users": {
"no_users_found": "Inga användare hittades med offentliga profiler." "no_users_found": "Inga användare hittades med offentliga profiler."
@ -369,7 +383,17 @@
"csrf_failed": "Det gick inte att hämta CSRF-token", "csrf_failed": "Det gick inte att hämta CSRF-token",
"duplicate_email": "Den här e-postadressen används redan.", "duplicate_email": "Den här e-postadressen används redan.",
"email_taken": "Den här e-postadressen används redan.", "email_taken": "Den här e-postadressen används redan.",
"username_taken": "Detta användarnamn används redan." "username_taken": "Detta användarnamn används redan.",
"administration_settings": "Administrationsinställningar",
"documentation_link": "Dokumentationslänk",
"launch_account_connections": "Starta kontoanslutningar",
"launch_administration_panel": "Starta administrationspanelen",
"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",
"add_email": "Lägg till e-post",
"password_too_short": "Lösenordet måste bestå av minst 6 tecken"
}, },
"checklist": { "checklist": {
"add_item": "Lägg till objekt", "add_item": "Lägg till objekt",

View file

@ -284,7 +284,8 @@
"email_required": "电子邮件为必填项", "email_required": "电子邮件为必填项",
"both_passwords_required": "两个密码都需要", "both_passwords_required": "两个密码都需要",
"new_password": "新密码", "new_password": "新密码",
"reset_failed": "重置密码失败" "reset_failed": "重置密码失败",
"or_3rd_party": "或者使用第三方服务登录"
}, },
"worldtravel": { "worldtravel": {
"all": "全部", "all": "全部",
@ -295,7 +296,20 @@
"no_countries_found": "没有找到国家", "no_countries_found": "没有找到国家",
"not_visited": "未访问过", "not_visited": "未访问过",
"num_countries": "找到的国家", "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": { "users": {
"no_users_found": "未找到具有公开个人资料的用户。" "no_users_found": "未找到具有公开个人资料的用户。"
@ -369,7 +383,17 @@
"csrf_failed": "获取 CSRF 令牌失败", "csrf_failed": "获取 CSRF 令牌失败",
"duplicate_email": "该电子邮件地址已被使用。", "duplicate_email": "该电子邮件地址已被使用。",
"email_taken": "该电子邮件地址已被使用。", "email_taken": "该电子邮件地址已被使用。",
"username_taken": "该用户名已被使用。" "username_taken": "该用户名已被使用。",
"administration_settings": "管理设置",
"documentation_link": "文档链接",
"launch_account_connections": "启动帐户连接",
"launch_administration_panel": "启动管理面板",
"no_verified_email_warning": "您必须拥有经过验证的电子邮件地址才能启用双因素身份验证。",
"social_auth_desc": "为您的帐户启用或禁用社交和 OIDC 身份验证提供商。\n这些连接允许您使用自托管身份验证身份提供商如 Authentik或第三方提供商如 GitHub登录。",
"social_auth_desc_2": "这些设置在 AdventureLog 服务器中进行管理,并且必须由管理员手动启用。",
"social_oidc_auth": "社交和 OIDC 身份验证",
"add_email": "添加电子邮件",
"password_too_short": "密码必须至少为 6 个字符"
}, },
"checklist": { "checklist": {
"add_item": "添加项目", "add_item": "添加项目",

View file

@ -53,18 +53,25 @@ async function handleRequest(
const headers = new Headers(request.headers); 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(); const csrfToken = await fetchCSRFToken();
if (!csrfToken) { if (!csrfToken) {
return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); 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 { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: request.method, method: request.method,
headers: { headers: {
...Object.fromEntries(headers), ...Object.fromEntries(headers),
'X-CSRFToken': csrfToken, 'X-CSRFToken': csrfToken,
Cookie: `csrftoken=${csrfToken}` Cookie: cookieHeader
}, },
body: body:
request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,

View file

@ -12,7 +12,7 @@ export async function GET(event) {
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) { 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); return handleRequest(url, params, request, fetch, cookies, searchParam, true);
} }
@ -53,18 +53,25 @@ async function handleRequest(
const headers = new Headers(request.headers); 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(); const csrfToken = await fetchCSRFToken();
if (!csrfToken) { if (!csrfToken) {
return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); 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 { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: request.method, method: request.method,
headers: { headers: {
...Object.fromEntries(headers), ...Object.fromEntries(headers),
'X-CSRFToken': csrfToken, 'X-CSRFToken': csrfToken,
Cookie: `csrftoken=${csrfToken}` Cookie: cookieHeader
}, },
body: body:
request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,

View file

@ -1,69 +1,84 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
import { fetchCSRFToken } from '$lib/index.server';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function GET({ url, params, request, fetch, cookies }) { export async function GET(event) {
// add the param format = json to the url or add additional if anothre param is already present const { url, params, request, fetch, cookies } = event;
if (url.search) { const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
url.search = url.search + '&format=json'; return handleRequest(url, params, request, fetch, cookies, searchParam);
} else {
url.search = '?format=json';
}
return handleRequest(url, params, request, fetch, cookies);
} }
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function POST({ url, params, request, fetch, cookies }) { 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 }) { 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 }) { 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 }) { 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( async function handleRequest(
url: any, url: any,
params: any, params: any,
request: any, request: any,
fetch: any, fetch: any,
cookies: any, cookies: any,
searchParam: string,
requreTrailingSlash: boolean | undefined = false requreTrailingSlash: boolean | undefined = false
) { ) {
const path = params.path; 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('/')) { if (requreTrailingSlash && !targetUrl.endsWith('/')) {
targetUrl += '/'; 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 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) { // Generate a new csrf token (using your existing fetchCSRFToken function)
headers.set('Cookie', `${authCookie}`); 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 { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: request.method, method: request.method,
headers: headers, headers: {
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined ...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) { if (response.status === 204) {
// For 204 No Content, return a response with no body
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: response.headers headers: response.headers
@ -71,10 +86,13 @@ async function handleRequest(
} }
const responseData = await response.text(); 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, { return new Response(responseData, {
status: response.status, status: response.status,
headers: response.headers headers: cleanHeaders
}); });
} catch (error) { } catch (error) {
console.error('Error forwarding request:', error); console.error('Error forwarding request:', error);

View file

@ -17,7 +17,11 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<!-- Welcome Message --> <!-- Welcome Message -->
<div class="mb-8"> <div class="mb-8">
<h1 class="text-4xl font-extrabold">{$t('dashboard.welcome_back')}, {user?.first_name}!</h1> <h1 class="text-4xl font-extrabold">
{$t('dashboard.welcome_back')}, {user?.first_name
? `${user.first_name} ${user.last_name}`
: user?.username}!
</h1>
</div> </div>
<!-- Stats --> <!-- Stats -->

View file

@ -4,6 +4,7 @@ import type { Actions, PageServerLoad, RouteParams } from './$types';
import { getRandomBackground, getRandomQuote } from '$lib'; import { getRandomBackground, getRandomQuote } from '$lib';
import { fetchCSRFToken } from '$lib/index.server'; import { fetchCSRFToken } from '$lib/index.server';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
if (event.locals.user) { if (event.locals.user) {
@ -12,10 +13,17 @@ export const load: PageServerLoad = async (event) => {
const quote = getRandomQuote(); const quote = getRandomQuote();
const background = getRandomBackground(); 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 { return {
props: { props: {
quote, quote,
background background,
socialProviders
} }
}; };
} }

View file

@ -9,14 +9,19 @@
let isImageInfoModalOpen: boolean = false; 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 { page } from '$app/stores';
import ImageInfoModal from '$lib/components/ImageInfoModal.svelte'; import ImageInfoModal from '$lib/components/ImageInfoModal.svelte';
import type { Background } from '$lib/types.js'; 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: '' };
</script> </script>
{#if isImageInfoModalOpen} {#if isImageInfoModalOpen}
@ -62,6 +67,22 @@
{/if} {/if}
<button class="py-2 px-4 btn btn-primary mr-2">{$t('auth.login')}</button> <button class="py-2 px-4 btn btn-primary mr-2">{$t('auth.login')}</button>
{#if socialProviders.length > 0}
<div class="divider text-center text-sm my-4">{$t('auth.or_3rd_party')}</div>
<div class="flex justify-center">
{#each socialProviders as provider}
<a href={provider.url} class="btn btn-primary mr-2 flex items-center">
{#if provider.provider === 'github'}
<GitHub class="w-4 h-4 mr-2" />
{:else if provider.provider === 'openid_connect'}
<OpenIdConnect class="w-4 h-4 mr-2" />
{/if}
{provider.name}
</a>
{/each}
</div>
{/if}
<div class="flex justify-between mt-4"> <div class="flex justify-between mt-4">
<p><a href="/signup" class="underline">{$t('auth.signup')}</a></p> <p><a href="/signup" class="underline">{$t('auth.signup')}</a></p>
<p> <p>

View file

@ -66,12 +66,22 @@ export const load: PageServerLoad = async (event) => {
immichIntegration = await immichIntegrationsFetch.json(); 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 { return {
props: { props: {
user, user,
emails, emails,
authenticators, authenticators,
immichIntegration immichIntegration,
publicUrl
} }
}; };
}; };
@ -179,33 +189,57 @@ export const actions: Actions = {
const password1 = formData.get('password1') as string | null | undefined; const password1 = formData.get('password1') as string | null | undefined;
const password2 = formData.get('password2') 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) { if (password1 !== password2) {
return fail(400, { message: 'settings.password_does_not_match' }); return fail(400, { message: 'settings.password_does_not_match' });
} }
if (!current_password) { if (!current_password) {
return fail(400, { message: 'settings.password_is_required' }); current_password = null;
}
if (password1 && password1?.length < 6) {
return fail(400, { message: 'settings.password_too_short' });
} }
let csrfToken = await fetchCSRFToken(); let csrfToken = await fetchCSRFToken();
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { if (current_password) {
method: 'POST', let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
headers: { method: 'POST',
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, headers: {
'X-CSRFToken': csrfToken, Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json' 'X-CSRFToken': csrfToken,
}, 'Content-Type': 'application/json'
body: JSON.stringify({ },
current_password, body: JSON.stringify({
new_password: password1 current_password,
}) new_password: password1
}); })
if (!res.ok) { });
return fail(res.status, { message: 'settings.error_change_password' }); 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) => { changeEmail: async (event) => {
if (!event.locals.user) { if (!event.locals.user) {

View file

@ -9,6 +9,7 @@
import TotpModal from '$lib/components/TOTPModal.svelte'; import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js'; import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg'; import ImmichLogo from '$lib/assets/immich.svg';
import { goto } from '$app/navigation';
export let data; export let data;
console.log(data); console.log(data);
@ -20,7 +21,7 @@
} }
let new_email: string = ''; let new_email: string = '';
let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration; let immichIntegration = data.props.immichIntegration;
let newImmichIntegration: ImmichIntegration = { let newImmichIntegration: ImmichIntegration = {
@ -307,17 +308,19 @@
</h2> </h2>
<div class="bg-neutral p-6 rounded-lg shadow-md"> <div class="bg-neutral p-6 rounded-lg shadow-md">
<form method="post" action="?/changePassword" use:enhance class="space-y-6"> <form method="post" action="?/changePassword" use:enhance class="space-y-6">
<div> {#if user.has_password}
<label for="current_password" class="text-sm font-medium text-neutral-content" <div>
>{$t('settings.current_password')}</label <label for="current_password" class="text-sm font-medium text-neutral-content"
> >{$t('settings.current_password')}</label
<input >
type="password" <input
id="current_password" type="password"
name="current_password" id="current_password"
class="block w-full mt-1 input input-bordered input-primary" name="current_password"
/> class="block w-full mt-1 input input-bordered input-primary"
</div> />
</div>
{/if}
<div> <div>
<label for="password1" class="text-sm font-medium text-neutral-content" <label for="password1" class="text-sm font-medium text-neutral-content"
@ -342,6 +345,11 @@
class="block w-full mt-1 input input-bordered input-primary" class="block w-full mt-1 input input-bordered input-primary"
/> />
</div> </div>
{#if $page.form?.message}
<div class="alert alert-warning">
{$t($page.form?.message)}
</div>
{/if}
<div <div
class="tooltip tooltip-warning" class="tooltip tooltip-warning"
@ -391,7 +399,7 @@
{/if} {/if}
</div> </div>
<form class="mt-4" on:submit={addEmail}> <form class="mt-4" on:submit|preventDefault={addEmail}>
<input <input
type="email" type="email"
id="new_email" id="new_email"
@ -400,7 +408,7 @@
placeholder={$t('settings.new_email')} placeholder={$t('settings.new_email')}
class="block w-full input input-bordered input-primary" class="block w-full input input-bordered input-primary"
/> />
<button class="w-full mt-4 btn btn-primary py-2">{$t('settings.email_change')}</button> <button class="w-full mt-4 btn btn-primary py-2">{$t('settings.add_email')}</button>
</form> </form>
</div> </div>
</section> </section>
@ -413,9 +421,15 @@
<div class="bg-neutral p-6 rounded-lg shadow-md text-center"> <div class="bg-neutral p-6 rounded-lg shadow-md text-center">
{#if !data.props.authenticators} {#if !data.props.authenticators}
<p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p> <p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
<button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)} {#if !emails.some((e) => e.verified)}
>{$t('settings.enable_mfa')}</button <div class="alert alert-warning mt-4">
> {$t('settings.no_verified_email_warning')}
</div>
{:else}
<button class="btn btn-primary mt-4" on:click={() => (isMFAModalOpen = true)}
>{$t('settings.enable_mfa')}</button
>
{/if}
{:else} {:else}
<button class="btn btn-warning mt-4" on:click={disableMfa} <button class="btn btn-warning mt-4" on:click={disableMfa}
>{$t('settings.disable_mfa')}</button >{$t('settings.disable_mfa')}</button
@ -424,6 +438,58 @@
</div> </div>
</section> </section>
<!-- Admin Settings -->
{#if user.is_staff}
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">
{$t('settings.administration_settings')}
</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
<a class="btn btn-primary mt-4" href={`${public_url}/admin/`} target="_blank"
>{$t('settings.launch_administration_panel')}</a
>
</div>
</section>
{/if}
<!-- Social Auth Settings -->
<section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8">{$t('settings.social_oidc_auth')}</h2>
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
<p>
{$t('settings.social_auth_desc')}
</p>
<div role="alert" class="alert alert-info mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span
>{$t('settings.social_auth_desc_2')}
<a
href="https://adventurelog.app/docs/configuration/social_auth.html"
class="link link-neutral"
target="_blank">{$t('settings.documentation_link')}</a
>.
</span>
</div>
<a
class="btn btn-primary mt-4"
href={`${public_url}/accounts/social/connections/`}
target="_blank">{$t('settings.launch_account_connections')}</a
>
</div>
</section>
<!-- Immich Integration Section --> <!-- Immich Integration Section -->
<section class="space-y-8"> <section class="space-y-8">
<h2 class="text-2xl font-semibold text-center mt-8"> <h2 class="text-2xl font-semibold text-center mt-8">

View file

@ -30,80 +30,3 @@ export const load = (async (event) => {
} }
} }
}) satisfies PageServerLoad; }) 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
};
}
}
};

View file

@ -24,7 +24,7 @@
visitedRegions = visitedRegions.filter( visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id (visitedRegion) => visitedRegion.region !== region.id
); );
removeVisit(region, visitedRegion.id); removeVisit(region);
} else { } else {
markVisited(region); markVisited(region);
} }
@ -32,48 +32,35 @@
} }
async function markVisited(region: Region) { async function markVisited(region: Region) {
let res = await fetch(`/worldtravel?/markVisited`, { let res = await fetch(`/api/visitedregion/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ regionId: region.id }) headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region: region.id })
}); });
if (res.ok) { 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 {
console.error('Failed to mark region as visited'); 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',
`${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}`
);
} }
} }
async function removeVisit(region: Region, visitId: number) { async function removeVisit(region: Region) {
let res = await fetch(`/worldtravel?/removeVisited`, { let res = await fetch(`/api/visitedregion/${region.id}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitId: visitId }) method: 'DELETE'
}); });
if (res.ok) { if (!res.ok) {
addToast('info', `Visit to ${region.name} removed`); console.error($t('worldtravel.region_failed_visited'));
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`);
return;
} else { } else {
console.error('Failed to remove visit'); visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id);
addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`);
} }
} }
@ -86,16 +73,16 @@
); );
</script> </script>
<h1 class="text-center font-bold text-4xl mb-4">Regions in {country?.name}</h1> <h1 class="text-center font-bold text-4xl mb-4">{$t('worldtravel.regions_in')} {country?.name}</h1>
<div class="flex items-center justify-center mb-4"> <div class="flex items-center justify-center mb-4">
<div class="stats shadow bg-base-300"> <div class="stats shadow bg-base-300">
<div class="stat"> <div class="stat">
<div class="stat-title">Region Stats</div> <div class="stat-title">{$t('worldtravel.region_stats')}</div>
<div class="stat-value">{numVisitedRegions}/{numRegions} Visited</div> <div class="stat-value">{numVisitedRegions}/{numRegions} {$t('adventures.visited')}</div>
{#if numRegions === numVisitedRegions} {#if numRegions === numVisitedRegions}
<div class="stat-desc">You've visited all regions in {country?.name} 🎉!</div> <div class="stat-desc">{$t('worldtravel.all_visited')} {country?.name} 🎉!</div>
{:else} {:else}
<div class="stat-desc">Keep exploring!</div> <div class="stat-desc">{$t('adventures.keep_exploring')}</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -110,8 +97,12 @@
visitedRegions = [...visitedRegions, e.detail]; visitedRegions = [...visitedRegions, e.detail];
numVisitedRegions++; numVisitedRegions++;
}} }}
visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id} on:remove={() => {
on:remove={() => numVisitedRegions--} visitedRegions = visitedRegions.filter(
(visitedRegion) => visitedRegion.region !== region.id
);
numVisitedRegions--;
}}
/> />
{/each} {/each}
</div> </div>

View file

@ -0,0 +1,67 @@
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { City, Country, Region, VisitedCity, 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 visitedCities: VisitedCity[] = [];
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/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;
}
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,
visitedCities
}
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,223 @@
<script lang="ts">
import CityCard from '$lib/components/CityCard.svelte';
import { addToast } from '$lib/toasts';
import type { City } from '$lib/types';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { MapLibre, Marker } from 'svelte-maplibre';
export let data: PageData;
console.log(data);
let searchQuery: string = '';
let filteredCities: City[] = [];
const allCities: City[] = data.props?.cities || [];
let visitedCities = data.props?.visitedCities || [];
$: {
if (searchQuery === '') {
filteredCities = allCities;
} else {
// otherwise, filter countries by name
filteredCities = filteredCities.filter((country) =>
country.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
}
function togleVisited(city: City) {
return () => {
const visitedCity = visitedCities.find((visitedCity) => visitedCity.city === city.id);
if (visitedCity) {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
removeVisit(city);
} else {
markVisited(city);
}
};
}
async function markVisited(city: City) {
let res = await fetch(`/api/visitedcity/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: city.id })
});
if (!res.ok) {
console.error('Failed to mark city as visited');
addToast('error', `Failed to mark visit to ${city.name}`);
return;
} else {
visitedCities = [...visitedCities, await res.json()];
addToast('success', `Visit to ${city.name} marked`);
}
}
async function removeVisit(region: City) {
let res = await fetch(`/api/visitedcity/${region.id}`, {
headers: { 'Content-Type': 'application/json' },
method: 'DELETE'
});
if (!res.ok) {
console.error('Failed to remove visit');
addToast('error', `Failed to remove visit to ${region.name}`);
return;
} else {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== region.id);
addToast('info', `Visit to ${region.name} removed`);
}
}
let numCities: number = data.props?.cities?.length || 0;
let numVisitedCities: number = visitedCities.length;
$: {
numVisitedCities = visitedCities.length;
}
</script>
<h1 class="text-center font-bold text-4xl">Cities in {data.props?.region.name}</h1>
<!-- result count -->
<p class="text-center mb-4">
{allCities.length}
Cities Found
</p>
<div class="flex items-center justify-center mb-4">
<div class="stats shadow bg-base-300">
<div class="stat">
<div class="stat-title">City Stats</div>
<div class="stat-value">{numVisitedCities}/{numCities} Visited</div>
{#if numCities === numVisitedCities}
<div class="stat-desc">You've visited all cities in {data.props?.region.name} 🎉!</div>
{:else}
<div class="stat-desc">Keep exploring!</div>
{/if}
</div>
</div>
</div>
<!-- <div class="flex items-center justify-center mb-4">
<div class="join">
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.all')}
checked
on:click={() => (filterOption = 'all')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.partially_visited')}
on:click={() => (filterOption = 'partial')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.completely_visited')}
on:click={() => (filterOption = 'complete')}
/>
<input
class="join-item btn"
type="radio"
name="filter"
aria-label={$t('worldtravel.not_visited')}
on:click={() => (filterOption = 'not')}
/>
</div>
<select class="select select-bordered w-full max-w-xs ml-4" bind:value={subRegionOption}>
<option value="">{$t('worldtravel.all_subregions')}</option>
{#each worldSubregions as subregion}
<option value={subregion}>{subregion}</option>
{/each}
</select>
<div class="flex items-center justify-center ml-4">
<input
type="checkbox"
class="checkbox checkbox-bordered"
bind:checked={showMap}
aria-label={$t('adventures.show_map')}
/>
<span class="ml-2">{$t('adventures.show_map')}</span>
</div>
</div> -->
{#if allCities.length > 0}
<div class="flex items-center justify-center mb-4">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered w-full max-w-xs"
bind:value={searchQuery}
/>
{#if searchQuery.length > 0}
<!-- clear button -->
<div class="flex items-center justify-center ml-4">
<button class="btn btn-neutral" on:click={() => (searchQuery = '')}>
{$t('worldtravel.clear_search')}
</button>
</div>
{/if}
</div>
<div class="mt-4 mb-4 flex justify-center">
<!-- checkbox to toggle marker -->
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max-h-full w-10/12 rounded-lg"
standardControls
center={allCities[0] && allCities[0].longitude !== null && allCities[0].latitude !== null
? [allCities[0].longitude, allCities[0].latitude]
: [0, 0]}
zoom={8}
>
{#each filteredCities as city}
{#if city.latitude && city.longitude}
<Marker
lngLat={[city.longitude, city.latitude]}
class="grid px-2 py-1 place-items-center rounded-full border border-gray-200 {visitedCities.some(
(visitedCity) => visitedCity.city === city.id
)
? 'bg-red-300'
: 'bg-blue-300'} text-black focus:outline-6 focus:outline-black"
on:click={togleVisited(city)}
>
<span class="text-xs">
{city.name}
</span>
</Marker>
{/if}
{/each}
</MapLibre>
</div>
{/if}
<div class="flex flex-wrap gap-4 mr-4 ml-4 justify-center content-center">
{#each filteredCities as city}
<CityCard
{city}
visited={visitedCities.some((visitedCity) => visitedCity.city === city.id)}
on:visit={(e) => {
visitedCities = [...visitedCities, e.detail];
}}
on:remove={() => {
visitedCities = visitedCities.filter((visitedCity) => visitedCity.city !== city.id);
}}
/>
{/each}
</div>
{#if filteredCities.length === 0}
<p class="text-center font-bold text-2xl mt-12">{$t('worldtravel.no_cities_found')}</p>
{/if}
<svelte:head>
<title>Cities in {data.props?.region.name} | World Travel</title>
<meta name="description" content="Explore the world and add countries to your visited list!" />
</svelte:head>