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:
commit
ec3ada986d
59 changed files with 1776 additions and 484 deletions
205
README.md
205
README.md
|
@ -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>
|
||||
|
||||
[](https://www.buymeacoffee.com/seanmorley15)
|
||||
<p>
|
||||
The ultimate travel companion for the modern-day explorer.
|
||||
</p>
|
||||
|
||||
- **[Documentation](https://adventurelog.app)**
|
||||
- **[Demo](https://demo.adventurelog.app)**
|
||||
- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)**
|
||||
<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>
|
||||
|
||||
<br />
|
||||
|
||||
<!-- Table of Contents -->
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Docker 🐋](#docker-)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Configuration](#configuration)
|
||||
- [Frontend Container (web)](#frontend-container-web)
|
||||
- [Backend Container (server)](#backend-container-server)
|
||||
- [Proxy Container (nginx) Configuration](#proxy-container-nginx-configuration)
|
||||
- [Running the Containers](#running-the-containers)
|
||||
- [Screenshots 🖼️](#screenshots)
|
||||
- [About AdventureLog](#about-adventurelog)
|
||||
- [Attribution](#attribution)
|
||||
- [About the Project](#-about-the-project)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Tech Stack](#-tech-stack)
|
||||
- [Features](#-features)
|
||||
- [Roadmap](#-roadmap)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
- [Contact](#-contact)
|
||||
- [Acknowledgements](#-acknowledgements)
|
||||
|
||||
# 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.
|
||||
**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems.
|
||||
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.
|
||||
|
||||
## 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
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
|
||||
```
|
||||
<!-- TechStack -->
|
||||
|
||||
## 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 |
|
||||
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | http://server:8000 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
- **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.
|
||||
- Adventures can be sorted into custom categories for easy organization.
|
||||
- Adventures can be marked as private or public, allowing you to share your adventures with friends and family.
|
||||
- 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 |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `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 |
|
||||
## 🧭 Roadmap
|
||||
|
||||
## 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
|
||||
docker compose up -d
|
||||
```
|
||||
## 👋 Contributing
|
||||
|
||||
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!
|
||||
|
||||

|
||||
Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
|
||||
See `contributing.md` for ways to get started.
|
||||
|
||||

|
||||
Shows specific details about an adventure, including the name, date, location, description, and rating.
|
||||
<!-- License -->
|
||||
|
||||

|
||||
## 📃 License
|
||||
|
||||

|
||||
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.
|
||||
Distributed under the GNU General Public License v3.0. See `LICENSE` for more information.
|
||||
|
||||

|
||||
Displays a summary of your adventures, including your world travel stats.
|
||||
<!-- 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.
|
||||
## 🤝 Contact
|
||||
|
||||

|
||||
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
|
||||
Sean Morley - [website](https://seanmorley.com)
|
||||
|
||||

|
||||
Displays the regions for a specific country, includes a map view to visually select regions.
|
||||
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!
|
||||
|
||||
# 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:
|
||||
|
||||
- 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 🖼️
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Roadmap 🛣️
|
||||
|
||||
- Improved mobile device support
|
||||
- Password reset functionality
|
||||
- Improved error handling
|
||||
- Handling of adventure cards with variable width -->
|
||||
|
||||
# Attribution
|
||||
## 💎 Acknowledgements
|
||||
|
||||
- Logo Design by [nordtechtiger](https://github.com/nordtechtiger)
|
||||
- WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database)
|
||||
|
|
|
@ -20,21 +20,45 @@ done
|
|||
python manage.py migrate
|
||||
|
||||
# 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..."
|
||||
python manage.py shell << EOF
|
||||
from django.contrib.auth import get_user_model
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
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.")
|
||||
|
||||
# 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:
|
||||
print("Superuser already exists.")
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
# Sync the countries and world travel regions
|
||||
# Sync the countries and world travel regions
|
||||
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
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import os
|
|||
from django.contrib import admin
|
||||
from django.utils.html import mark_safe
|
||||
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
|
||||
from worldtravel.models import Country, Region, VisitedRegion
|
||||
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
admin.autodiscover()
|
||||
|
@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin):
|
|||
|
||||
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.auth.admin import UserAdmin
|
||||
from users.models import CustomUser
|
||||
|
@ -127,6 +137,8 @@ admin.site.register(Checklist)
|
|||
admin.site.register(ChecklistItem)
|
||||
admin.site.register(AdventureImage, AdventureImageAdmin)
|
||||
admin.site.register(Category, CategoryAdmin)
|
||||
admin.site.register(City, CityAdmin)
|
||||
admin.site.register(VisitedCity)
|
||||
|
||||
admin.site.site_header = 'AdventureLog Admin'
|
||||
admin.site.site_title = 'AdventureLog Admin Site'
|
||||
|
|
|
@ -21,3 +21,22 @@ class PrintCookiesMiddleware:
|
|||
print(request.COOKIES)
|
||||
response = self.get_response(request)
|
||||
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
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.db.models.functions import Lower
|
|||
from rest_framework.response import Response
|
||||
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from worldtravel.models import VisitedRegion, Region, Country
|
||||
from worldtravel.models import VisitedCity, VisitedRegion, Region, Country, City
|
||||
from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
|
@ -20,6 +20,7 @@ from django.contrib.auth import get_user_model
|
|||
from icalendar import Calendar, Event, vText, vCalAddress
|
||||
from django.http import HttpResponse
|
||||
from datetime import datetime
|
||||
from django.db.models import Min
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -44,17 +45,25 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
include_collections = self.request.query_params.get('include_collections', 'true')
|
||||
|
||||
valid_order_by = ['name', 'type', 'start_date', 'rating', 'updated_at']
|
||||
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'name'
|
||||
|
||||
if order_direction not in ['asc', 'desc']:
|
||||
order_direction = 'asc'
|
||||
|
||||
if order_by == 'date':
|
||||
# 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
|
||||
if order_by == 'name':
|
||||
elif order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
elif order_by == 'rating':
|
||||
queryset = queryset.filter(rating__isnull=False)
|
||||
ordering = 'rating'
|
||||
else:
|
||||
ordering = order_by
|
||||
|
||||
|
@ -1150,41 +1159,48 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
|
|||
Returns a dictionary containing the region name, country name, and ISO code if found.
|
||||
"""
|
||||
iso_code = None
|
||||
town = None
|
||||
city = None
|
||||
county = None
|
||||
town_city_or_county = None
|
||||
display_name = None
|
||||
country_code = None
|
||||
city = None
|
||||
|
||||
# town = None
|
||||
# city = None
|
||||
# county = None
|
||||
|
||||
if 'address' in data.keys():
|
||||
keys = data['address'].keys()
|
||||
for key in keys:
|
||||
if key.find("ISO") != -1:
|
||||
iso_code = data['address'][key]
|
||||
if 'town' in keys:
|
||||
town = data['address']['town']
|
||||
town_city_or_county = data['address']['town']
|
||||
if 'county' in keys:
|
||||
county = data['address']['county']
|
||||
town_city_or_county = data['address']['county']
|
||||
if 'city' in keys:
|
||||
city = data['address']['city']
|
||||
town_city_or_county = data['address']['city']
|
||||
if not iso_code:
|
||||
return {"error": "No region found"}
|
||||
|
||||
region = Region.objects.filter(id=iso_code).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||
is_visited = False
|
||||
|
||||
region_visited = False
|
||||
city_visited = False
|
||||
country_code = iso_code[:2]
|
||||
|
||||
if region:
|
||||
if city:
|
||||
display_name = f"{city}, {region.name}, {country_code}"
|
||||
elif town:
|
||||
display_name = f"{town}, {region.name}, {country_code}"
|
||||
elif county:
|
||||
display_name = f"{county}, {region.name}, {country_code}"
|
||||
if town_city_or_county:
|
||||
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
|
||||
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
|
||||
if visited_region:
|
||||
is_visited = True
|
||||
region_visited = True
|
||||
if visited_city:
|
||||
city_visited = True
|
||||
if region:
|
||||
return {"id": iso_code, "region": region.name, "country": region.country.name, "is_visited": is_visited, "display_name": display_name}
|
||||
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited}
|
||||
return {"error": "No region found"}
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
|
|
|
@ -42,6 +42,7 @@ INSTALLED_APPS = (
|
|||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
# "allauth_ui",
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'allauth',
|
||||
|
@ -49,8 +50,8 @@ INSTALLED_APPS = (
|
|||
'allauth.mfa',
|
||||
'allauth.headless',
|
||||
'allauth.socialaccount',
|
||||
# "widget_tweaks",
|
||||
# "slippers",
|
||||
'allauth.socialaccount.providers.github',
|
||||
'allauth.socialaccount.providers.openid_connect',
|
||||
'drf_yasg',
|
||||
'corsheaders',
|
||||
'adventures',
|
||||
|
@ -58,6 +59,9 @@ INSTALLED_APPS = (
|
|||
'users',
|
||||
'integrations',
|
||||
'django.contrib.gis',
|
||||
# 'widget_tweaks',
|
||||
# 'slippers',
|
||||
|
||||
)
|
||||
|
||||
MIDDLEWARE = (
|
||||
|
@ -65,6 +69,7 @@ MIDDLEWARE = (
|
|||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'adventures.middleware.OverrideHostMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
|
@ -75,6 +80,8 @@ MIDDLEWARE = (
|
|||
# disable verifications for new users
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'none'
|
||||
|
||||
ALLAUTH_UI_THEME = "night"
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
|
@ -120,7 +127,7 @@ USE_L10N = True
|
|||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
SESSION_COOKIE_SAMESITE = None
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.7/howto/static-files/
|
||||
|
@ -143,6 +150,7 @@ STORAGES = {
|
|||
}
|
||||
}
|
||||
|
||||
SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
|
@ -175,6 +183,11 @@ SESSION_SAVE_EVERY_REQUEST = True
|
|||
|
||||
FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
# Set login redirect URL to the frontend
|
||||
LOGIN_REDIRECT_URL = FRONTEND_URL
|
||||
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
|
||||
HEADLESS_FRONTEND_URLS = {
|
||||
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
|
||||
"account_reset_password": f"{FRONTEND_URL}/user/reset-password",
|
||||
|
@ -263,4 +276,4 @@ LOGGING = {
|
|||
},
|
||||
}
|
||||
# https://github.com/dr5hn/countries-states-cities-database/tags
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.4'
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.5'
|
|
@ -3,8 +3,8 @@ from django.contrib import admin
|
|||
from django.views.generic import RedirectView, TemplateView
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView
|
||||
from .views import get_csrf_token
|
||||
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView
|
||||
from .views import get_csrf_token, get_public_url
|
||||
from drf_yasg.views import get_schema_view
|
||||
|
||||
from drf_yasg import openapi
|
||||
|
@ -27,7 +27,10 @@ urlpatterns = [
|
|||
|
||||
path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'),
|
||||
|
||||
path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'),
|
||||
|
||||
path('csrf/', get_csrf_token, name='get_csrf_token'),
|
||||
path('public-url/', get_public_url, name='get_public_url'),
|
||||
|
||||
path('', TemplateView.as_view(template_name='home.html')),
|
||||
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
from django.http import JsonResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from os import getenv
|
||||
|
||||
def get_csrf_token(request):
|
||||
csrf_token = get_token(request)
|
||||
return JsonResponse({'csrfToken': csrf_token})
|
||||
|
||||
def get_public_url(request):
|
||||
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
|
|
@ -13,8 +13,10 @@ django-geojson
|
|||
setuptools
|
||||
gunicorn==23.0.0
|
||||
qrcode==8.0
|
||||
# slippers==0.6.2
|
||||
# django-allauth-ui==1.5.1
|
||||
# django-widget-tweaks==1.5.0
|
||||
slippers==0.6.2
|
||||
django-allauth-ui==1.5.1
|
||||
django-widget-tweaks==1.5.0
|
||||
django-ical==1.9.2
|
||||
icalendar==6.1.0
|
||||
ijson==3.3.0
|
||||
tqdm==4.67.1
|
|
@ -36,7 +36,7 @@ import os
|
|||
|
||||
class UserDetailsSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
User model w/o password
|
||||
User model without exposing the password.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
return username
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
extra_fields = ['profile_pic', 'uuid', 'public_profile']
|
||||
profile_pic = serializers.ImageField(required=False)
|
||||
|
||||
if hasattr(UserModel, 'USERNAME_FIELD'):
|
||||
extra_fields.append(UserModel.USERNAME_FIELD)
|
||||
|
@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
extra_fields.append('date_joined')
|
||||
if hasattr(UserModel, 'is_staff'):
|
||||
extra_fields.append('is_staff')
|
||||
if hasattr(UserModel, 'public_profile'):
|
||||
extra_fields.append('public_profile')
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
|
||||
|
||||
model = UserModel
|
||||
fields = ('pk', *extra_fields)
|
||||
fields = ['pk', *extra_fields]
|
||||
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
|
||||
|
||||
def handle_public_profile_change(self, instance, validated_data):
|
||||
"""Remove user from `shared_with` if public profile is set to False."""
|
||||
"""
|
||||
Remove user from `shared_with` if public profile is set to False.
|
||||
"""
|
||||
if 'public_profile' in validated_data and not validated_data['public_profile']:
|
||||
for collection in Collection.objects.filter(shared_with=instance):
|
||||
collection.shared_with.remove(instance)
|
||||
|
@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class CustomUserDetailsSerializer(UserDetailsSerializer):
|
||||
"""
|
||||
Custom serializer to add additional fields and logic for the user details.
|
||||
"""
|
||||
|
||||
has_password = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(UserDetailsSerializer.Meta):
|
||||
model = CustomUser
|
||||
fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile')
|
||||
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',)
|
||||
fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password']
|
||||
read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password')
|
||||
|
||||
@staticmethod
|
||||
def get_has_password(instance):
|
||||
"""
|
||||
Computes whether the user has a usable password set.
|
||||
"""
|
||||
return instance.has_usable_password()
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Customizes the serialized output to modify `profile_pic` URL and add computed fields.
|
||||
"""
|
||||
representation = super().to_representation(instance)
|
||||
|
||||
# Construct profile picture URL if it exists
|
||||
if instance.profile_pic:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
#print(public_url)
|
||||
# remove any ' from the url
|
||||
public_url = public_url.replace("'", "")
|
||||
public_url = public_url.replace("'", "") # Sanitize URL
|
||||
representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}"
|
||||
del representation['pk'] # remove the pk field from the response
|
||||
|
||||
# Remove `pk` field from the response
|
||||
representation.pop('pk', None)
|
||||
|
||||
return representation
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from os import getenv
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
@ -9,6 +10,7 @@ from django.conf import settings
|
|||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib.auth import get_user_model
|
||||
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -121,3 +123,30 @@ class UpdateUserMetadataView(APIView):
|
|||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
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)
|
|
@ -1,9 +1,10 @@
|
|||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
import requests
|
||||
from worldtravel.models import Country, Region
|
||||
from worldtravel.models import Country, Region, City
|
||||
from django.db import transaction
|
||||
import json
|
||||
from tqdm import tqdm
|
||||
import ijson
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
@ -37,33 +38,54 @@ def saveCountryFlag(country_code):
|
|||
class Command(BaseCommand):
|
||||
help = 'Imports the world travel data'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json')
|
||||
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 add_arguments(self, parser):
|
||||
parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
|
||||
|
||||
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:
|
||||
with open(countries_json_path, 'w') as f:
|
||||
f.write(res.text)
|
||||
self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully'))
|
||||
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
|
||||
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:
|
||||
data = json.load(f)
|
||||
f = open(countries_json_path, 'rb')
|
||||
parser = ijson.items(f, 'item')
|
||||
|
||||
with transaction.atomic():
|
||||
existing_countries = {country.country_code: country for country in Country.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 = []
|
||||
regions_to_create = []
|
||||
countries_to_update = []
|
||||
regions_to_update = []
|
||||
cities_to_create = []
|
||||
cities_to_update = []
|
||||
|
||||
processed_country_codes = set()
|
||||
processed_region_ids = set()
|
||||
processed_city_ids = set()
|
||||
|
||||
for country in data:
|
||||
for country in parser:
|
||||
country_code = country['iso2']
|
||||
country_name = country['name']
|
||||
country_subregion = country['subregion']
|
||||
|
@ -93,7 +115,6 @@ class Command(BaseCommand):
|
|||
countries_to_create.append(country_obj)
|
||||
|
||||
saveCountryFlag(country_code)
|
||||
self.stdout.write(self.style.SUCCESS(f'Country {country_name} prepared'))
|
||||
|
||||
if 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
|
||||
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)
|
||||
|
||||
if state_id in existing_regions:
|
||||
|
@ -120,7 +146,40 @@ class Command(BaseCommand):
|
|||
latitude=latitude
|
||||
)
|
||||
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:
|
||||
state_id = f"{country_code}-00"
|
||||
processed_region_ids.add(state_id)
|
||||
|
@ -136,18 +195,35 @@ class Command(BaseCommand):
|
|||
country=country_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
|
||||
Country.objects.bulk_create(countries_to_create)
|
||||
Region.objects.bulk_create(regions_to_create)
|
||||
for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"):
|
||||
batch = regions_to_create[i:i + batch_size]
|
||||
Region.objects.bulk_create(batch)
|
||||
|
||||
# Bulk update existing countries and regions
|
||||
Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital'])
|
||||
Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude'])
|
||||
for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"):
|
||||
batch = cities_to_create[i:i + batch_size]
|
||||
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()
|
||||
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'))
|
27
backend/server/worldtravel/migrations/0012_city.py
Normal file
27
backend/server/worldtravel/migrations/0012_city.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
24
backend/server/worldtravel/migrations/0013_visitedcity.py
Normal file
24
backend/server/worldtravel/migrations/0013_visitedcity.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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'},
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -17,6 +17,7 @@ class Country(models.Model):
|
|||
capital = models.CharField(max_length=100, blank=True, null=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)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Country"
|
||||
|
@ -31,6 +32,21 @@ class Region(models.Model):
|
|||
country = models.ForeignKey(Country, 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)
|
||||
|
||||
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):
|
||||
return self.name
|
||||
|
@ -48,3 +64,20 @@ class VisitedRegion(models.Model):
|
|||
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
|
||||
raise ValidationError("Region already visited by user.")
|
||||
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"
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
from .models import Country, Region, VisitedRegion
|
||||
from .models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
|
||||
|
@ -33,10 +33,20 @@ class CountrySerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
num_cities = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude']
|
||||
read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities']
|
||||
|
||||
def get_num_cities(self, obj):
|
||||
return City.objects.filter(region=obj).count()
|
||||
|
||||
class CitySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = City
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude']
|
||||
|
||||
class VisitedRegionSerializer(CustomModelSerializer):
|
||||
longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
|
@ -47,3 +57,13 @@ class VisitedRegionSerializer(CustomModelSerializer):
|
|||
model = VisitedRegion
|
||||
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
|
||||
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
|
||||
|
||||
class VisitedCitySerializer(CustomModelSerializer):
|
||||
longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True)
|
||||
name = serializers.CharField(source='city.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VisitedCity
|
||||
fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
|
||||
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
|
|
@ -2,15 +2,17 @@
|
|||
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country
|
||||
|
||||
from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
|
||||
router = DefaultRouter()
|
||||
router.register(r'countries', CountryViewSet, basename='countries')
|
||||
router.register(r'regions', RegionViewSet, basename='regions')
|
||||
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
|
||||
router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('<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'),
|
||||
]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.shortcuts import render
|
||||
from .models import Country, Region, VisitedRegion
|
||||
from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer
|
||||
from .models import Country, Region, VisitedRegion, City, VisitedCity
|
||||
from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
@ -33,6 +33,23 @@ def visits_by_country(request, country_code):
|
|||
serializer = VisitedRegionSerializer(visits, many=True)
|
||||
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):
|
||||
queryset = Country.objects.all().order_by('name')
|
||||
serializer_class = CountrySerializer
|
||||
|
@ -94,3 +111,45 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
|
|||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def destroy(self, request, **kwargs):
|
||||
# delete by 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)
|
|
@ -92,6 +92,28 @@ export default defineConfig({
|
|||
text: "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",
|
||||
link: "/docs/configuration/updating",
|
||||
|
|
15
documentation/docs/configuration/social_auth.md
Normal file
15
documentation/docs/configuration/social_auth.md
Normal 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.
|
56
documentation/docs/configuration/social_auth/authentik.md
Normal file
56
documentation/docs/configuration/social_auth/authentik.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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.
|
44
documentation/docs/configuration/social_auth/github.md
Normal file
44
documentation/docs/configuration/social_auth/github.md
Normal 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
|
||||
|
||||

|
||||
|
||||
4. Save the configuration.
|
||||
|
||||
Users should now be able to log in to AdventureLog using their GitHub account, and link it to exisiting accounts.
|
7
documentation/docs/configuration/social_auth/oidc.md
Normal file
7
documentation/docs/configuration/social_auth/oidc.md
Normal 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.
|
|
@ -20,5 +20,5 @@ docker exec -it <container> bash
|
|||
Once you are in the container run the following command to resync the region data.
|
||||
|
||||
```bash
|
||||
python manage.py download-countries
|
||||
python manage.py download-countries --force
|
||||
```
|
||||
|
|
|
@ -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:
|
||||
|
||||
::: 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
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
|
||||
```
|
||||
|
|
BIN
documentation/public/authentik_settings.png
Normal file
BIN
documentation/public/authentik_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
BIN
documentation/public/github_settings.png
Normal file
BIN
documentation/public/github_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
1
frontend/src/app.d.ts
vendored
1
frontend/src/app.d.ts
vendored
|
@ -15,6 +15,7 @@ declare global {
|
|||
profile_pic: string | null;
|
||||
uuid: string;
|
||||
public_profile: boolean;
|
||||
has_password: boolean;
|
||||
} | null;
|
||||
locale: string;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks';
|
|||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
|
||||
export const authHook: Handle = async ({ event, resolve }) => {
|
||||
event.cookies.delete('csrftoken', { path: '/' });
|
||||
try {
|
||||
let sessionid = event.cookies.get('sessionid');
|
||||
|
||||
|
|
|
@ -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() {
|
||||
try {
|
||||
let res = await fetch(url);
|
||||
|
@ -241,39 +272,10 @@
|
|||
formData.append('image', file);
|
||||
formData.append('adventure', adventure.id);
|
||||
|
||||
let res2 = await fetch(`/adventures?/image`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
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'));
|
||||
await uploadImage(file);
|
||||
url = '';
|
||||
} else {
|
||||
addToast('error', $t('adventures.image_upload_error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchImage:', error);
|
||||
addToast('error', $t('adventures.image_upload_error'));
|
||||
} catch (e) {
|
||||
imageError = $t('adventures.image_fetch_failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -365,17 +367,33 @@
|
|||
async function markVisited() {
|
||||
console.log(reverseGeocodePlace);
|
||||
if (reverseGeocodePlace) {
|
||||
let res = await fetch(`/worldtravel?/markVisited`, {
|
||||
if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) {
|
||||
let region_res = await fetch(`/api/visitedregion`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ regionId: reverseGeocodePlace.id })
|
||||
body: JSON.stringify({ region: reverseGeocodePlace.region_id })
|
||||
});
|
||||
if (res.ok) {
|
||||
reverseGeocodePlace.is_visited = true;
|
||||
if (region_res.ok) {
|
||||
reverseGeocodePlace.region_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`);
|
||||
}
|
||||
}
|
||||
if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) {
|
||||
let city_res = await fetch(`/api/visitedcity`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: reverseGeocodePlace.city_id })
|
||||
});
|
||||
if (city_res.ok) {
|
||||
reverseGeocodePlace.city_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.city} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reverseGeocode(force_update: boolean = false) {
|
||||
|
@ -542,7 +560,10 @@
|
|||
addToast('error', $t('adventures.adventure_update_error'));
|
||||
}
|
||||
}
|
||||
if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
|
||||
if (
|
||||
adventure.is_visited &&
|
||||
(!reverseGeocodePlace?.region_visited || !reverseGeocodePlace?.city_visited)
|
||||
) {
|
||||
markVisited();
|
||||
}
|
||||
imageSearch = adventure.name;
|
||||
|
@ -785,19 +806,33 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
</MapLibre>
|
||||
{#if reverseGeocodePlace}
|
||||
<div class="mt-2">
|
||||
<p>{reverseGeocodePlace.region}, {reverseGeocodePlace.country}</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.not_visited')}
|
||||
</p>
|
||||
{#if reverseGeocodePlace.city}
|
||||
<p>
|
||||
{reverseGeocodePlace.city}:
|
||||
{reverseGeocodePlace.city_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !reverseGeocodePlace.is_visited && !willBeMarkedVisited}
|
||||
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
|
||||
<button type="button" class="btn btn-neutral" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/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">
|
||||
<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>
|
||||
</svg>
|
||||
<span
|
||||
>{reverseGeocodePlace.region},
|
||||
>{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
{$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}
|
||||
accept="image/*"
|
||||
id="image"
|
||||
multiple
|
||||
on:change={handleMultipleFiles}
|
||||
/>
|
||||
<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')}
|
||||
</button>
|
||||
</button> -->
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -30,8 +30,9 @@
|
|||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<p class="text-lg ml-4 font-bold">
|
||||
{$t('navbar.greeting')}, {user.first_name}
|
||||
{user.last_name}
|
||||
{$t('navbar.greeting')}, {user.first_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.username}
|
||||
</p>
|
||||
<li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li>
|
||||
<li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li>
|
||||
|
|
|
@ -86,7 +86,7 @@
|
|||
bind:value={new_category.icon}
|
||||
/>
|
||||
<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')}
|
||||
</button>
|
||||
<button on:click={custom_category} type="button" class="btn btn-primary">
|
||||
|
|
61
frontend/src/lib/components/CityCard.svelte
Normal file
61
frontend/src/lib/components/CityCard.svelte
Normal 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>
|
|
@ -4,54 +4,47 @@
|
|||
import type { Region, VisitedRegion } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let region: Region;
|
||||
export let visited: boolean;
|
||||
|
||||
export let visit_id: number | undefined | null;
|
||||
function goToCity() {
|
||||
console.log(region);
|
||||
goto(`/worldtravel/${region.id.split('-')[0]}/${region.id}`);
|
||||
}
|
||||
|
||||
async function markVisited() {
|
||||
let res = await fetch(`/worldtravel?/markVisited`, {
|
||||
let res = await fetch(`/api/visitedregion/`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ regionId: region.id })
|
||||
body: JSON.stringify({ region: region.id })
|
||||
});
|
||||
if (res.ok) {
|
||||
// visited = true;
|
||||
const result = await res.json();
|
||||
const data = JSON.parse(result.data);
|
||||
if (data[1] !== undefined) {
|
||||
console.log('New adventure created with id:', data[3]);
|
||||
let visit_id = data[3];
|
||||
let region_id = data[5];
|
||||
let user_id = data[4];
|
||||
|
||||
let newVisit: VisitedRegion = {
|
||||
id: visit_id,
|
||||
region: region_id,
|
||||
user_id: user_id,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
name: ''
|
||||
};
|
||||
addToast('success', `Visit to ${region.name} marked`);
|
||||
dispatch('visit', newVisit);
|
||||
}
|
||||
visited = true;
|
||||
let data = await res.json();
|
||||
addToast(
|
||||
'success',
|
||||
`${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}`
|
||||
);
|
||||
dispatch('visit', data);
|
||||
} else {
|
||||
console.error('Failed to mark region as visited');
|
||||
addToast('error', `Failed to mark visit to ${region.name}`);
|
||||
console.error($t('worldtravel.region_failed_visited'));
|
||||
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`);
|
||||
}
|
||||
}
|
||||
async function removeVisit() {
|
||||
let res = await fetch(`/worldtravel?/removeVisited`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ visitId: visit_id })
|
||||
let res = await fetch(`/api/visitedregion/${region.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
visited = false;
|
||||
addToast('info', `Visit to ${region.name} removed`);
|
||||
dispatch('remove', null);
|
||||
addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`);
|
||||
dispatch('remove', region);
|
||||
} 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>
|
||||
|
@ -61,14 +54,28 @@
|
|||
>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title overflow-ellipsis">{region.name}</h2>
|
||||
<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">
|
||||
<!-- <button class="btn btn-info" on:click={moreInfo}>More Info</button> -->
|
||||
{#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 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}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,7 @@ export type User = {
|
|||
profile_pic: string | null;
|
||||
uuid: string;
|
||||
public_profile: boolean;
|
||||
has_password: boolean;
|
||||
};
|
||||
|
||||
export type Adventure = {
|
||||
|
@ -56,16 +57,34 @@ export type Country = {
|
|||
};
|
||||
|
||||
export type Region = {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
country: number;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
num_cities: number;
|
||||
};
|
||||
|
||||
export type City = {
|
||||
id: string;
|
||||
name: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
region: string;
|
||||
};
|
||||
|
||||
export type VisitedRegion = {
|
||||
id: number;
|
||||
region: number;
|
||||
region: string;
|
||||
user_id: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VisitedCity = {
|
||||
id: number;
|
||||
city: string;
|
||||
user_id: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
|
@ -182,11 +201,14 @@ export type Background = {
|
|||
};
|
||||
|
||||
export type ReverseGeocode = {
|
||||
id: string;
|
||||
region_id: string;
|
||||
region: string;
|
||||
country: string;
|
||||
is_visited: boolean;
|
||||
region_visited: boolean;
|
||||
city_visited: boolean;
|
||||
display_name: string;
|
||||
city: string;
|
||||
city_id: string;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
|
|
|
@ -284,7 +284,8 @@
|
|||
"email_required": "E-Mail ist erforderlich",
|
||||
"both_passwords_required": "Beide Passwörter sind erforderlich",
|
||||
"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": {
|
||||
"no_users_found": "Keine Benutzer mit öffentlichen Profilen gefunden."
|
||||
|
@ -298,7 +299,20 @@
|
|||
"no_countries_found": "Keine Länder gefunden",
|
||||
"not_visited": "Nicht besucht",
|
||||
"num_countries": "Länder gefunden",
|
||||
"partially_visited": "Teilweise besucht"
|
||||
"partially_visited": "Teilweise besucht",
|
||||
"all_visited": "Sie haben alle Regionen in besucht",
|
||||
"cities": "Städte",
|
||||
"failed_to_mark_visit": "Der Besuch konnte nicht markiert werden",
|
||||
"failed_to_remove_visit": "Der Besuch von konnte nicht entfernt werden",
|
||||
"marked_visited": "als besucht markiert",
|
||||
"no_cities_found": "Keine Städte gefunden",
|
||||
"region_failed_visited": "Die Region konnte nicht als besucht markiert werden",
|
||||
"region_stats": "Regionsstatistiken",
|
||||
"regions_in": "Regionen in",
|
||||
"removed": "ENTFERNT",
|
||||
"view_cities": "Städte anzeigen",
|
||||
"visit_remove_failed": "Der Besuch konnte nicht entfernt werden",
|
||||
"visit_to": "Besuch bei"
|
||||
},
|
||||
"settings": {
|
||||
"account_settings": "Benutzerkontoeinstellungen",
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "CSRF-Token konnte nicht abgerufen werden",
|
||||
"duplicate_email": "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": {
|
||||
"add_item": "Artikel hinzufügen",
|
||||
|
|
|
@ -275,7 +275,20 @@
|
|||
"completely_visited": "Completely Visited",
|
||||
"all_subregions": "All Subregions",
|
||||
"clear_search": "Clear Search",
|
||||
"no_countries_found": "No countries found"
|
||||
"no_countries_found": "No countries found",
|
||||
"view_cities": "View Cities",
|
||||
"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": {
|
||||
"username": "Username",
|
||||
|
@ -295,7 +308,8 @@
|
|||
"email_required": "Email is required",
|
||||
"new_password": "New Password (6+ characters)",
|
||||
"both_passwords_required": "Both passwords are required",
|
||||
"reset_failed": "Failed to reset password"
|
||||
"reset_failed": "Failed to reset password",
|
||||
"or_3rd_party": "Or login with a third-party service"
|
||||
},
|
||||
"users": {
|
||||
"no_users_found": "No users found with public profiles."
|
||||
|
@ -306,6 +320,7 @@
|
|||
"settings_page": "Settings Page",
|
||||
"account_settings": "User Account Settings",
|
||||
"update": "Update",
|
||||
"no_verified_email_warning": "You must have a verified email address to enable two-factor authentication.",
|
||||
"password_change": "Change Password",
|
||||
"new_password": "New Password",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
|
@ -369,7 +384,16 @@
|
|||
"duplicate_email": "This email address is already in use.",
|
||||
"csrf_failed": "Failed to fetch CSRF token",
|
||||
"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_created": "Collection created successfully!",
|
||||
|
|
|
@ -275,7 +275,20 @@
|
|||
"not_visited": "No visitado",
|
||||
"num_countries": "países encontrados",
|
||||
"partially_visited": "Parcialmente visitado",
|
||||
"country_list": "Lista de países"
|
||||
"country_list": "Lista de países",
|
||||
"all_visited": "Has visitado todas las regiones en",
|
||||
"cities": "ciudades",
|
||||
"failed_to_mark_visit": "No se pudo marcar la visita a",
|
||||
"failed_to_remove_visit": "No se pudo eliminar la visita a",
|
||||
"marked_visited": "marcado como visitado",
|
||||
"no_cities_found": "No se encontraron ciudades",
|
||||
"region_failed_visited": "No se pudo marcar la región como visitada",
|
||||
"region_stats": "Estadísticas de la región",
|
||||
"regions_in": "Regiones en",
|
||||
"removed": "remoto",
|
||||
"view_cities": "Ver ciudades",
|
||||
"visit_remove_failed": "No se pudo eliminar la visita",
|
||||
"visit_to": "Visita a"
|
||||
},
|
||||
"auth": {
|
||||
"forgot_password": "¿Has olvidado tu contraseña?",
|
||||
|
@ -295,7 +308,8 @@
|
|||
"email_required": "Se requiere correo electrónico",
|
||||
"both_passwords_required": "Se requieren ambas contraseñas",
|
||||
"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": {
|
||||
"no_users_found": "No se encontraron usuarios con perfiles públicos."
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "No se pudo recuperar el token CSRF",
|
||||
"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.",
|
||||
"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": {
|
||||
"add_item": "Agregar artículo",
|
||||
|
|
|
@ -284,7 +284,8 @@
|
|||
"email_required": "L'e-mail est requis",
|
||||
"both_passwords_required": "Les deux mots de passe sont requis",
|
||||
"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": {
|
||||
"no_users_found": "Aucun utilisateur trouvé avec des profils publics."
|
||||
|
@ -298,7 +299,20 @@
|
|||
"no_countries_found": "Aucun pays trouvé",
|
||||
"not_visited": "Non visité",
|
||||
"num_countries": "pays trouvés",
|
||||
"partially_visited": "Partiellement visité"
|
||||
"partially_visited": "Partiellement visité",
|
||||
"all_visited": "Vous avez visité toutes les régions de",
|
||||
"cities": "villes",
|
||||
"failed_to_mark_visit": "Échec de la notation de la visite à",
|
||||
"failed_to_remove_visit": "Échec de la suppression de la visite à",
|
||||
"marked_visited": "marqué comme visité",
|
||||
"no_cities_found": "Aucune ville trouvée",
|
||||
"region_failed_visited": "Échec du marquage de la région comme visitée",
|
||||
"region_stats": "Statistiques de la région",
|
||||
"regions_in": "Régions dans",
|
||||
"removed": "supprimé",
|
||||
"view_cities": "Voir les villes",
|
||||
"visit_remove_failed": "Échec de la suppression de la visite",
|
||||
"visit_to": "Visite à"
|
||||
},
|
||||
"settings": {
|
||||
"account_settings": "Paramètres du compte utilisateur",
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "Échec de la récupération du jeton CSRF",
|
||||
"duplicate_email": "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": {
|
||||
"add_item": "Ajouter un article",
|
||||
|
|
|
@ -284,7 +284,8 @@
|
|||
"email_required": "L'e-mail è obbligatoria",
|
||||
"both_passwords_required": "Sono necessarie entrambe le password",
|
||||
"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": {
|
||||
"no_users_found": "Nessun utente trovato con profili pubblici."
|
||||
|
@ -298,7 +299,20 @@
|
|||
"no_countries_found": "Nessun paese trovato",
|
||||
"not_visited": "Non visitato",
|
||||
"num_countries": "paesi trovati",
|
||||
"partially_visited": "Parzialmente visitato"
|
||||
"partially_visited": "Parzialmente visitato",
|
||||
"all_visited": "Hai visitato tutte le regioni in",
|
||||
"cities": "città",
|
||||
"failed_to_mark_visit": "Impossibile contrassegnare la visita a",
|
||||
"failed_to_remove_visit": "Impossibile rimuovere la visita a",
|
||||
"marked_visited": "contrassegnato come visitato",
|
||||
"no_cities_found": "Nessuna città trovata",
|
||||
"region_failed_visited": "Impossibile contrassegnare la regione come visitata",
|
||||
"region_stats": "Statistiche della regione",
|
||||
"regions_in": "Regioni dentro",
|
||||
"removed": "RIMOSSO",
|
||||
"view_cities": "Visualizza città",
|
||||
"visit_remove_failed": "Impossibile rimuovere la visita",
|
||||
"visit_to": "Visita a"
|
||||
},
|
||||
"settings": {
|
||||
"account_settings": "Impostazioni dell'account utente",
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "Impossibile recuperare il token CSRF",
|
||||
"duplicate_email": "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": {
|
||||
"add_item": "Aggiungi articolo",
|
||||
|
|
|
@ -284,7 +284,8 @@
|
|||
"email_required": "E-mail is vereist",
|
||||
"both_passwords_required": "Beide wachtwoorden zijn vereist",
|
||||
"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": {
|
||||
"no_users_found": "Er zijn geen gebruikers gevonden met openbare profielen."
|
||||
|
@ -298,7 +299,20 @@
|
|||
"no_countries_found": "Geen landen gevonden",
|
||||
"not_visited": "Niet bezocht",
|
||||
"num_countries": "landen gevonden",
|
||||
"partially_visited": "Gedeeltelijk bezocht"
|
||||
"partially_visited": "Gedeeltelijk bezocht",
|
||||
"all_visited": "Je hebt alle regio's in bezocht",
|
||||
"cities": "steden",
|
||||
"failed_to_mark_visit": "Kan bezoek aan niet markeren",
|
||||
"failed_to_remove_visit": "Kan bezoek aan niet verwijderen",
|
||||
"marked_visited": "gemarkeerd als bezocht",
|
||||
"no_cities_found": "Geen steden gevonden",
|
||||
"region_failed_visited": "Kan de regio niet als bezocht markeren",
|
||||
"region_stats": "Regiostatistieken",
|
||||
"regions_in": "Regio's binnen",
|
||||
"removed": "VERWIJDERD",
|
||||
"view_cities": "Steden bekijken",
|
||||
"visit_remove_failed": "Kan bezoek niet verwijderen",
|
||||
"visit_to": "Bezoek aan"
|
||||
},
|
||||
"settings": {
|
||||
"account_settings": "Gebruikersaccount instellingen",
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "Kan CSRF-token niet ophalen",
|
||||
"duplicate_email": "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": {
|
||||
"add_item": "Artikel toevoegen",
|
||||
|
|
|
@ -275,7 +275,20 @@
|
|||
"completely_visited": "Całkowicie odwiedzone",
|
||||
"all_subregions": "Wszystkie podregiony",
|
||||
"clear_search": "Wyczyść wyszukiwanie",
|
||||
"no_countries_found": "Nie znaleziono krajów"
|
||||
"no_countries_found": "Nie znaleziono krajów",
|
||||
"all_visited": "Odwiedziłeś wszystkie regiony w",
|
||||
"cities": "miasta",
|
||||
"failed_to_mark_visit": "Nie udało się oznaczyć wizyty w",
|
||||
"failed_to_remove_visit": "Nie udało się usunąć wizyty w",
|
||||
"marked_visited": "oznaczone jako odwiedzone",
|
||||
"no_cities_found": "Nie znaleziono żadnych miast",
|
||||
"region_failed_visited": "Nie udało się oznaczyć regionu jako odwiedzony",
|
||||
"region_stats": "Statystyki regionu",
|
||||
"regions_in": "Regiony w",
|
||||
"removed": "REMOVED",
|
||||
"view_cities": "Zobacz Miasta",
|
||||
"visit_remove_failed": "Nie udało się usunąć wizyty",
|
||||
"visit_to": "Wizyta w"
|
||||
},
|
||||
"auth": {
|
||||
"username": "Nazwa użytkownika",
|
||||
|
@ -295,7 +308,8 @@
|
|||
"email_required": "Adres e-mail jest wymagany",
|
||||
"both_passwords_required": "Obydwa hasła są wymagane",
|
||||
"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": {
|
||||
"no_users_found": "Nie znaleziono użytkowników z publicznymi profilami."
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "Nie udało się pobrać tokena CSRF",
|
||||
"duplicate_email": "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_created": "Kolekcja została pomyślnie utworzona!",
|
||||
|
|
|
@ -275,7 +275,20 @@
|
|||
"no_countries_found": "Inga länder hittades",
|
||||
"not_visited": "Ej besökta",
|
||||
"num_countries": "länder hittades",
|
||||
"partially_visited": "Delvis besökta"
|
||||
"partially_visited": "Delvis besökta",
|
||||
"all_visited": "Du har besökt alla regioner i",
|
||||
"cities": "städer",
|
||||
"failed_to_mark_visit": "Det gick inte att markera besök till",
|
||||
"failed_to_remove_visit": "Det gick inte att ta bort besök på",
|
||||
"marked_visited": "markerad som besökt",
|
||||
"no_cities_found": "Inga städer hittades",
|
||||
"region_failed_visited": "Det gick inte att markera regionen som besökt",
|
||||
"region_stats": "Regionstatistik",
|
||||
"regions_in": "Regioner i",
|
||||
"removed": "tas bort",
|
||||
"view_cities": "Visa städer",
|
||||
"visit_remove_failed": "Det gick inte att ta bort besöket",
|
||||
"visit_to": "Besök till"
|
||||
},
|
||||
"auth": {
|
||||
"confirm_password": "Bekräfta lösenord",
|
||||
|
@ -295,7 +308,8 @@
|
|||
"email_required": "E-post krävs",
|
||||
"both_passwords_required": "Båda lösenorden krävs",
|
||||
"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": {
|
||||
"no_users_found": "Inga användare hittades med offentliga profiler."
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "Det gick inte att hämta CSRF-token",
|
||||
"duplicate_email": "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": {
|
||||
"add_item": "Lägg till objekt",
|
||||
|
|
|
@ -284,7 +284,8 @@
|
|||
"email_required": "电子邮件为必填项",
|
||||
"both_passwords_required": "两个密码都需要",
|
||||
"new_password": "新密码",
|
||||
"reset_failed": "重置密码失败"
|
||||
"reset_failed": "重置密码失败",
|
||||
"or_3rd_party": "或者使用第三方服务登录"
|
||||
},
|
||||
"worldtravel": {
|
||||
"all": "全部",
|
||||
|
@ -295,7 +296,20 @@
|
|||
"no_countries_found": "没有找到国家",
|
||||
"not_visited": "未访问过",
|
||||
"num_countries": "找到的国家",
|
||||
"partially_visited": "部分访问"
|
||||
"partially_visited": "部分访问",
|
||||
"all_visited": "您已访问过所有地区",
|
||||
"cities": "城市",
|
||||
"failed_to_mark_visit": "无法标记访问",
|
||||
"failed_to_remove_visit": "无法删除对的访问",
|
||||
"marked_visited": "标记为已访问",
|
||||
"no_cities_found": "没有找到城市",
|
||||
"region_failed_visited": "无法将区域标记为已访问",
|
||||
"region_stats": "地区统计",
|
||||
"regions_in": "地区位于",
|
||||
"removed": "已删除",
|
||||
"view_cities": "查看城市",
|
||||
"visit_remove_failed": "删除访问失败",
|
||||
"visit_to": "参观"
|
||||
},
|
||||
"users": {
|
||||
"no_users_found": "未找到具有公开个人资料的用户。"
|
||||
|
@ -369,7 +383,17 @@
|
|||
"csrf_failed": "获取 CSRF 令牌失败",
|
||||
"duplicate_email": "该电子邮件地址已被使用。",
|
||||
"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": {
|
||||
"add_item": "添加项目",
|
||||
|
|
|
@ -53,18 +53,25 @@ async function handleRequest(
|
|||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
// Delete existing csrf cookie by setting an expired date
|
||||
cookies.delete('csrftoken', { path: '/' });
|
||||
|
||||
// Generate a new csrf token (using your existing fetchCSRFToken function)
|
||||
const csrfToken = await fetchCSRFToken();
|
||||
if (!csrfToken) {
|
||||
return json({ error: 'CSRF token is missing or invalid' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Set the new csrf token in both headers and cookies
|
||||
const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`;
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
...Object.fromEntries(headers),
|
||||
'X-CSRFToken': csrfToken,
|
||||
Cookie: `csrftoken=${csrfToken}`
|
||||
Cookie: cookieHeader
|
||||
},
|
||||
body:
|
||||
request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,
|
||||
|
|
|
@ -12,7 +12,7 @@ export async function GET(event) {
|
|||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ url, params, request, fetch, cookies }) {
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
const searchParam = url.search ? `${url.search}` : '';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
|
||||
}
|
||||
|
||||
|
@ -53,18 +53,25 @@ async function handleRequest(
|
|||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
// Delete existing csrf cookie by setting an expired date
|
||||
cookies.delete('csrftoken', { path: '/' });
|
||||
|
||||
// Generate a new csrf token (using your existing fetchCSRFToken function)
|
||||
const csrfToken = await fetchCSRFToken();
|
||||
if (!csrfToken) {
|
||||
return json({ error: 'CSRF token is missing or invalid' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Set the new csrf token in both headers and cookies
|
||||
const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`;
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: {
|
||||
...Object.fromEntries(headers),
|
||||
'X-CSRFToken': csrfToken,
|
||||
Cookie: `csrftoken=${csrfToken}`
|
||||
Cookie: cookieHeader
|
||||
},
|
||||
body:
|
||||
request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,
|
||||
|
|
|
@ -1,69 +1,84 @@
|
|||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function GET({ url, params, request, fetch, cookies }) {
|
||||
// add the param format = json to the url or add additional if anothre param is already present
|
||||
if (url.search) {
|
||||
url.search = url.search + '&format=json';
|
||||
} else {
|
||||
url.search = '?format=json';
|
||||
}
|
||||
return handleRequest(url, params, request, fetch, cookies);
|
||||
export async function GET(event) {
|
||||
const { url, params, request, fetch, cookies } = event;
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
|
||||
}
|
||||
|
||||
export async function PATCH({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
|
||||
}
|
||||
|
||||
export async function PUT({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
|
||||
}
|
||||
|
||||
export async function DELETE({ url, params, request, fetch, cookies }) {
|
||||
return handleRequest(url, params, request, fetch, cookies, true);
|
||||
const searchParam = url.search ? `${url.search}&format=json` : '?format=json';
|
||||
return handleRequest(url, params, request, fetch, cookies, searchParam, true);
|
||||
}
|
||||
|
||||
// Implement other HTTP methods as needed (PUT, DELETE, etc.)
|
||||
|
||||
async function handleRequest(
|
||||
url: any,
|
||||
params: any,
|
||||
request: any,
|
||||
fetch: any,
|
||||
cookies: any,
|
||||
searchParam: string,
|
||||
requreTrailingSlash: boolean | undefined = false
|
||||
) {
|
||||
const path = params.path;
|
||||
let targetUrl = `${endpoint}/auth/${path}${url.search}`;
|
||||
let targetUrl = `${endpoint}/auth/${path}`;
|
||||
|
||||
// Ensure the path ends with a trailing slash
|
||||
if (requreTrailingSlash && !targetUrl.endsWith('/')) {
|
||||
targetUrl += '/';
|
||||
}
|
||||
|
||||
// Append query parameters to the path correctly
|
||||
targetUrl += searchParam; // This will add ?format=json or &format=json to the URL
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
const authCookie = cookies.get('auth');
|
||||
// Delete existing csrf cookie by setting an expired date
|
||||
cookies.delete('csrftoken', { path: '/' });
|
||||
|
||||
if (authCookie) {
|
||||
headers.set('Cookie', `${authCookie}`);
|
||||
// Generate a new csrf token (using your existing fetchCSRFToken function)
|
||||
const csrfToken = await fetchCSRFToken();
|
||||
if (!csrfToken) {
|
||||
return json({ error: 'CSRF token is missing or invalid' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Set the new csrf token in both headers and cookies
|
||||
const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`;
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined
|
||||
headers: {
|
||||
...Object.fromEntries(headers),
|
||||
'X-CSRFToken': csrfToken,
|
||||
Cookie: cookieHeader
|
||||
},
|
||||
body:
|
||||
request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,
|
||||
credentials: 'include' // This line ensures cookies are sent with the request
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
// For 204 No Content, return a response with no body
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: response.headers
|
||||
|
@ -71,10 +86,13 @@ async function handleRequest(
|
|||
}
|
||||
|
||||
const responseData = await response.text();
|
||||
// Create a new Headers object without the 'set-cookie' header
|
||||
const cleanHeaders = new Headers(response.headers);
|
||||
cleanHeaders.delete('set-cookie');
|
||||
|
||||
return new Response(responseData, {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
headers: cleanHeaders
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error forwarding request:', error);
|
||||
|
|
|
@ -17,7 +17,11 @@
|
|||
<div class="container mx-auto p-4">
|
||||
<!-- Welcome Message -->
|
||||
<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>
|
||||
|
||||
<!-- Stats -->
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { Actions, PageServerLoad, RouteParams } from './$types';
|
|||
import { getRandomBackground, getRandomQuote } from '$lib';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (event.locals.user) {
|
||||
|
@ -12,10 +13,17 @@ export const load: PageServerLoad = async (event) => {
|
|||
const quote = getRandomQuote();
|
||||
const background = getRandomBackground();
|
||||
|
||||
let socialProviderFetch = await event.fetch(`${serverEndpoint}/auth/social-providers/`);
|
||||
if (!socialProviderFetch.ok) {
|
||||
return fail(500, { message: 'settings.social_providers_error' });
|
||||
}
|
||||
let socialProviders = await socialProviderFetch.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
quote,
|
||||
background
|
||||
background,
|
||||
socialProviders
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,14 +9,19 @@
|
|||
|
||||
let isImageInfoModalOpen: boolean = false;
|
||||
|
||||
let socialProviders = data.props?.socialProviders ?? [];
|
||||
|
||||
import GitHub from '~icons/mdi/github';
|
||||
import OpenIdConnect from '~icons/mdi/openid';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import ImageInfoModal from '$lib/components/ImageInfoModal.svelte';
|
||||
import type { Background } from '$lib/types.js';
|
||||
|
||||
let quote: { quote: string; author: string } = data.props.quote;
|
||||
let quote: { quote: string; author: string } = data.props?.quote ?? { quote: '', author: '' };
|
||||
|
||||
let background: Background = data.props.background;
|
||||
let background: Background = data.props?.background ?? { url: '' };
|
||||
</script>
|
||||
|
||||
{#if isImageInfoModalOpen}
|
||||
|
@ -62,6 +67,22 @@
|
|||
{/if}
|
||||
<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">
|
||||
<p><a href="/signup" class="underline">{$t('auth.signup')}</a></p>
|
||||
<p>
|
||||
|
|
|
@ -66,12 +66,22 @@ export const load: PageServerLoad = async (event) => {
|
|||
immichIntegration = await immichIntegrationsFetch.json();
|
||||
}
|
||||
|
||||
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
||||
let publicUrl = '';
|
||||
if (!publicUrlFetch.ok) {
|
||||
return redirect(302, '/');
|
||||
} else {
|
||||
let publicUrlJson = await publicUrlFetch.json();
|
||||
publicUrl = publicUrlJson.PUBLIC_URL;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
user,
|
||||
emails,
|
||||
authenticators,
|
||||
immichIntegration
|
||||
immichIntegration,
|
||||
publicUrl
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -179,17 +189,23 @@ export const actions: Actions = {
|
|||
|
||||
const password1 = formData.get('password1') as string | null | undefined;
|
||||
const password2 = formData.get('password2') as string | null | undefined;
|
||||
const current_password = formData.get('current_password') as string | null | undefined;
|
||||
let current_password = formData.get('current_password') as string | null | undefined;
|
||||
|
||||
if (password1 !== password2) {
|
||||
return fail(400, { message: 'settings.password_does_not_match' });
|
||||
}
|
||||
|
||||
if (!current_password) {
|
||||
return fail(400, { message: 'settings.password_is_required' });
|
||||
current_password = null;
|
||||
}
|
||||
|
||||
if (password1 && password1?.length < 6) {
|
||||
return fail(400, { message: 'settings.password_too_short' });
|
||||
}
|
||||
|
||||
let csrfToken = await fetchCSRFToken();
|
||||
|
||||
if (current_password) {
|
||||
let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -206,6 +222,24 @@ export const actions: Actions = {
|
|||
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 };
|
||||
}
|
||||
},
|
||||
changeEmail: async (event) => {
|
||||
if (!event.locals.user) {
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import TotpModal from '$lib/components/TOTPModal.svelte';
|
||||
import { appTitle, appVersion } from '$lib/config.js';
|
||||
import ImmichLogo from '$lib/assets/immich.svg';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let data;
|
||||
console.log(data);
|
||||
|
@ -20,7 +21,7 @@
|
|||
}
|
||||
|
||||
let new_email: string = '';
|
||||
|
||||
let public_url: string = data.props.publicUrl;
|
||||
let immichIntegration = data.props.immichIntegration;
|
||||
|
||||
let newImmichIntegration: ImmichIntegration = {
|
||||
|
@ -307,6 +308,7 @@
|
|||
</h2>
|
||||
<div class="bg-neutral p-6 rounded-lg shadow-md">
|
||||
<form method="post" action="?/changePassword" use:enhance class="space-y-6">
|
||||
{#if user.has_password}
|
||||
<div>
|
||||
<label for="current_password" class="text-sm font-medium text-neutral-content"
|
||||
>{$t('settings.current_password')}</label
|
||||
|
@ -318,6 +320,7 @@
|
|||
class="block w-full mt-1 input input-bordered input-primary"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
{#if $page.form?.message}
|
||||
<div class="alert alert-warning">
|
||||
{$t($page.form?.message)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="tooltip tooltip-warning"
|
||||
|
@ -391,7 +399,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<form class="mt-4" on:submit={addEmail}>
|
||||
<form class="mt-4" on:submit|preventDefault={addEmail}>
|
||||
<input
|
||||
type="email"
|
||||
id="new_email"
|
||||
|
@ -400,7 +408,7 @@
|
|||
placeholder={$t('settings.new_email')}
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -413,9 +421,15 @@
|
|||
<div class="bg-neutral p-6 rounded-lg shadow-md text-center">
|
||||
{#if !data.props.authenticators}
|
||||
<p class="text-neutral-content">{$t('settings.mfa_not_enabled')}</p>
|
||||
{#if !emails.some((e) => e.verified)}
|
||||
<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}
|
||||
<button class="btn btn-warning mt-4" on:click={disableMfa}
|
||||
>{$t('settings.disable_mfa')}</button
|
||||
|
@ -424,6 +438,58 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="text-2xl font-semibold text-center mt-8">
|
||||
|
|
|
@ -30,80 +30,3 @@ export const load = (async (event) => {
|
|||
}
|
||||
}
|
||||
}) satisfies PageServerLoad;
|
||||
|
||||
export const actions: Actions = {
|
||||
markVisited: async (event) => {
|
||||
const body = await event.request.json();
|
||||
|
||||
if (!body || !body.regionId) {
|
||||
return {
|
||||
status: 400
|
||||
};
|
||||
}
|
||||
|
||||
let sessionId = event.cookies.get('sessionid');
|
||||
|
||||
if (!event.locals.user || !sessionId) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
let csrfToken = await fetchCSRFToken();
|
||||
|
||||
const res = await fetch(`${endpoint}/api/visitedregion/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ region: body.regionId })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to mark country as visited');
|
||||
return { status: 500 };
|
||||
} else {
|
||||
return {
|
||||
status: 200,
|
||||
data: await res.json()
|
||||
};
|
||||
}
|
||||
},
|
||||
removeVisited: async (event) => {
|
||||
const body = await event.request.json();
|
||||
|
||||
if (!body || !body.visitId) {
|
||||
return {
|
||||
status: 400
|
||||
};
|
||||
}
|
||||
|
||||
const visitId = body.visitId as number;
|
||||
|
||||
let sessionId = event.cookies.get('sessionid');
|
||||
|
||||
if (!event.locals.user || !sessionId) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
let csrfToken = await fetchCSRFToken();
|
||||
|
||||
const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
console.error('Failed to remove country from visited');
|
||||
return { status: 500 };
|
||||
} else {
|
||||
return {
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
visitedRegions = visitedRegions.filter(
|
||||
(visitedRegion) => visitedRegion.region !== region.id
|
||||
);
|
||||
removeVisit(region, visitedRegion.id);
|
||||
removeVisit(region);
|
||||
} else {
|
||||
markVisited(region);
|
||||
}
|
||||
|
@ -32,48 +32,35 @@
|
|||
}
|
||||
|
||||
async function markVisited(region: Region) {
|
||||
let res = await fetch(`/worldtravel?/markVisited`, {
|
||||
let res = await fetch(`/api/visitedregion/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ regionId: region.id })
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ region: region.id })
|
||||
});
|
||||
if (res.ok) {
|
||||
// visited = true;
|
||||
const result = await res.json();
|
||||
const data = JSON.parse(result.data);
|
||||
if (data[1] !== undefined) {
|
||||
console.log('New adventure created with id:', data[3]);
|
||||
let visit_id = data[3];
|
||||
let region_id = data[5];
|
||||
let user_id = data[4];
|
||||
|
||||
visitedRegions = [
|
||||
...visitedRegions,
|
||||
{
|
||||
id: visit_id,
|
||||
region: region_id,
|
||||
user_id: user_id,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
name: ''
|
||||
}
|
||||
];
|
||||
|
||||
addToast('success', `Visit to ${region.name} marked`);
|
||||
}
|
||||
} else {
|
||||
if (!res.ok) {
|
||||
console.error('Failed to mark region as visited');
|
||||
addToast('error', `Failed to mark visit to ${region.name}`);
|
||||
}
|
||||
}
|
||||
async function removeVisit(region: Region, visitId: number) {
|
||||
let res = await fetch(`/worldtravel?/removeVisited`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ visitId: visitId })
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('info', `Visit to ${region.name} removed`);
|
||||
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`);
|
||||
return;
|
||||
} else {
|
||||
console.error('Failed to remove visit');
|
||||
visitedRegions = [...visitedRegions, await res.json()];
|
||||
addToast(
|
||||
'success',
|
||||
`${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.marked_visited')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
async function removeVisit(region: Region) {
|
||||
let res = await fetch(`/api/visitedregion/${region.id}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error($t('worldtravel.region_failed_visited'));
|
||||
addToast('error', `${$t('worldtravel.failed_to_mark_visit')} ${region.name}`);
|
||||
return;
|
||||
} else {
|
||||
visitedRegions = visitedRegions.filter((visitedRegion) => visitedRegion.region !== region.id);
|
||||
addToast('info', `${$t('worldtravel.visit_to')} ${region.name} ${$t('worldtravel.removed')}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,16 +73,16 @@
|
|||
);
|
||||
</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="stats shadow bg-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Region Stats</div>
|
||||
<div class="stat-value">{numVisitedRegions}/{numRegions} Visited</div>
|
||||
<div class="stat-title">{$t('worldtravel.region_stats')}</div>
|
||||
<div class="stat-value">{numVisitedRegions}/{numRegions} {$t('adventures.visited')}</div>
|
||||
{#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}
|
||||
<div class="stat-desc">Keep exploring!</div>
|
||||
<div class="stat-desc">{$t('adventures.keep_exploring')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -110,8 +97,12 @@
|
|||
visitedRegions = [...visitedRegions, e.detail];
|
||||
numVisitedRegions++;
|
||||
}}
|
||||
visit_id={visitedRegions.find((visitedRegion) => visitedRegion.region === region.id)?.id}
|
||||
on:remove={() => numVisitedRegions--}
|
||||
on:remove={() => {
|
||||
visitedRegions = visitedRegions.filter(
|
||||
(visitedRegion) => visitedRegion.region !== region.id
|
||||
);
|
||||
numVisitedRegions--;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
67
frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts
Normal file
67
frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts
Normal 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;
|
223
frontend/src/routes/worldtravel/[id]/[id]/+page.svelte
Normal file
223
frontend/src/routes/worldtravel/[id]/[id]/+page.svelte
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue