diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ca0f2f6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @seanmorley15 \ No newline at end of file diff --git a/.github/workflows/cdn-beta.yml b/.github/workflows/cdn-beta.yml new file mode 100644 index 0000000..d5c2c85 --- /dev/null +++ b/.github/workflows/cdn-beta.yml @@ -0,0 +1,46 @@ +name: Upload beta CDN image to GHCR and Docker Hub + +on: + push: + branches: + - development + paths: + - "cdn/**" + +env: + IMAGE_NAME: "adventurelog-cdn" + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.ACCESS_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: set lower case owner name + run: | + echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} + env: + OWNER: "${{ github.repository_owner }}" + + - name: Build Docker images + run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:beta -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:beta ./cdn diff --git a/.github/workflows/cdn-latest.yml b/.github/workflows/cdn-latest.yml new file mode 100644 index 0000000..376ede9 --- /dev/null +++ b/.github/workflows/cdn-latest.yml @@ -0,0 +1,46 @@ +name: Upload latest CDN image to GHCR and Docker Hub + +on: + push: + branches: + - main + paths: + - "cdn/**" + +env: + IMAGE_NAME: "adventurelog-cdn" + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.ACCESS_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: set lower case owner name + run: | + echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} + env: + OWNER: "${{ github.repository_owner }}" + + - name: Build Docker images + run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:latest -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:latest ./cdn diff --git a/.github/workflows/cdn-release.yml b/.github/workflows/cdn-release.yml new file mode 100644 index 0000000..2bba9af --- /dev/null +++ b/.github/workflows/cdn-release.yml @@ -0,0 +1,43 @@ +name: Upload the tagged release CDN image to GHCR and Docker Hub + +on: + release: + types: [released] + +env: + IMAGE_NAME: "adventurelog-cdn" + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.ACCESS_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: set lower case owner name + run: | + echo "REPO_OWNER=${OWNER,,}" >>${GITHUB_ENV} + env: + OWNER: "${{ github.repository_owner }}" + + - name: Build Docker images + run: docker buildx build --platform linux/amd64,linux/arm64 --push -t ghcr.io/$REPO_OWNER/$IMAGE_NAME:${{ github.event.release.tag_name }} -t ${{ secrets.DOCKERHUB_USERNAME }}/$IMAGE_NAME:${{ github.event.release.tag_name }} ./cdn diff --git a/.gitignore b/.gitignore index fd9d0df..314a123 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Ignore everything in the .venv folder .venv/ .vscode/settings.json +.pnpm-store/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8777355 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "lokalise.i18n-ally", + "svelte.svelte-vscode" + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c980b88..7b49b3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,91 +1,50 @@ # Contributing to AdventureLog -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. - -Please note we have a code of conduct, please follow it in all your interactions with the project. +We’re excited to have you contribute to AdventureLog! To ensure that this community remains welcoming and productive for all users and developers, please follow this simple Code of Conduct. ## Pull Request Process -1. Please make sure you create an issue first for your change so you can link any pull requests to this issue. There should be a clear relationship between pull requests and issues. -2. Update the README.md with details of changes to the interface, this includes new environment - variables, exposed ports, useful file locations and container parameters. -3. Increase the version numbers in any examples files and the README.md to the new version that this - Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). -4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you - do not have permission to do that, you may request the second reviewer to merge it for you. +1. **Open an Issue First**: Discuss any changes or features you plan to implement by opening an issue. This helps to clarify your idea and ensures there’s a shared understanding. +2. **Document Changes**: If your changes impact the user interface, add new environment variables, or introduce new container configurations, make sure to update the documentation accordingly. The documentation is located in the `documentation` folder. +3. **Pull Request**: Submit a pull request with your changes. Make sure to reference the issue you opened in the description. ## Code of Conduct ### Our Pledge -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. +At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation. We encourage diverse participation and strive to maintain a space where everyone feels welcome to contribute, regardless of their background or experience level. We ask that you contribute with respect and kindness, making sure to prioritize collaboration and mutual growth. ### Our Standards -Examples of behavior that contributes to creating a positive environment -include: +In order to maintain a positive environment, we encourage the following behaviors: -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members +- **Inclusivity**: Use welcoming and inclusive language that fosters collaboration across all perspectives and experiences. +- **Respect**: Respect differing opinions and engage with empathy, understanding that each person’s perspective is valuable. +- **Constructive Feedback**: Offer feedback that helps improve the project and allows contributors to grow from it. +- **Adventure Spirit**: Bring the same sense of curiosity, discovery, and positivity that drives AdventureLog into all interactions with the community. -Examples of unacceptable behavior by participants include: +Examples of unacceptable behavior include: -- The use of sexualized language or imagery and unwelcome sexual attention or - advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or electronic - address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- Personal attacks, trolling, or any form of harassment. +- Insensitive or discriminatory language, including sexualized comments or imagery. +- Spamming or misusing project spaces for personal gain. +- Publishing or using others’ private information without permission. +- Anything else that could be seen as disrespectful or unprofessional in a collaborative environment. ### Our Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +As maintainers of AdventureLog, we are committed to enforcing this Code of Conduct and taking corrective action when necessary. This may involve moderating comments, pulling code, or banning users who engage in harmful behaviors. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +We strive to foster a community that balances open collaboration with respect for all contributors. ### Scope -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies in all spaces related to AdventureLog. This includes our GitHub repository, discussions, documentation, social media accounts, and events—both online and in person. ### Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at [INSERT EMAIL ADDRESS]. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +If you experience or witness unacceptable behavior, please report it to the project team at `contact@adventurelog.app`. All reports will be confidential and handled swiftly. The maintainers will investigate the issue and take appropriate action as needed. ### Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +This Code of Conduct is inspired by the [Contributor Covenant](http://contributor-covenant.org), version 1.4, and adapted to fit the unique spirit of AdventureLog. diff --git a/README.md b/README.md index 3724be9..3b48223 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,150 @@ -# AdventureLog: Embark, Explore, Remember. 🌍 +
-### _"Never forget an adventure with AdventureLog - Your ultimate travel companion!"_ + logo +

AdventureLog

+ +

+ The ultimate travel companion for the modern-day explorer. +

+ +

+ View Demo + · + Documentation + · + Discord + · + Support 💖 +

+
-[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15) +
-- **[Documentation](https://adventurelog.app)** -- **[Demo](https://demo.adventurelog.app)** -- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)** + # 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 + -# 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/). + -## 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: +
+ Adventures +

Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.

+ Adventure Details +

Shows specific details about an adventure, including the name, date, location, description, and rating.

+ Edit Modal + Adventure Details +

View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map

+ Dashboard +

Displays a summary of your adventures, including your world travel stats.

+ Itinerary +

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.

+ Countries +

Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.

+ Regions +

Displays the regions for a specific country, includes a map view to visually select regions.

+
-```bash -wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml -``` + -## Configuration +### 🚀 Tech Stack -Here is a summary of the configuration options available in the `docker-compose.yml` file: +
+ Client + +
- +
+ Server + +
+ -### 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) + -| 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: + -```bash -docker compose up -d -``` +## 👋 Contributing -Enjoy AdventureLog! 🎉 + + + -# Screenshots +Contributions are always welcome! -![Adventure Page](screenshots/adventures.png) -Displaying 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. -![Detail Page](screenshots/details.png) -Shows specific details about an adventure, including the name, date, location, description, and rating. + -![Edit](screenshots/edit.png) +## 📃 License -![Map Page](screenshots/map.png) -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. -![Itinerary Page](screenshots/itinerary.png) + -![Country Page](screenshots/countries.png) +## 🤝 Contact -![Region Page](screenshots/regions.png) +Sean Morley - [website](https://seanmorley.com) -# About AdventureLog +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! -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. - - - -# 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) + +### Top Supporters 💖 + +- [Veymax](https://x.com/veymax) +- [nebriv](https://github.com/nebriv) +- [Victor Butler](https://x.com/victor_butler) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 48adee3..9ca926c 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -20,23 +20,47 @@ 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 # Start gunicorn -gunicorn main.wsgi:application --bind 0.0.0.0:8000 --timeout 120 --workers 2 \ No newline at end of file +gunicorn main.wsgi:application --bind [::]:8000 --timeout 120 --workers 2 \ No newline at end of file diff --git a/backend/nginx.conf b/backend/nginx.conf index a58fce5..8074aa6 100644 --- a/backend/nginx.conf +++ b/backend/nginx.conf @@ -1,4 +1,5 @@ worker_processes 1; + events { worker_connections 1024; } @@ -12,29 +13,33 @@ http { client_max_body_size 100M; + # The backend is running in the same container, so reference localhost upstream django { - server server:8000; # Use the internal Docker networking + server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally } server { - listen 80; # NGINX always listens on port 80 inside the container + listen 80; server_name localhost; location / { - proxy_pass http://server:8000; # Explicitly forward to Django service + proxy_pass http://django; # Forward to the upstream block proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - location /static/ { alias /code/staticfiles/; # Serve static files directly } - location /media/ { - alias /code/media/; # Serve media files directly + # Serve protected media files with X-Accel-Redirect + location /protectedMedia/ { + internal; # Only internal requests are allowed + alias /code/media/; # This should match Django MEDIA_ROOT + try_files $uri =404; # Return a 404 if the file doesn't exist } + } } diff --git a/backend/server/.env.example b/backend/server/.env.example index 04eb77f..598aeb7 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -20,4 +20,15 @@ EMAIL_BACKEND='console' # EMAIL_USE_SSL=True # EMAIL_HOST_USER='user' # EMAIL_HOST_PASSWORD='password' -# DEFAULT_FROM_EMAIL='user@example.com' \ No newline at end of file +# DEFAULT_FROM_EMAIL='user@example.com' + + +# ------------------- # +# For Developers to start a Demo Database +# docker run --name adventurelog-development -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=adventurelog -p 5432:5432 -d postgis/postgis:15-3.3 + +# PGHOST='localhost' +# PGDATABASE='adventurelog' +# PGUSER='admin' +# PGPASSWORD='admin' +# ------------------- # \ No newline at end of file diff --git a/backend/server/achievements/__init__.py b/backend/server/achievements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/achievements/admin.py b/backend/server/achievements/admin.py new file mode 100644 index 0000000..af087ce --- /dev/null +++ b/backend/server/achievements/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from allauth.account.decorators import secure_admin_login +from achievements.models import Achievement, UserAchievement + +admin.autodiscover() +admin.site.login = secure_admin_login(admin.site.login) + +admin.site.register(Achievement) +admin.site.register(UserAchievement) \ No newline at end of file diff --git a/backend/server/achievements/apps.py b/backend/server/achievements/apps.py new file mode 100644 index 0000000..2a635e2 --- /dev/null +++ b/backend/server/achievements/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AchievementsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'achievements' diff --git a/backend/server/achievements/management/__init__.py b/backend/server/achievements/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/achievements/management/commands/__init__.py b/backend/server/achievements/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/achievements/management/commands/achievement-seed.py b/backend/server/achievements/management/commands/achievement-seed.py new file mode 100644 index 0000000..7713e88 --- /dev/null +++ b/backend/server/achievements/management/commands/achievement-seed.py @@ -0,0 +1,66 @@ +import json +from django.core.management.base import BaseCommand +from achievements.models import Achievement + +US_STATE_CODES = [ + 'US-AL', 'US-AK', 'US-AZ', 'US-AR', 'US-CA', 'US-CO', 'US-CT', 'US-DE', + 'US-FL', 'US-GA', 'US-HI', 'US-ID', 'US-IL', 'US-IN', 'US-IA', 'US-KS', + 'US-KY', 'US-LA', 'US-ME', 'US-MD', 'US-MA', 'US-MI', 'US-MN', 'US-MS', + 'US-MO', 'US-MT', 'US-NE', 'US-NV', 'US-NH', 'US-NJ', 'US-NM', 'US-NY', + 'US-NC', 'US-ND', 'US-OH', 'US-OK', 'US-OR', 'US-PA', 'US-RI', 'US-SC', + 'US-SD', 'US-TN', 'US-TX', 'US-UT', 'US-VT', 'US-VA', 'US-WA', 'US-WV', + 'US-WI', 'US-WY' +] + +ACHIEVEMENTS = [ + { + "name": "First Adventure", + "key": "achievements.first_adventure", + "type": "adventure_count", + "description": "Log your first adventure!", + "condition": {"type": "adventure_count", "value": 1}, + }, + { + "name": "Explorer", + "key": "achievements.explorer", + "type": "adventure_count", + "description": "Log 10 adventures.", + "condition": {"type": "adventure_count", "value": 10}, + }, + { + "name": "Globetrotter", + "key": "achievements.globetrotter", + "type": "country_count", + "description": "Visit 5 different countries.", + "condition": {"type": "country_count", "value": 5}, + }, + { + "name": "American Dream", + "key": "achievements.american_dream", + "type": "country_count", + "description": "Visit all 50 states in the USA.", + "condition": {"type": "country_count", "items": US_STATE_CODES}, + } +] + + + + +class Command(BaseCommand): + help = "Seeds the database with predefined achievements" + + def handle(self, *args, **kwargs): + for achievement_data in ACHIEVEMENTS: + achievement, created = Achievement.objects.update_or_create( + name=achievement_data["name"], + defaults={ + "description": achievement_data["description"], + "condition": json.dumps(achievement_data["condition"]), + "type": achievement_data["type"], + "key": achievement_data["key"], + }, + ) + if created: + self.stdout.write(self.style.SUCCESS(f"✅ Created: {achievement.name}")) + else: + self.stdout.write(self.style.WARNING(f"🔄 Updated: {achievement.name}")) diff --git a/backend/server/achievements/models.py b/backend/server/achievements/models.py new file mode 100644 index 0000000..c23bb31 --- /dev/null +++ b/backend/server/achievements/models.py @@ -0,0 +1,34 @@ +import uuid +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + +VALID_ACHIEVEMENT_TYPES = [ + "adventure_count", + "country_count", +] + +class Achievement(models.Model): + """Stores all possible achievements""" + name = models.CharField(max_length=255, unique=True) + key = models.CharField(max_length=255, unique=True, default='achievements.other') # Used for frontend lookups, e.g. "achievements.first_adventure" + type = models.CharField(max_length=255, choices=[(tag, tag) for tag in VALID_ACHIEVEMENT_TYPES], default='adventure_count') # adventure_count, country_count, etc. + description = models.TextField() + icon = models.ImageField(upload_to="achievements/", null=True, blank=True) + condition = models.JSONField() # Stores rules like {"type": "adventure_count", "value": 10} + + def __str__(self): + return self.name + +class UserAchievement(models.Model): + """Tracks which achievements a user has earned""" + user = models.ForeignKey(User, on_delete=models.CASCADE) + achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE) + earned_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "achievement") # Prevent duplicates + + def __str__(self): + return f"{self.user.username} - {self.achievement.name}" diff --git a/backend/server/achievements/tests.py b/backend/server/achievements/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/server/achievements/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/server/achievements/views.py b/backend/server/achievements/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/server/achievements/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 1beac0f..3210f7a 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,15 +1,13 @@ 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 .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Lodging +from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) - - class AdventureAdmin(admin.ModelAdmin): list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public') list_filter = ( 'user_id', 'is_public') @@ -53,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 @@ -63,7 +71,7 @@ class CustomUserAdmin(UserAdmin): readonly_fields = ('uuid',) search_fields = ('username',) fieldsets = UserAdmin.fieldsets + ( - (None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), + (None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}), ) def image_display(self, obj): if obj.profile_pic: @@ -129,6 +137,10 @@ 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.register(Attachment) +admin.site.register(Lodging) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' diff --git a/backend/server/adventures/managers.py b/backend/server/adventures/managers.py new file mode 100644 index 0000000..6d8d43c --- /dev/null +++ b/backend/server/adventures/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.db.models import Q + +class AdventureManager(models.Manager): + def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False): + # Initialize the query with an empty Q object + query = Q() + + # Add owned adventures to the query if included + if include_owned: + query |= Q(user_id=user.id) + + # Add shared adventures to the query if included + if include_shared: + query |= Q(collection__shared_with=user.id) + + # Add public adventures to the query if included + if include_public: + query |= Q(is_public=True) + + # Perform the query with the final Q object and remove duplicates + return self.filter(query).distinct() diff --git a/backend/server/adventures/middleware.py b/backend/server/adventures/middleware.py index 3cd9713..10050b0 100644 --- a/backend/server/adventures/middleware.py +++ b/backend/server/adventures/middleware.py @@ -1,23 +1,32 @@ -class AppVersionMiddleware: +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin +import os + +class OverrideHostMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - # Process request (if needed) + public_url = os.getenv('PUBLIC_URL', None) + if public_url: + # Extract host and scheme + scheme, host = public_url.split("://") + request.META['HTTP_HOST'] = host + request.META['wsgi.url_scheme'] = scheme + + # Set X-Forwarded-Proto for Django + request.META['HTTP_X_FORWARDED_PROTO'] = scheme + response = self.get_response(request) - - # Add custom header to response - # Replace with your app version - response['X-AdventureLog-Version'] = '1.0.0' - return response -# make a middlewra that prints all of the request cookies -class PrintCookiesMiddleware: - def __init__(self, get_response): - self.get_response = get_response +class XSessionTokenMiddleware(MiddlewareMixin): + def process_request(self, request): + session_token = request.headers.get('X-Session-Token') + if session_token: + request.COOKIES[settings.SESSION_COOKIE_NAME] = session_token - def __call__(self, request): - print(request.COOKIES) - response = self.get_response(request) - return response \ No newline at end of file +class DisableCSRFForSessionTokenMiddleware(MiddlewareMixin): + def process_request(self, request): + if 'X-Session-Token' in request.headers: + setattr(request, '_dont_enforce_csrf_checks', True) \ No newline at end of file diff --git a/backend/server/adventures/migrations/0016_alter_adventureimage_image.py b/backend/server/adventures/migrations/0016_alter_adventureimage_image.py new file mode 100644 index 0000000..a226fe1 --- /dev/null +++ b/backend/server/adventures/migrations/0016_alter_adventureimage_image.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.8 on 2025-01-01 21:40 + +import adventures.models +import django_resized.forms +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0015_transportation_destination_latitude_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='adventureimage', + name='image', + field=django_resized.forms.ResizedImageField(crop=None, force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')), + ), + ] diff --git a/backend/server/adventures/migrations/0017_adventureimage_is_primary.py b/backend/server/adventures/migrations/0017_adventureimage_is_primary.py new file mode 100644 index 0000000..9a920a3 --- /dev/null +++ b/backend/server/adventures/migrations/0017_adventureimage_is_primary.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-01-03 04:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0016_alter_adventureimage_image'), + ] + + operations = [ + migrations.AddField( + model_name='adventureimage', + name='is_primary', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/server/adventures/migrations/0018_attachment.py b/backend/server/adventures/migrations/0018_attachment.py new file mode 100644 index 0000000..f41c44b --- /dev/null +++ b/backend/server/adventures/migrations/0018_attachment.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.8 on 2025-01-19 00:39 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0017_adventureimage_is_primary'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('file', models.FileField(upload_to='attachments/')), + ('adventure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='adventures.adventure')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/migrations/0019_alter_attachment_file.py b/backend/server/adventures/migrations/0019_alter_attachment_file.py new file mode 100644 index 0000000..bb48fae --- /dev/null +++ b/backend/server/adventures/migrations/0019_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:17 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0018_attachment'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/')), + ), + ] diff --git a/backend/server/adventures/migrations/0020_attachment_name.py b/backend/server/adventures/migrations/0020_attachment_name.py new file mode 100644 index 0000000..4773250 --- /dev/null +++ b/backend/server/adventures/migrations/0020_attachment_name.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0019_alter_attachment_file'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='name', + field=models.CharField(default='', max_length=200), + preserve_default=False, + ), + ] diff --git a/backend/server/adventures/migrations/0021_alter_attachment_name.py b/backend/server/adventures/migrations/0021_alter_attachment_name.py new file mode 100644 index 0000000..93b7eb3 --- /dev/null +++ b/backend/server/adventures/migrations/0021_alter_attachment_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-01-19 22:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0020_attachment_name'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='name', + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/backend/server/adventures/migrations/0022_hotel.py b/backend/server/adventures/migrations/0022_hotel.py new file mode 100644 index 0000000..56a6097 --- /dev/null +++ b/backend/server/adventures/migrations/0022_hotel.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.8 on 2025-02-02 15:36 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0021_alter_attachment_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Hotel', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True, null=True)), + ('rating', models.FloatField(blank=True, null=True)), + ('link', models.URLField(blank=True, max_length=2083, null=True)), + ('check_in', models.DateTimeField(blank=True, null=True)), + ('check_out', models.DateTimeField(blank=True, null=True)), + ('reservation_number', models.CharField(blank=True, max_length=100, null=True)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('location', models.CharField(blank=True, max_length=200, null=True)), + ('is_public', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/migrations/0023_lodging_delete_hotel.py b/backend/server/adventures/migrations/0023_lodging_delete_hotel.py new file mode 100644 index 0000000..44e502c --- /dev/null +++ b/backend/server/adventures/migrations/0023_lodging_delete_hotel.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.8 on 2025-02-08 01:50 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0022_hotel'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Lodging', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=200)), + ('type', models.CharField(choices=[('hotel', 'Hotel'), ('hostel', 'Hostel'), ('resort', 'Resort'), ('bnb', 'Bed & Breakfast'), ('campground', 'Campground'), ('cabin', 'Cabin'), ('apartment', 'Apartment'), ('house', 'House'), ('villa', 'Villa'), ('motel', 'Motel'), ('other', 'Other')], default='other', max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('rating', models.FloatField(blank=True, null=True)), + ('link', models.URLField(blank=True, max_length=2083, null=True)), + ('check_in', models.DateTimeField(blank=True, null=True)), + ('check_out', models.DateTimeField(blank=True, null=True)), + ('reservation_number', models.CharField(blank=True, max_length=100, null=True)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)), + ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)), + ('location', models.CharField(blank=True, max_length=200, null=True)), + ('is_public', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.DeleteModel( + name='Hotel', + ), + ] diff --git a/backend/server/adventures/migrations/0024_alter_attachment_file.py b/backend/server/adventures/migrations/0024_alter_attachment_file.py new file mode 100644 index 0000000..e63bb0e --- /dev/null +++ b/backend/server/adventures/migrations/0024_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0023_lodging_delete_hotel'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/'), validators=[adventures.models.validate_file_extension]), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 98ae268..c7f78ca 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -1,13 +1,23 @@ -from collections.abc import Collection +from django.core.exceptions import ValidationError +import os from typing import Iterable import uuid from django.db import models - +from django.utils.deconstruct import deconstructible +from adventures.managers import AdventureManager from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.forms import ValidationError from django_resized import ResizedImageField +def validate_file_extension(value): + import os + from django.core.exceptions import ValidationError + ext = os.path.splitext(value.name)[1] # [0] returns path+filename + valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', 'gpx', 'md', 'pdf'] + if not ext.lower() in valid_extensions: + raise ValidationError('Unsupported file extension.') + ADVENTURE_TYPES = [ ('general', 'General 🌍'), ('outdoor', 'Outdoor 🏞️'), @@ -33,6 +43,20 @@ ADVENTURE_TYPES = [ ('other', 'Other') ] +LODGING_TYPES = [ + ('hotel', 'Hotel'), + ('hostel', 'Hostel'), + ('resort', 'Resort'), + ('bnb', 'Bed & Breakfast'), + ('campground', 'Campground'), + ('cabin', 'Cabin'), + ('apartment', 'Apartment'), + ('house', 'House'), + ('villa', 'Villa'), + ('motel', 'Motel'), + ('other', 'Other') +] + TRANSPORTATION_TYPES = [ ('car', 'Car'), ('plane', 'Plane'), @@ -86,6 +110,8 @@ class Adventure(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = AdventureManager() + # DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS # Migrations performed in this version will remove these fields # image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/') @@ -257,15 +283,42 @@ class ChecklistItem(models.Model): def __str__(self): return self.name +@deconstructible +class PathAndRename: + def __init__(self, path): + self.path = path + + def __call__(self, instance, filename): + ext = filename.split('.')[-1] + # Generate a new UUID for the filename + filename = f"{uuid.uuid4()}.{ext}" + return os.path.join(self.path, filename) + class AdventureImage(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) - image = ResizedImageField(force_format="WEBP", quality=75, upload_to='images/') + image = ResizedImageField( + force_format="WEBP", + quality=75, + upload_to=PathAndRename('images/') # Use the callable class here + ) adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) + is_primary = models.BooleanField(default=False) def __str__(self): return self.image.url + +class Attachment(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + file = models.FileField(upload_to=PathAndRename('attachments/'),validators=[validate_file_extension]) + adventure = models.ForeignKey(Adventure, related_name='attachments', on_delete=models.CASCADE) + name = models.CharField(max_length=200, null=True, blank=True) + + def __str__(self): + return self.file.url class Category(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) @@ -286,4 +339,38 @@ class Category(models.Model): def __str__(self): - return self.name + ' - ' + self.display_name + ' - ' + self.icon \ No newline at end of file + return self.name + ' - ' + self.display_name + ' - ' + self.icon + +class Lodging(models.Model): + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + name = models.CharField(max_length=200) + type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other') + description = models.TextField(blank=True, null=True) + rating = models.FloatField(blank=True, null=True) + link = models.URLField(blank=True, null=True, max_length=2083) + check_in = models.DateTimeField(blank=True, null=True) + check_out = models.DateTimeField(blank=True, null=True) + reservation_number = models.CharField(max_length=100, blank=True, null=True) + price = models.DecimalField(max_digits=9, decimal_places=2, blank=True, null=True) + latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + location = models.CharField(max_length=200, blank=True, null=True) + is_public = models.BooleanField(default=False) + collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + if self.date and self.end_date and self.date > self.end_date: + raise ValidationError('The start date must be before the end date. Start date: ' + str(self.date) + ' End date: ' + str(self.end_date)) + + if self.collection: + if self.collection.is_public and not self.is_public: + raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Loging: ' + self.name) + if self.user_id != self.collection.user_id: + raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Lodging owner: ' + self.user_id.username) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/server/adventures/permissions.py b/backend/server/adventures/permissions.py index 5f9a911..941fbc0 100644 --- a/backend/server/adventures/permissions.py +++ b/backend/server/adventures/permissions.py @@ -61,6 +61,10 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): """ def has_object_permission(self, request, view, obj): + + # Allow GET only for a public object + if request.method in permissions.SAFE_METHODS and obj.is_public: + return True # Check if the object has a collection if hasattr(obj, 'collection') and obj.collection: # Allow all actions for shared users @@ -71,27 +75,5 @@ class IsOwnerOrSharedWithFullAccess(permissions.BasePermission): if request.method in permissions.SAFE_METHODS: return True - # Allow all actions for the owner - return obj.user_id == request.user - -class IsPublicOrOwnerOrSharedWithFullAccess(permissions.BasePermission): - """ - Custom permission to allow: - - Read-only access for public objects - - Full access for shared users - - Full access for owners - """ - - def has_object_permission(self, request, view, obj): - # Allow read-only access for public objects - if obj.is_public and request.method in permissions.SAFE_METHODS: - return True - - # Check if the object has a collection - if hasattr(obj, 'collection') and obj.collection: - # Allow all actions for shared users - if request.user in obj.collection.shared_with.all(): - return True - # Allow all actions for the owner return obj.user_id == request.user \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 45a2141..97dd633 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,15 +1,16 @@ from django.utils import timezone import os -from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging from rest_framework import serializers from main.utils import CustomModelSerializer +from users.serializers import CustomUserDetailsSerializer class AdventureImageSerializer(CustomModelSerializer): class Meta: model = AdventureImage - fields = ['id', 'image', 'adventure'] - read_only_fields = ['id'] + fields = ['id', 'image', 'adventure', 'is_primary', 'user_id'] + read_only_fields = ['id', 'user_id'] def to_representation(self, instance): representation = super().to_representation(instance) @@ -21,6 +22,26 @@ class AdventureImageSerializer(CustomModelSerializer): representation['image'] = f"{public_url}/media/{instance.image.name}" return representation +class AttachmentSerializer(CustomModelSerializer): + extension = serializers.SerializerMethodField() + class Meta: + model = Attachment + fields = ['id', 'file', 'adventure', 'extension', 'name', 'user_id'] + read_only_fields = ['id', 'user_id'] + + def get_extension(self, obj): + return obj.file.name.split('.')[-1] + + def to_representation(self, instance): + representation = super().to_representation(instance) + if instance.file: + 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("'", "") + representation['file'] = f"{public_url}/media/{instance.file.name}" + return representation + class CategorySerializer(serializers.ModelSerializer): num_adventures = serializers.SerializerMethodField() class Meta: @@ -57,17 +78,19 @@ class VisitSerializer(serializers.ModelSerializer): class AdventureSerializer(CustomModelSerializer): images = AdventureImageSerializer(many=True, read_only=True) visits = VisitSerializer(many=True, read_only=False, required=False) + attachments = AttachmentSerializer(many=True, read_only=True) category = CategorySerializer(read_only=False, required=False) is_visited = serializers.SerializerMethodField() + user = serializers.SerializerMethodField() class Meta: model = Adventure fields = [ 'id', 'user_id', 'name', 'description', 'rating', 'activity_types', 'location', 'is_public', 'collection', 'created_at', 'updated_at', 'images', 'link', 'longitude', - 'latitude', 'visits', 'is_visited', 'category' + 'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user' ] - read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited'] + read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user'] def validate_category(self, category_data): if isinstance(category_data, Category): @@ -105,7 +128,11 @@ class AdventureSerializer(CustomModelSerializer): } ) return category - + + def get_user(self, obj): + user = obj.user_id + return CustomUserDetailsSerializer(user).data + def get_is_visited(self, obj): current_date = timezone.now().date() for visit in obj.visits.all(): @@ -116,7 +143,7 @@ class AdventureSerializer(CustomModelSerializer): return False def create(self, validated_data): - visits_data = validated_data.pop('visits', []) + visits_data = validated_data.pop('visits', None) category_data = validated_data.pop('category', None) print(category_data) adventure = Adventure.objects.create(**validated_data) @@ -131,6 +158,7 @@ class AdventureSerializer(CustomModelSerializer): return adventure def update(self, instance, validated_data): + has_visits = 'visits' in validated_data visits_data = validated_data.pop('visits', []) category_data = validated_data.pop('category', None) @@ -142,24 +170,25 @@ class AdventureSerializer(CustomModelSerializer): instance.category = category instance.save() - current_visits = instance.visits.all() - current_visit_ids = set(current_visits.values_list('id', flat=True)) + if has_visits: + current_visits = instance.visits.all() + current_visit_ids = set(current_visits.values_list('id', flat=True)) - updated_visit_ids = set() - for visit_data in visits_data: - visit_id = visit_data.get('id') - if visit_id and visit_id in current_visit_ids: - visit = current_visits.get(id=visit_id) - for attr, value in visit_data.items(): - setattr(visit, attr, value) - visit.save() - updated_visit_ids.add(visit_id) - else: - new_visit = Visit.objects.create(adventure=instance, **visit_data) - updated_visit_ids.add(new_visit.id) + updated_visit_ids = set() + for visit_data in visits_data: + visit_id = visit_data.get('id') + if visit_id and visit_id in current_visit_ids: + visit = current_visits.get(id=visit_id) + for attr, value in visit_data.items(): + setattr(visit, attr, value) + visit.save() + updated_visit_ids.add(visit_id) + else: + new_visit = Visit.objects.create(adventure=instance, **visit_data) + updated_visit_ids.add(new_visit.id) - visits_to_delete = current_visit_ids - updated_visit_ids - instance.visits.filter(id__in=visits_to_delete).delete() + visits_to_delete = current_visit_ids - updated_visit_ids + instance.visits.filter(id__in=visits_to_delete).delete() return instance @@ -174,6 +203,17 @@ class TransportationSerializer(CustomModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] +class LodgingSerializer(CustomModelSerializer): + + class Meta: + model = Lodging + fields = [ + 'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out', + 'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public', + 'collection', 'created_at', 'updated_at', 'type' + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] + class NoteSerializer(CustomModelSerializer): class Meta: @@ -260,10 +300,11 @@ class CollectionSerializer(CustomModelSerializer): transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set') notes = NoteSerializer(many=True, read_only=True, source='note_set') checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set') + lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set') class Meta: model = Collection - fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link'] + fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] def to_representation(self, instance): @@ -273,5 +314,4 @@ class CollectionSerializer(CustomModelSerializer): for user in instance.shared_with.all(): shared_uuids.append(str(user.uuid)) representation['shared_with'] = shared_uuids - return representation - \ No newline at end of file + return representation \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index d035522..1a98273 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet, IcsCalendarGeneratorViewSet +from adventures.views import * router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') @@ -15,6 +15,10 @@ router.register(r'images', AdventureImageViewSet, basename='images') router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode') router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') +router.register(r'overpass', OverpassViewSet, basename='overpass') +router.register(r'search', GlobalSearchView, basename='search') +router.register(r'attachments', AttachmentViewSet, basename='attachments') +router.register(r'lodging', LodgingViewSet, basename='lodging') urlpatterns = [ diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py new file mode 100644 index 0000000..02971bc --- /dev/null +++ b/backend/server/adventures/utils/file_permissions.py @@ -0,0 +1,41 @@ +from adventures.models import AdventureImage, Attachment + +protected_paths = ['images/', 'attachments/'] + +def checkFilePermission(fileId, user, mediaType): + if mediaType not in protected_paths: + return True + if mediaType == 'images/': + try: + # Construct the full relative path to match the database field + image_path = f"images/{fileId}" + # Fetch the AdventureImage object + adventure = AdventureImage.objects.get(image=image_path).adventure + if adventure.is_public: + return True + elif adventure.user_id == user: + return True + elif adventure.collection: + if adventure.collection.shared_with.filter(id=user.id).exists(): + return True + else: + return False + except AdventureImage.DoesNotExist: + return False + elif mediaType == 'attachments/': + try: + # Construct the full relative path to match the database field + attachment_path = f"attachments/{fileId}" + # Fetch the Attachment object + attachment = Attachment.objects.get(file=attachment_path).adventure + if attachment.is_public: + return True + elif attachment.user_id == user: + return True + elif attachment.collection: + if attachment.collection.shared_with.filter(id=user.id).exists(): + return True + else: + return False + except Attachment.DoesNotExist: + return False \ No newline at end of file diff --git a/backend/server/adventures/utils/pagination.py b/backend/server/adventures/utils/pagination.py new file mode 100644 index 0000000..4337190 --- /dev/null +++ b/backend/server/adventures/utils/pagination.py @@ -0,0 +1,6 @@ +from rest_framework.pagination import PageNumberPagination + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 25 + page_size_query_param = 'page_size' + max_page_size = 1000 \ No newline at end of file diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py deleted file mode 100644 index b1c3956..0000000 --- a/backend/server/adventures/views.py +++ /dev/null @@ -1,1275 +0,0 @@ -import json -import uuid -import requests -from django.db import transaction -from rest_framework.decorators import action -from rest_framework import viewsets -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 .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer -from rest_framework.permissions import IsAuthenticated -from django.db.models import Q -from .permissions import CollectionShared, IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess -from rest_framework.pagination import PageNumberPagination -from django.shortcuts import get_object_or_404 -from rest_framework import status -from django.contrib.auth import get_user_model -from icalendar import Calendar, Event, vText, vCalAddress -from django.http import HttpResponse -from datetime import datetime - -User = get_user_model() - -class StandardResultsSetPagination(PageNumberPagination): - page_size = 25 - page_size_query_param = 'page_size' - max_page_size = 1000 - -from rest_framework.pagination import PageNumberPagination - -from rest_framework.decorators import action -from rest_framework.response import Response -from django.db.models import Q - -class AdventureViewSet(viewsets.ModelViewSet): - serializer_class = AdventureSerializer - permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] - pagination_class = StandardResultsSetPagination - - def apply_sorting(self, queryset): - order_by = self.request.query_params.get('order_by', 'updated_at') - order_direction = self.request.query_params.get('order_direction', 'asc') - include_collections = self.request.query_params.get('include_collections', 'true') - - valid_order_by = ['name', 'type', 'start_date', 'rating', 'updated_at'] - if order_by not in valid_order_by: - order_by = 'name' - - if order_direction not in ['asc', 'desc']: - order_direction = 'asc' - - # Apply case-insensitive sorting for the 'name' field - if order_by == 'name': - queryset = queryset.annotate(lower_name=Lower('name')) - ordering = 'lower_name' - else: - ordering = order_by - - if order_direction == 'desc': - ordering = f'-{ordering}' - - # reverse ordering for updated_at field - if order_by == 'updated_at': - if order_direction == 'asc': - ordering = '-updated_at' - else: - ordering = 'updated_at' - - print(f"Ordering by: {ordering}") # For debugging - - if include_collections == 'false': - queryset = queryset.filter(collection = None) - - return queryset.order_by(ordering) - - def get_queryset(self): - print(self.request.user) - # if the user is not authenticated return only public adventures for retrieve action - if not self.request.user.is_authenticated: - if self.action == 'retrieve': - return Adventure.objects.filter(is_public=True).distinct().order_by('-updated_at') - return Adventure.objects.none() - - if self.action == 'retrieve': - # For individual adventure retrieval, include public adventures - return Adventure.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - else: - # For other actions, include user's own adventures and shared adventures - return Adventure.objects.filter( - Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - - def retrieve(self, request, *args, **kwargs): - queryset = self.get_queryset() - adventure = get_object_or_404(queryset, pk=kwargs['pk']) - serializer = self.get_serializer(adventure) - return Response(serializer.data) - - def perform_update(self, serializer): - adventure = serializer.save() - if adventure.collection: - adventure.is_public = adventure.collection.is_public - adventure.save() - - @action(detail=False, methods=['get']) - def filtered(self, request): - types = request.query_params.get('types', '').split(',') - is_visited = request.query_params.get('is_visited', 'all') - - # Handle case where types is all - if 'all' in types: - types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) - - else: - for type in types: - if not Category.objects.filter(user_id=request.user, name=type).exists(): - return Response({"error": f"Category {type} does not exist"}, status=400) - - if not types: - return Response({"error": "At least one type must be provided"}, status=400) - - queryset = Adventure.objects.filter( - category__in=Category.objects.filter(name__in=types, user_id=request.user), - user_id=request.user.id - ) - - # Handle is_visited filtering - if is_visited.lower() == 'true': - serializer = self.get_serializer(queryset, many=True) - filtered_ids = [ - adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data) - if serialized_adventure['is_visited'] - ] - queryset = queryset.filter(id__in=filtered_ids) - elif is_visited.lower() == 'false': - serializer = self.get_serializer(queryset, many=True) - filtered_ids = [ - adventure.id for adventure, serialized_adventure in zip(queryset, serializer.data) - if not serialized_adventure['is_visited'] - ] - queryset = queryset.filter(id__in=filtered_ids) - # If is_visited is 'all' or any other value, we don't apply additional filtering - - # Apply sorting - queryset = self.apply_sorting(queryset) - - # Paginate and respond - adventures = self.paginate_and_respond(queryset, request) - return adventures - - @action(detail=False, methods=['get']) - def all(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - include_collections = request.query_params.get('include_collections', 'false') - if include_collections not in ['true', 'false']: - include_collections = 'false' - - if include_collections == 'true': - queryset = Adventure.objects.filter( - Q(is_public=True) | Q(user_id=request.user.id) - ) - else: - queryset = Adventure.objects.filter( - Q(is_public=True) | Q(user_id=request.user.id), collection=None - ) - queryset = Adventure.objects.filter( - Q(user_id=request.user.id) - ) - queryset = self.apply_sorting(queryset) - serializer = self.get_serializer(queryset, many=True) - - return Response(serializer.data) - - @action(detail=False, methods=['get']) - def search(self, request): - query = self.request.query_params.get('query', '') - property = self.request.query_params.get('property', 'all') - if len(query) < 2: - return Response({"error": "Query must be at least 2 characters long"}, status=400) - - if property not in ['name', 'type', 'location', 'description', 'activity_types']: - property = 'all' - - queryset = Adventure.objects.none() - - if property == 'name': - queryset = Adventure.objects.filter( - (Q(name__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'type': - queryset = Adventure.objects.filter( - (Q(type__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'location': - queryset = Adventure.objects.filter( - (Q(location__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'description': - queryset = Adventure.objects.filter( - (Q(description__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - elif property == 'activity_types': - queryset = Adventure.objects.filter( - (Q(activity_types__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - else: - queryset = Adventure.objects.filter( - (Q(name__icontains=query) | Q(description__icontains=query) | Q(location__icontains=query) | Q(activity_types__icontains=query)) & - (Q(user_id=request.user.id) | Q(is_public=True)) - ) - - queryset = self.apply_sorting(queryset) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - def update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # if the adventure is trying to have is_public changed and its part of a collection return an error - if new_collection is not None: - serializer.validated_data['is_public'] = new_collection.is_public - elif instance.collection: - serializer.validated_data['is_public'] = instance.collection.is_public - - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - # if the adventure is trying to have is_public changed and its part of a collection return an error - if new_collection is not None: - serializer.validated_data['is_public'] = new_collection.is_public - elif instance.collection: - serializer.validated_data['is_public'] = instance.collection.is_public - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def perform_update(self, serializer): - serializer.save() - - # when creating an adventure, make sure the user is the owner of the collection or shared with the collection - @transaction.atomic - def perform_create(self, serializer): - # Retrieve the collection from the validated data - collection = serializer.validated_data.get('collection') - - # Check if a collection is provided - if collection: - user = self.request.user - # Check if the user is the owner or is in the shared_with list - if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): - # Return an error response if the user does not have permission - raise PermissionDenied("You do not have permission to use this collection.") - # if collection the owner of the adventure is the owner of the collection - # set the is_public field of the adventure to the is_public field of the collection - serializer.save(user_id=collection.user_id, is_public=collection.is_public) - return - - # Save the adventure with the current user as the owner - serializer.save(user_id=self.request.user) - - def paginate_and_respond(self, queryset, request): - paginator = self.pagination_class() - page = paginator.paginate_queryset(queryset, request) - if page is not None: - serializer = self.get_serializer(page, many=True) - return paginator.get_paginated_response(serializer.data) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - -class CollectionViewSet(viewsets.ModelViewSet): - serializer_class = CollectionSerializer - permission_classes = [CollectionShared] - pagination_class = StandardResultsSetPagination - - # def get_queryset(self): - # return Collection.objects.filter(Q(user_id=self.request.user.id) & Q(is_archived=False)) - - def apply_sorting(self, queryset): - order_by = self.request.query_params.get('order_by', 'name') - order_direction = self.request.query_params.get('order_direction', 'asc') - - valid_order_by = ['name', 'upated_at'] - if order_by not in valid_order_by: - order_by = 'updated_at' - - if order_direction not in ['asc', 'desc']: - order_direction = 'asc' - - # Apply case-insensitive sorting for the 'name' field - if order_by == 'name': - queryset = queryset.annotate(lower_name=Lower('name')) - ordering = 'lower_name' - if order_direction == 'desc': - ordering = f'-{ordering}' - else: - order_by == 'updated_at' - ordering = 'updated_at' - if order_direction == 'asc': - ordering = '-updated_at' - - #print(f"Ordering by: {ordering}") # For debugging - - return queryset.order_by(ordering) - - def list(self, request, *args, **kwargs): - # make sure the user is authenticated - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - queryset = Collection.objects.filter(user_id=request.user.id) - queryset = self.apply_sorting(queryset) - collections = self.paginate_and_respond(queryset, request) - return collections - - @action(detail=False, methods=['get']) - def all(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - - queryset = Collection.objects.filter( - Q(user_id=request.user.id) - ) - - queryset = self.apply_sorting(queryset) - serializer = self.get_serializer(queryset, many=True) - - return Response(serializer.data) - - @action(detail=False, methods=['get']) - def archived(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - - queryset = Collection.objects.filter( - Q(user_id=request.user.id) & Q(is_archived=True) - ) - - queryset = self.apply_sorting(queryset) - serializer = self.get_serializer(queryset, many=True) - - return Response(serializer.data) - - # this make the is_public field of the collection cascade to the adventures - @transaction.atomic - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - - if 'collection' in serializer.validated_data: - new_collection = serializer.validated_data['collection'] - # if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error - if new_collection != instance.collection and new_collection.user_id != request.user: - return Response({"error": "User does not own the new collection"}, status=400) - - # Check if the 'is_public' field is present in the update data - if 'is_public' in serializer.validated_data: - new_public_status = serializer.validated_data['is_public'] - - # if is_publuc has changed and the user is not the owner of the collection return an error - if new_public_status != instance.is_public and instance.user_id != request.user: - print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}") - return Response({"error": "User does not own the collection"}, status=400) - - # Update associated adventures to match the collection's is_public status - Adventure.objects.filter(collection=instance).update(is_public=new_public_status) - - # do the same for transportations - Transportation.objects.filter(collection=instance).update(is_public=new_public_status) - - # do the same for notes - Note.objects.filter(collection=instance).update(is_public=new_public_status) - - # Log the action (optional) - action = "public" if new_public_status else "private" - print(f"Collection {instance.id} and its adventures were set to {action}") - - self.perform_update(serializer) - - if getattr(instance, '_prefetched_objects_cache', None): - # If 'prefetch_related' has been applied to a queryset, we need to - # forcibly invalidate the prefetch cache on the instance. - instance._prefetched_objects_cache = {} - - return Response(serializer.data) - - # make an action to retreive all adventures that are shared with the user - @action(detail=False, methods=['get']) - def shared(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - queryset = Collection.objects.filter( - shared_with=request.user - ) - queryset = self.apply_sorting(queryset) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - # Adds a new user to the shared_with field of an adventure - @action(detail=True, methods=['post'], url_path='share/(?P[^/.]+)') - def share(self, request, pk=None, uuid=None): - collection = self.get_object() - if not uuid: - return Response({"error": "User UUID is required"}, status=400) - try: - user = User.objects.get(uuid=uuid, public_profile=True) - except User.DoesNotExist: - return Response({"error": "User not found"}, status=404) - - if user == request.user: - return Response({"error": "Cannot share with yourself"}, status=400) - - if collection.shared_with.filter(id=user.id).exists(): - return Response({"error": "Adventure is already shared with this user"}, status=400) - - collection.shared_with.add(user) - collection.save() - return Response({"success": f"Shared with {user.username}"}) - - @action(detail=True, methods=['post'], url_path='unshare/(?P[^/.]+)') - def unshare(self, request, pk=None, uuid=None): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - collection = self.get_object() - if not uuid: - return Response({"error": "User UUID is required"}, status=400) - try: - user = User.objects.get(uuid=uuid, public_profile=True) - except User.DoesNotExist: - return Response({"error": "User not found"}, status=404) - - if user == request.user: - return Response({"error": "Cannot unshare with yourself"}, status=400) - - if not collection.shared_with.filter(id=user.id).exists(): - return Response({"error": "Collection is not shared with this user"}, status=400) - - collection.shared_with.remove(user) - collection.save() - return Response({"success": f"Unshared with {user.username}"}) - - def get_queryset(self): - if self.action == 'destroy': - return Collection.objects.filter(user_id=self.request.user.id) - - if self.action in ['update', 'partial_update']: - return Collection.objects.filter( - Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) - ).distinct() - - if self.action == 'retrieve': - if not self.request.user.is_authenticated: - return Collection.objects.filter(is_public=True) - return Collection.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) - ).distinct() - - # For list action, include collections owned by the user or shared with the user, that are not archived - return Collection.objects.filter( - (Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False) - ).distinct() - - - def perform_create(self, serializer): - # This is ok because you cannot share a collection when creating it - serializer.save(user_id=self.request.user) - - def paginate_and_respond(self, queryset, request): - paginator = self.pagination_class() - page = paginator.paginate_queryset(queryset, request) - if page is not None: - serializer = self.get_serializer(page, many=True) - return paginator.get_paginated_response(serializer.data) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - -class StatsViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - @action(detail=False, methods=['get']) - def counts(self, request): - adventure_count = Adventure.objects.filter( - user_id=request.user.id).count() - trips_count = Collection.objects.filter( - user_id=request.user.id).count() - visited_region_count = VisitedRegion.objects.filter( - user_id=request.user.id).count() - total_regions = Region.objects.count() - country_count = VisitedRegion.objects.filter( - user_id=request.user.id).values('region__country').distinct().count() - total_countries = Country.objects.count() - return Response({ - 'adventure_count': adventure_count, - 'trips_count': trips_count, - 'visited_region_count': visited_region_count, - 'total_regions': total_regions, - 'country_count': country_count, - 'total_countries': total_countries - }) - -class GenerateDescription(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - @action(detail=False, methods=['get'],) - def desc(self, request): - name = self.request.query_params.get('name', '') - # un url encode the name - name = name.replace('%20', ' ') - print(name) - url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name - response = requests.get(url) - data = response.json() - data = response.json() - page_id = next(iter(data["query"]["pages"])) - extract = data["query"]["pages"][page_id] - if extract.get('extract') is None: - return Response({"error": "No description found"}, status=400) - return Response(extract) - @action(detail=False, methods=['get'],) - def img(self, request): - name = self.request.query_params.get('name', '') - # un url encode the name - name = name.replace('%20', ' ') - url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name - response = requests.get(url) - data = response.json() - page_id = next(iter(data["query"]["pages"])) - extract = data["query"]["pages"][page_id] - if extract.get('original') is None: - return Response({"error": "No image found"}, status=400) - return Response(extract["original"]) - - -class ActivityTypesView(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - @action(detail=False, methods=['get']) - def types(self, request): - """ - Retrieve a list of distinct activity types for adventures associated with the current user. - - Args: - request (HttpRequest): The HTTP request object. - - Returns: - Response: A response containing a list of distinct activity types. - """ - types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct() - - allTypes = [] - - for i in types: - if not i: - continue - for x in i: - if x and x not in allTypes: - allTypes.append(x) - - return Response(allTypes) - -class CategoryViewSet(viewsets.ModelViewSet): - queryset = Category.objects.all() - serializer_class = CategorySerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return Category.objects.filter(user_id=self.request.user) - - @action(detail=False, methods=['get']) - def categories(self, request): - """ - Retrieve a list of distinct categories for adventures associated with the current user. - """ - categories = self.get_queryset().distinct() - serializer = self.get_serializer(categories, many=True) - return Response(serializer.data) - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if instance.user_id != request.user: - return Response({"error": "User does not own this category"}, status - =400) - - if instance.name == 'general': - return Response({"error": "Cannot delete the general category"}, status=400) - - # set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user - general_category = Category.objects.filter(user_id=request.user, name='general').first() - - if not general_category: - general_category = Category.objects.create(user_id=request.user, name='general', icon='🌍', display_name='General') - - Adventure.objects.filter(category=instance).update(category=general_category) - - return super().destroy(request, *args, **kwargs) - - -class TransportationViewSet(viewsets.ModelViewSet): - queryset = Transportation.objects.all() - serializer_class = TransportationSerializer - permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] - filterset_fields = ['type', 'is_public', 'collection'] - - # return error message if user is not authenticated on the root endpoint - def list(self, request, *args, **kwargs): - # Prevent listing all adventures - return Response({"detail": "Listing all transportations is not allowed."}, - status=status.HTTP_403_FORBIDDEN) - - @action(detail=False, methods=['get']) - def all(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - queryset = Transportation.objects.filter( - Q(user_id=request.user.id) - ) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - - def get_queryset(self): - # if the user is not authenticated return only public transportations for retrieve action - if not self.request.user.is_authenticated: - if self.action == 'retrieve': - return Transportation.objects.filter(is_public=True).distinct().order_by('-updated_at') - return Transportation.objects.none() - - - if self.action == 'retrieve': - # For individual adventure retrieval, include public adventures - return Transportation.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - else: - # For other actions, include user's own adventures and shared adventures - return Transportation.objects.filter( - Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def perform_update(self, serializer): - serializer.save() - - # when creating an adventure, make sure the user is the owner of the collection or shared with the collection - def perform_create(self, serializer): - # Retrieve the collection from the validated data - collection = serializer.validated_data.get('collection') - - # Check if a collection is provided - if collection: - user = self.request.user - # Check if the user is the owner or is in the shared_with list - if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): - # Return an error response if the user does not have permission - raise PermissionDenied("You do not have permission to use this collection.") - # if collection the owner of the adventure is the owner of the collection - serializer.save(user_id=collection.user_id) - return - - # Save the adventure with the current user as the owner - serializer.save(user_id=self.request.user) - -class NoteViewSet(viewsets.ModelViewSet): - queryset = Note.objects.all() - serializer_class = NoteSerializer - permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] - filterset_fields = ['is_public', 'collection'] - - # return error message if user is not authenticated on the root endpoint - def list(self, request, *args, **kwargs): - # Prevent listing all adventures - return Response({"detail": "Listing all notes is not allowed."}, - status=status.HTTP_403_FORBIDDEN) - - @action(detail=False, methods=['get']) - def all(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - queryset = Note.objects.filter( - Q(user_id=request.user.id) - ) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - - def get_queryset(self): - # if the user is not authenticated return only public transportations for retrieve action - if not self.request.user.is_authenticated: - if self.action == 'retrieve': - return Note.objects.filter(is_public=True).distinct().order_by('-updated_at') - return Note.objects.none() - - - if self.action == 'retrieve': - # For individual adventure retrieval, include public adventures - return Note.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - else: - # For other actions, include user's own adventures and shared adventures - return Note.objects.filter( - Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def perform_update(self, serializer): - serializer.save() - - # when creating an adventure, make sure the user is the owner of the collection or shared with the collection - def perform_create(self, serializer): - # Retrieve the collection from the validated data - collection = serializer.validated_data.get('collection') - - # Check if a collection is provided - if collection: - user = self.request.user - # Check if the user is the owner or is in the shared_with list - if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): - # Return an error response if the user does not have permission - raise PermissionDenied("You do not have permission to use this collection.") - # if collection the owner of the adventure is the owner of the collection - serializer.save(user_id=collection.user_id) - return - - # Save the adventure with the current user as the owner - serializer.save(user_id=self.request.user) - -class ChecklistViewSet(viewsets.ModelViewSet): - queryset = Checklist.objects.all() - serializer_class = ChecklistSerializer - permission_classes = [IsOwnerOrSharedWithFullAccess, IsPublicOrOwnerOrSharedWithFullAccess] - filterset_fields = ['is_public', 'collection'] - - # return error message if user is not authenticated on the root endpoint - def list(self, request, *args, **kwargs): - # Prevent listing all adventures - return Response({"detail": "Listing all checklists is not allowed."}, - status=status.HTTP_403_FORBIDDEN) - - @action(detail=False, methods=['get']) - def all(self, request): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=400) - queryset = Checklist.objects.filter( - Q(user_id=request.user.id) - ) - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) - - - def get_queryset(self): - # if the user is not authenticated return only public transportations for retrieve action - if not self.request.user.is_authenticated: - if self.action == 'retrieve': - return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at') - return Checklist.objects.none() - - - if self.action == 'retrieve': - # For individual adventure retrieval, include public adventures - return Checklist.objects.filter( - Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - else: - # For other actions, include user's own adventures and shared adventures - return Checklist.objects.filter( - Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) - ).distinct().order_by('-updated_at') - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def partial_update(self, request, *args, **kwargs): - # Retrieve the current object - instance = self.get_object() - - # Partially update the instance with the request data - serializer = self.get_serializer(instance, data=request.data, partial=True) - serializer.is_valid(raise_exception=True) - - # Retrieve the collection from the validated data - new_collection = serializer.validated_data.get('collection') - - user = request.user - print(new_collection) - - if new_collection is not None and new_collection!=instance.collection: - # Check if the user is the owner of the new collection - if new_collection.user_id != user or instance.user_id != user: - raise PermissionDenied("You do not have permission to use this collection.") - elif new_collection is None: - # Handle the case where the user is trying to set the collection to None - if instance.collection is not None and instance.collection.user_id != user: - raise PermissionDenied("You cannot remove the collection as you are not the owner.") - - # Perform the update - self.perform_update(serializer) - - # Return the updated instance - return Response(serializer.data) - - def perform_update(self, serializer): - serializer.save() - - # when creating an adventure, make sure the user is the owner of the collection or shared with the collection - def perform_create(self, serializer): - # Retrieve the collection from the validated data - collection = serializer.validated_data.get('collection') - - # Check if a collection is provided - if collection: - user = self.request.user - # Check if the user is the owner or is in the shared_with list - if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): - # Return an error response if the user does not have permission - raise PermissionDenied("You do not have permission to use this collection.") - # if collection the owner of the adventure is the owner of the collection - serializer.save(user_id=collection.user_id) - return - - # Save the adventure with the current user as the owner - serializer.save(user_id=self.request.user) - -class AdventureImageViewSet(viewsets.ModelViewSet): - serializer_class = AdventureImageSerializer - permission_classes = [IsAuthenticated] - - def dispatch(self, request, *args, **kwargs): - print(f"Method: {request.method}") - return super().dispatch(request, *args, **kwargs) - - @action(detail=True, methods=['post']) - def image_delete(self, request, *args, **kwargs): - return self.destroy(request, *args, **kwargs) - - - def create(self, request, *args, **kwargs): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) - adventure_id = request.data.get('adventure') - try: - adventure = Adventure.objects.get(id=adventure_id) - except Adventure.DoesNotExist: - return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) - - if adventure.user_id != request.user: - # Check if the adventure has a collection - if adventure.collection: - # Check if the user is in the collection's shared_with list - if not adventure.collection.shared_with.filter(id=request.user.id).exists(): - return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) - else: - return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) - - return super().create(request, *args, **kwargs) - - def update(self, request, *args, **kwargs): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) - - adventure_id = request.data.get('adventure') - try: - adventure = Adventure.objects.get(id=adventure_id) - except Adventure.DoesNotExist: - return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) - - if adventure.user_id != request.user: - return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) - - return super().update(request, *args, **kwargs) - - def perform_destroy(self, instance): - print("perform_destroy") - return super().perform_destroy(instance) - - def destroy(self, request, *args, **kwargs): - print("destroy") - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) - - instance = self.get_object() - adventure = instance.adventure - if adventure.user_id != request.user: - return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) - - return super().destroy(request, *args, **kwargs) - - def partial_update(self, request, *args, **kwargs): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) - - instance = self.get_object() - adventure = instance.adventure - if adventure.user_id != request.user: - return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) - - return super().partial_update(request, *args, **kwargs) - - @action(detail=False, methods=['GET'], url_path='(?P[0-9a-f-]+)') - def adventure_images(self, request, adventure_id=None, *args, **kwargs): - if not request.user.is_authenticated: - return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) - - try: - adventure_uuid = uuid.UUID(adventure_id) - except ValueError: - return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST) - - queryset = AdventureImage.objects.filter( - Q(adventure__id=adventure_uuid) & Q(user_id=request.user) - ) - - serializer = self.get_serializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) - - def get_queryset(self): - return AdventureImage.objects.filter(user_id=self.request.user) - - def perform_create(self, serializer): - serializer.save(user_id=self.request.user) - -class ReverseGeocodeViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - def extractIsoCode(self, data): - """ - Extract the ISO code from the response data. - Returns a dictionary containing the region name, country name, and ISO code if found. - """ - iso_code = None - town = None - city = None - county = None - display_name = None - country_code = 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'] - if 'county' in keys: - county = data['address']['county'] - if 'city' in keys: - city = 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 - 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 visited_region: - is_visited = True - if region: - return {"id": iso_code, "region": region.name, "country": region.country.name, "is_visited": is_visited, "display_name": display_name} - return {"error": "No region found"} - - @action(detail=False, methods=['get']) - def reverse_geocode(self, request): - lat = request.query_params.get('lat', '') - lon = request.query_params.get('lon', '') - url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" - headers = {'User-Agent': 'AdventureLog Server'} - response = requests.get(url, headers=headers) - try: - data = response.json() - except requests.exceptions.JSONDecodeError: - return Response({"error": "Invalid response from geocoding service"}, status=400) - return Response(self.extractIsoCode(data)) - - @action(detail=False, methods=['post']) - def mark_visited_region(self, request): - # searches through all of the users adventures, if the serialized data is_visited, is true, runs reverse geocode on the adventures and if a region is found, marks it as visited. Use the extractIsoCode function to get the region - new_region_count = 0 - new_regions = {} - adventures = Adventure.objects.filter(user_id=self.request.user) - serializer = AdventureSerializer(adventures, many=True) - for adventure, serialized_adventure in zip(adventures, serializer.data): - if serialized_adventure['is_visited'] == True: - lat = adventure.latitude - lon = adventure.longitude - url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" - headers = {'User-Agent': 'AdventureLog Server'} - response = requests.get(url, headers=headers) - try: - data = response.json() - except requests.exceptions.JSONDecodeError: - return Response({"error": "Invalid response from geocoding service"}, status=400) - region = self.extractIsoCode(data) - if 'error' not in region: - region = Region.objects.filter(id=region['id']).first() - visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first() - if not visited_region: - visited_region = VisitedRegion(region=region, user_id=self.request.user) - visited_region.save() - new_region_count += 1 - new_regions[region.id] = region.name - return Response({"new_regions": new_region_count, "regions": new_regions}) - - -from django.http import HttpResponse -from rest_framework import viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from icalendar import Calendar, Event, vText, vCalAddress -from datetime import datetime, timedelta - -class IcsCalendarGeneratorViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - @action(detail=False, methods=['get']) - def generate(self, request): - adventures = Adventure.objects.filter(user_id=request.user) - serializer = AdventureSerializer(adventures, many=True) - user = request.user - name = f"{user.first_name} {user.last_name}" - print(serializer.data) - - cal = Calendar() - cal.add('prodid', '-//My Adventure Calendar//example.com//') - cal.add('version', '2.0') - - for adventure in serializer.data: - if adventure['visits']: - for visit in adventure['visits']: - # Skip if start_date is missing - if not visit.get('start_date'): - continue - - # Parse start_date and handle end_date - try: - start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date() - except ValueError: - continue # Skip if the start_date is invalid - - end_date = ( - datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1) - if visit.get('end_date') else start_date + timedelta(days=1) - ) - - # Create event - event = Event() - event.add('summary', adventure['name']) - event.add('dtstart', start_date) - event.add('dtend', end_date) - event.add('dtstamp', datetime.now()) - event.add('transp', 'TRANSPARENT') - event.add('class', 'PUBLIC') - event.add('created', datetime.now()) - event.add('last-modified', datetime.now()) - event.add('description', adventure['description']) - if adventure.get('location'): - event.add('location', adventure['location']) - if adventure.get('link'): - event.add('url', adventure['link']) - - organizer = vCalAddress(f'MAILTO:{user.email}') - organizer.params['cn'] = vText(name) - event.add('organizer', organizer) - - cal.add_component(event) - - response = HttpResponse(cal.to_ical(), content_type='text/calendar') - response['Content-Disposition'] = 'attachment; filename=adventures.ics' - return response diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py new file mode 100644 index 0000000..8f531f7 --- /dev/null +++ b/backend/server/adventures/views/__init__.py @@ -0,0 +1,16 @@ +from .activity_types_view import * +from .adventure_image_view import * +from .adventure_view import * +from .category_view import * +from .checklist_view import * +from .collection_view import * +from .generate_description_view import * +from .ics_calendar_view import * +from .note_view import * +from .overpass_view import * +from .reverse_geocode_view import * +from .stats_view import * +from .transportation_view import * +from .global_search_view import * +from .attachment_view import * +from .lodging_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/activity_types_view.py b/backend/server/adventures/views/activity_types_view.py new file mode 100644 index 0000000..438c0d0 --- /dev/null +++ b/backend/server/adventures/views/activity_types_view.py @@ -0,0 +1,32 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from adventures.models import Adventure + +class ActivityTypesView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def types(self, request): + """ + Retrieve a list of distinct activity types for adventures associated with the current user. + + Args: + request (HttpRequest): The HTTP request object. + + Returns: + Response: A response containing a list of distinct activity types. + """ + types = Adventure.objects.filter(user_id=request.user.id).values_list('activity_types', flat=True).distinct() + + allTypes = [] + + for i in types: + if not i: + continue + for x in i: + if x and x not in allTypes: + allTypes.append(x) + + return Response(allTypes) \ No newline at end of file diff --git a/backend/server/adventures/views/adventure_image_view.py b/backend/server/adventures/views/adventure_image_view.py new file mode 100644 index 0000000..d76f6a5 --- /dev/null +++ b/backend/server/adventures/views/adventure_image_view.py @@ -0,0 +1,124 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Adventure, AdventureImage +from adventures.serializers import AdventureImageSerializer +import uuid + +class AdventureImageViewSet(viewsets.ModelViewSet): + serializer_class = AdventureImageSerializer + permission_classes = [IsAuthenticated] + + @action(detail=True, methods=['post']) + def image_delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + @action(detail=True, methods=['post']) + def toggle_primary(self, request, *args, **kwargs): + # Makes the image the primary image for the adventure, if there is already a primary image linked to the adventure, it is set to false and the new image is set to true. make sure that the permission is set to the owner of the adventure + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + + instance = self.get_object() + adventure = instance.adventure + if adventure.user_id != request.user: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + # Check if the image is already the primary image + if instance.is_primary: + return Response({"error": "Image is already the primary image"}, status=status.HTTP_400_BAD_REQUEST) + + # Set the current primary image to false + AdventureImage.objects.filter(adventure=adventure, is_primary=True).update(is_primary=False) + + # Set the new image to true + instance.is_primary = True + instance.save() + return Response({"success": "Image set as primary image"}) + + def create(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + adventure_id = request.data.get('adventure') + try: + adventure = Adventure.objects.get(id=adventure_id) + except Adventure.DoesNotExist: + return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) + + if adventure.user_id != request.user: + # Check if the adventure has a collection + if adventure.collection: + # Check if the user is in the collection's shared_with list + if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + + adventure_id = request.data.get('adventure') + try: + adventure = Adventure.objects.get(id=adventure_id) + except Adventure.DoesNotExist: + return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) + + if adventure.user_id != request.user: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().update(request, *args, **kwargs) + + def perform_destroy(self, instance): + print("perform_destroy") + return super().perform_destroy(instance) + + def destroy(self, request, *args, **kwargs): + print("destroy") + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + + instance = self.get_object() + adventure = instance.adventure + if adventure.user_id != request.user: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().destroy(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + + instance = self.get_object() + adventure = instance.adventure + if adventure.user_id != request.user: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().partial_update(request, *args, **kwargs) + + @action(detail=False, methods=['GET'], url_path='(?P[0-9a-f-]+)') + def adventure_images(self, request, adventure_id=None, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + adventure_uuid = uuid.UUID(adventure_id) + except ValueError: + return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST) + + queryset = AdventureImage.objects.filter( + Q(adventure__id=adventure_uuid) & Q(user_id=request.user) + ) + + serializer = self.get_serializer(queryset, many=True, context={'request': request}) + return Response(serializer.data) + + def get_queryset(self): + return AdventureImage.objects.filter(user_id=self.request.user) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py new file mode 100644 index 0000000..2f7e1f1 --- /dev/null +++ b/backend/server/adventures/views/adventure_view.py @@ -0,0 +1,217 @@ +from django.utils import timezone +from django.db import transaction +from django.core.exceptions import PermissionDenied +from django.db.models import Q, Max +from django.db.models.functions import Lower +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from adventures.models import Adventure, Category, Transportation, Lodging +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer +from adventures.utils import pagination + +class AdventureViewSet(viewsets.ModelViewSet): + serializer_class = AdventureSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + pagination_class = pagination.StandardResultsSetPagination + + def apply_sorting(self, queryset): + order_by = self.request.query_params.get('order_by', 'updated_at') + order_direction = self.request.query_params.get('order_direction', 'asc') + include_collections = self.request.query_params.get('include_collections', 'true') + + valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at'] + if order_by not in valid_order_by: + order_by = 'name' + + if order_direction not in ['asc', 'desc']: + order_direction = 'asc' + + if order_by == 'date': + queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False) + ordering = 'latest_visit' + elif order_by == 'name': + queryset = queryset.annotate(lower_name=Lower('name')) + ordering = 'lower_name' + elif order_by == 'rating': + queryset = queryset.filter(rating__isnull=False) + ordering = 'rating' + else: + ordering = order_by + + if order_direction == 'desc': + ordering = f'-{ordering}' + + if order_by == 'updated_at': + ordering = '-updated_at' if order_direction == 'asc' else 'updated_at' + + if include_collections == 'false': + queryset = queryset.filter(collection=None) + + return queryset.order_by(ordering) + + def get_queryset(self): + """ + Returns the queryset for the AdventureViewSet. Unauthenticated users can only + retrieve public adventures, while authenticated users can access their own, + shared, and public adventures depending on the action. + """ + user = self.request.user + + if not user.is_authenticated: + # Unauthenticated users can only access public adventures for retrieval + if self.action == 'retrieve': + return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at') + return Adventure.objects.none() + + # Authenticated users: Handle retrieval separately + include_public = self.action == 'retrieve' + return Adventure.objects.retrieve_adventures( + user, + include_public=include_public, + include_owned=True, + include_shared=True + ).order_by('-updated_at') + + def perform_update(self, serializer): + adventure = serializer.save() + if adventure.collection: + adventure.is_public = adventure.collection.is_public + adventure.save() + + @action(detail=False, methods=['get']) + def filtered(self, request): + types = request.query_params.get('types', '').split(',') + is_visited = request.query_params.get('is_visited', 'all') + + if 'all' in types: + types = Category.objects.filter(user_id=request.user).values_list('name', flat=True) + else: + if not types or not all( + Category.objects.filter(user_id=request.user, name=type).exists() for type in types + ): + return Response({"error": "Invalid category or no types provided"}, status=400) + + queryset = Adventure.objects.filter( + category__in=Category.objects.filter(name__in=types, user_id=request.user), + user_id=request.user.id + ) + + is_visited_param = request.query_params.get('is_visited') + if is_visited_param is not None: + # Convert is_visited_param to a boolean + if is_visited_param.lower() == 'true': + is_visited_bool = True + elif is_visited_param.lower() == 'false': + is_visited_bool = False + else: + is_visited_bool = None + + # Filter logic: "visited" means at least one visit with start_date <= today + now = timezone.now().date() + if is_visited_bool is True: + queryset = queryset.filter(visits__start_date__lte=now).distinct() + elif is_visited_bool is False: + queryset = queryset.exclude(visits__start_date__lte=now).distinct() + + queryset = self.apply_sorting(queryset) + return self.paginate_and_respond(queryset, request) + + @action(detail=False, methods=['get']) + def all(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + include_collections = request.query_params.get('include_collections', 'false') == 'true' + queryset = Adventure.objects.filter( + Q(is_public=True) | Q(user_id=request.user.id), + collection=None if not include_collections else Q() + ) + + queryset = self.apply_sorting(queryset) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + new_collection = serializer.validated_data.get('collection') + if new_collection and new_collection!=instance.collection: + if new_collection.user_id != request.user or instance.user_id != request.user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None and instance.collection and instance.collection.user_id != request.user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + self.perform_update(serializer) + return Response(serializer.data) + + @transaction.atomic + def perform_create(self, serializer): + collection = serializer.validated_data.get('collection') + + if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()): + raise PermissionDenied("You do not have permission to use this collection.") + elif collection: + serializer.save(user_id=collection.user_id, is_public=collection.is_public) + return + + serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False) + + def paginate_and_respond(self, queryset, request): + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request) + if page is not None: + serializer = self.get_serializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + # @action(detail=True, methods=['post']) + # def convert(self, request, pk=None): + # """ + # Convert an Adventure instance into a Transportation or Lodging instance. + # Expects a JSON body with "target_type": "transportation" or "lodging". + # """ + # adventure = self.get_object() + # target_type = request.data.get("target_type", "").lower() + + # if target_type not in ["transportation", "lodging"]: + # return Response( + # {"error": "Invalid target type. Must be 'transportation' or 'lodging'."}, + # status=400 + # ) + # if not adventure.collection: + # return Response( + # {"error": "Adventure must be part of a collection to be converted."}, + # status=400 + # ) + + # # Define the overlapping fields that both the Adventure and target models share. + # overlapping_fields = ["name", "description", "is_public", 'collection'] + + # # Gather the overlapping data from the adventure instance. + # conversion_data = {} + # for field in overlapping_fields: + # if hasattr(adventure, field): + # conversion_data[field] = getattr(adventure, field) + + # # Make sure to include the user reference + # conversion_data["user_id"] = adventure.user_id + + # # Convert the adventure instance within an atomic transaction. + # with transaction.atomic(): + # if target_type == "transportation": + # new_instance = Transportation.objects.create(**conversion_data) + # serializer = TransportationSerializer(new_instance) + # else: # target_type == "lodging" + # new_instance = Lodging.objects.create(**conversion_data) + # serializer = LodgingSerializer(new_instance) + + # # Optionally, delete the original adventure to avoid duplicates. + # adventure.delete() + + # return Response(serializer.data) diff --git a/backend/server/adventures/views/attachment_view.py b/backend/server/adventures/views/attachment_view.py new file mode 100644 index 0000000..0292b16 --- /dev/null +++ b/backend/server/adventures/views/attachment_view.py @@ -0,0 +1,40 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from adventures.models import Adventure, Attachment +from adventures.serializers import AttachmentSerializer + +class AttachmentViewSet(viewsets.ModelViewSet): + serializer_class = AttachmentSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Attachment.objects.filter(user_id=self.request.user) + + @action(detail=True, methods=['post']) + def attachment_delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=status.HTTP_401_UNAUTHORIZED) + adventure_id = request.data.get('adventure') + try: + adventure = Adventure.objects.get(id=adventure_id) + except Adventure.DoesNotExist: + return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND) + + if adventure.user_id != request.user: + # Check if the adventure has a collection + if adventure.collection: + # Check if the user is in the collection's shared_with list + if not adventure.collection.shared_with.filter(id=request.user.id).exists(): + return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN) + else: + return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN) + + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/category_view.py b/backend/server/adventures/views/category_view.py new file mode 100644 index 0000000..4bde278 --- /dev/null +++ b/backend/server/adventures/views/category_view.py @@ -0,0 +1,42 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from adventures.models import Category, Adventure +from adventures.serializers import CategorySerializer + +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all() + serializer_class = CategorySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Category.objects.filter(user_id=self.request.user) + + @action(detail=False, methods=['get']) + def categories(self, request): + """ + Retrieve a list of distinct categories for adventures associated with the current user. + """ + categories = self.get_queryset().distinct() + serializer = self.get_serializer(categories, many=True) + return Response(serializer.data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.user_id != request.user: + return Response({"error": "User does not own this category"}, status + =400) + + if instance.name == 'general': + return Response({"error": "Cannot delete the general category"}, status=400) + + # set any adventures with this category to a default category called general before deleting the category, if general does not exist create it for the user + general_category = Category.objects.filter(user_id=request.user, name='general').first() + + if not general_category: + general_category = Category.objects.create(user_id=request.user, name='general', icon='🌍', display_name='General') + + Adventure.objects.filter(category=instance).update(category=general_category) + + return super().destroy(request, *args, **kwargs) \ No newline at end of file diff --git a/backend/server/adventures/views/checklist_view.py b/backend/server/adventures/views/checklist_view.py new file mode 100644 index 0000000..6824f03 --- /dev/null +++ b/backend/server/adventures/views/checklist_view.py @@ -0,0 +1,130 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Checklist +from adventures.serializers import ChecklistSerializer +from rest_framework.exceptions import PermissionDenied +from adventures.permissions import IsOwnerOrSharedWithFullAccess + +class ChecklistViewSet(viewsets.ModelViewSet): + queryset = Checklist.objects.all() + serializer_class = ChecklistSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + filterset_fields = ['is_public', 'collection'] + + # return error message if user is not authenticated on the root endpoint + def list(self, request, *args, **kwargs): + # Prevent listing all adventures + return Response({"detail": "Listing all checklists is not allowed."}, + status=status.HTTP_403_FORBIDDEN) + + @action(detail=False, methods=['get']) + def all(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Checklist.objects.filter( + Q(user_id=request.user.id) + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + + def get_queryset(self): + # if the user is not authenticated return only public transportations for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Checklist.objects.filter(is_public=True).distinct().order_by('-updated_at') + return Checklist.objects.none() + + + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures + return Checklist.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ).distinct().order_by('-updated_at') + else: + # For other actions, include user's own adventures and shared adventures + return Checklist.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ).distinct().order_by('-updated_at') + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection + def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py new file mode 100644 index 0000000..f0529ee --- /dev/null +++ b/backend/server/adventures/views/collection_view.py @@ -0,0 +1,218 @@ +from django.db.models import Q +from django.db.models.functions import Lower +from django.db import transaction +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from adventures.models import Collection, Adventure, Transportation, Note +from adventures.permissions import CollectionShared +from adventures.serializers import CollectionSerializer +from users.models import CustomUser as User +from adventures.utils import pagination + +class CollectionViewSet(viewsets.ModelViewSet): + serializer_class = CollectionSerializer + permission_classes = [CollectionShared] + pagination_class = pagination.StandardResultsSetPagination + + # def get_queryset(self): + # return Collection.objects.filter(Q(user_id=self.request.user.id) & Q(is_archived=False)) + + def apply_sorting(self, queryset): + order_by = self.request.query_params.get('order_by', 'name') + order_direction = self.request.query_params.get('order_direction', 'asc') + + valid_order_by = ['name', 'upated_at'] + if order_by not in valid_order_by: + order_by = 'updated_at' + + if order_direction not in ['asc', 'desc']: + order_direction = 'asc' + + # Apply case-insensitive sorting for the 'name' field + if order_by == 'name': + queryset = queryset.annotate(lower_name=Lower('name')) + ordering = 'lower_name' + if order_direction == 'desc': + ordering = f'-{ordering}' + else: + order_by == 'updated_at' + ordering = 'updated_at' + if order_direction == 'asc': + ordering = '-updated_at' + + #print(f"Ordering by: {ordering}") # For debugging + + return queryset.order_by(ordering) + + def list(self, request, *args, **kwargs): + # make sure the user is authenticated + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Collection.objects.filter(user_id=request.user.id) + queryset = self.apply_sorting(queryset) + collections = self.paginate_and_respond(queryset, request) + return collections + + @action(detail=False, methods=['get']) + def all(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + queryset = Collection.objects.filter( + Q(user_id=request.user.id) + ) + + queryset = self.apply_sorting(queryset) + serializer = self.get_serializer(queryset, many=True) + + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def archived(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + + queryset = Collection.objects.filter( + Q(user_id=request.user.id) & Q(is_archived=True) + ) + + queryset = self.apply_sorting(queryset) + serializer = self.get_serializer(queryset, many=True) + + return Response(serializer.data) + + # this make the is_public field of the collection cascade to the adventures + @transaction.atomic + def update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + if 'collection' in serializer.validated_data: + new_collection = serializer.validated_data['collection'] + # if the new collection is different from the old one and the user making the request is not the owner of the new collection return an error + if new_collection != instance.collection and new_collection.user_id != request.user: + return Response({"error": "User does not own the new collection"}, status=400) + + # Check if the 'is_public' field is present in the update data + if 'is_public' in serializer.validated_data: + new_public_status = serializer.validated_data['is_public'] + + # if is_publuc has changed and the user is not the owner of the collection return an error + if new_public_status != instance.is_public and instance.user_id != request.user: + print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}") + return Response({"error": "User does not own the collection"}, status=400) + + # Update associated adventures to match the collection's is_public status + Adventure.objects.filter(collection=instance).update(is_public=new_public_status) + + # do the same for transportations + Transportation.objects.filter(collection=instance).update(is_public=new_public_status) + + # do the same for notes + Note.objects.filter(collection=instance).update(is_public=new_public_status) + + # Log the action (optional) + action = "public" if new_public_status else "private" + print(f"Collection {instance.id} and its adventures were set to {action}") + + self.perform_update(serializer) + + if getattr(instance, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + return Response(serializer.data) + + # make an action to retreive all adventures that are shared with the user + @action(detail=False, methods=['get']) + def shared(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Collection.objects.filter( + shared_with=request.user + ) + queryset = self.apply_sorting(queryset) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + # Adds a new user to the shared_with field of an adventure + @action(detail=True, methods=['post'], url_path='share/(?P[^/.]+)') + def share(self, request, pk=None, uuid=None): + collection = self.get_object() + if not uuid: + return Response({"error": "User UUID is required"}, status=400) + try: + user = User.objects.get(uuid=uuid, public_profile=True) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + if user == request.user: + return Response({"error": "Cannot share with yourself"}, status=400) + + if collection.shared_with.filter(id=user.id).exists(): + return Response({"error": "Adventure is already shared with this user"}, status=400) + + collection.shared_with.add(user) + collection.save() + return Response({"success": f"Shared with {user.username}"}) + + @action(detail=True, methods=['post'], url_path='unshare/(?P[^/.]+)') + def unshare(self, request, pk=None, uuid=None): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + collection = self.get_object() + if not uuid: + return Response({"error": "User UUID is required"}, status=400) + try: + user = User.objects.get(uuid=uuid, public_profile=True) + except User.DoesNotExist: + return Response({"error": "User not found"}, status=404) + + if user == request.user: + return Response({"error": "Cannot unshare with yourself"}, status=400) + + if not collection.shared_with.filter(id=user.id).exists(): + return Response({"error": "Collection is not shared with this user"}, status=400) + + collection.shared_with.remove(user) + collection.save() + return Response({"success": f"Unshared with {user.username}"}) + + def get_queryset(self): + if self.action == 'destroy': + return Collection.objects.filter(user_id=self.request.user.id) + + if self.action in ['update', 'partial_update']: + return Collection.objects.filter( + Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) + ).distinct() + + if self.action == 'retrieve': + if not self.request.user.is_authenticated: + return Collection.objects.filter(is_public=True) + return Collection.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(shared_with=self.request.user) + ).distinct() + + # For list action, include collections owned by the user or shared with the user, that are not archived + return Collection.objects.filter( + (Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False) + ).distinct() + + + def perform_create(self, serializer): + # This is ok because you cannot share a collection when creating it + serializer.save(user_id=self.request.user) + + def paginate_and_respond(self, queryset, request): + paginator = self.pagination_class() + page = paginator.paginate_queryset(queryset, request) + if page is not None: + serializer = self.get_serializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/backend/server/adventures/views/generate_description_view.py b/backend/server/adventures/views/generate_description_view.py new file mode 100644 index 0000000..988773a --- /dev/null +++ b/backend/server/adventures/views/generate_description_view.py @@ -0,0 +1,44 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +import requests + +class GenerateDescription(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get'],) + def desc(self, request): + name = self.request.query_params.get('name', '') + # un url encode the name + name = name.replace('%20', ' ') + name = self.get_search_term(name) + url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=extracts&exintro&explaintext&format=json&titles=%s' % name + response = requests.get(url) + data = response.json() + data = response.json() + page_id = next(iter(data["query"]["pages"])) + extract = data["query"]["pages"][page_id] + if extract.get('extract') is None: + return Response({"error": "No description found"}, status=400) + return Response(extract) + @action(detail=False, methods=['get'],) + def img(self, request): + name = self.request.query_params.get('name', '') + # un url encode the name + name = name.replace('%20', ' ') + name = self.get_search_term(name) + url = 'https://en.wikipedia.org/w/api.php?origin=*&action=query&prop=pageimages&format=json&piprop=original&titles=%s' % name + response = requests.get(url) + data = response.json() + page_id = next(iter(data["query"]["pages"])) + extract = data["query"]["pages"][page_id] + if extract.get('original') is None: + return Response({"error": "No image found"}, status=400) + return Response(extract["original"]) + + def get_search_term(self, term): + response = requests.get(f'https://en.wikipedia.org/w/api.php?action=opensearch&search={term}&limit=10&namespace=0&format=json') + data = response.json() + if data[1] and len(data[1]) > 0: + return data[1][0] \ No newline at end of file diff --git a/backend/server/adventures/views/global_search_view.py b/backend/server/adventures/views/global_search_view.py new file mode 100644 index 0000000..d2fa5d3 --- /dev/null +++ b/backend/server/adventures/views/global_search_view.py @@ -0,0 +1,73 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from django.db.models import Q +from django.contrib.postgres.search import SearchVector, SearchQuery +from adventures.models import Adventure, Collection +from adventures.serializers import AdventureSerializer, CollectionSerializer +from worldtravel.models import Country, Region, City, VisitedCity, VisitedRegion +from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer, VisitedCitySerializer, VisitedRegionSerializer +from users.models import CustomUser as User +from users.serializers import CustomUserDetailsSerializer as UserSerializer + +class GlobalSearchView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def list(self, request): + search_term = request.query_params.get('query', '').strip() + if not search_term: + return Response({"error": "Search query is required"}, status=400) + + # Initialize empty results + results = { + "adventures": [], + "collections": [], + "users": [], + "countries": [], + "regions": [], + "cities": [], + "visited_regions": [], + "visited_cities": [] + } + + # Adventures: Full-Text Search + adventures = Adventure.objects.annotate( + search=SearchVector('name', 'description', 'location') + ).filter(search=SearchQuery(search_term), user_id=request.user) + results["adventures"] = AdventureSerializer(adventures, many=True).data + + # Collections: Partial Match Search + collections = Collection.objects.filter( + Q(name__icontains=search_term) & Q(user_id=request.user) + ) + results["collections"] = CollectionSerializer(collections, many=True).data + + # Users: Public Profiles Only + users = User.objects.filter( + (Q(username__icontains=search_term) | + Q(first_name__icontains=search_term) | + Q(last_name__icontains=search_term)) & Q(public_profile=True) + ) + results["users"] = UserSerializer(users, many=True).data + + # Countries: Full-Text Search + countries = Country.objects.annotate( + search=SearchVector('name', 'country_code') + ).filter(search=SearchQuery(search_term)) + results["countries"] = CountrySerializer(countries, many=True).data + + # Regions and Cities: Partial Match Search + regions = Region.objects.filter(Q(name__icontains=search_term)) + results["regions"] = RegionSerializer(regions, many=True).data + + cities = City.objects.filter(Q(name__icontains=search_term)) + results["cities"] = CitySerializer(cities, many=True).data + + # Visited Regions and Cities + visited_regions = VisitedRegion.objects.filter(user_id=request.user) + results["visited_regions"] = VisitedRegionSerializer(visited_regions, many=True).data + + visited_cities = VisitedCity.objects.filter(user_id=request.user) + results["visited_cities"] = VisitedCitySerializer(visited_cities, many=True).data + + return Response(results) diff --git a/backend/server/adventures/views/ics_calendar_view.py b/backend/server/adventures/views/ics_calendar_view.py new file mode 100644 index 0000000..9d120e4 --- /dev/null +++ b/backend/server/adventures/views/ics_calendar_view.py @@ -0,0 +1,67 @@ +from django.http import HttpResponse +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from icalendar import Calendar, Event, vText, vCalAddress +from datetime import datetime, timedelta +from adventures.models import Adventure +from adventures.serializers import AdventureSerializer + +class IcsCalendarGeneratorViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def generate(self, request): + adventures = Adventure.objects.filter(user_id=request.user) + serializer = AdventureSerializer(adventures, many=True) + user = request.user + name = f"{user.first_name} {user.last_name}" + print(serializer.data) + + cal = Calendar() + cal.add('prodid', '-//My Adventure Calendar//example.com//') + cal.add('version', '2.0') + + for adventure in serializer.data: + if adventure['visits']: + for visit in adventure['visits']: + # Skip if start_date is missing + if not visit.get('start_date'): + continue + + # Parse start_date and handle end_date + try: + start_date = datetime.strptime(visit['start_date'], '%Y-%m-%d').date() + except ValueError: + continue # Skip if the start_date is invalid + + end_date = ( + datetime.strptime(visit['end_date'], '%Y-%m-%d').date() + timedelta(days=1) + if visit.get('end_date') else start_date + timedelta(days=1) + ) + + # Create event + event = Event() + event.add('summary', adventure['name']) + event.add('dtstart', start_date) + event.add('dtend', end_date) + event.add('dtstamp', datetime.now()) + event.add('transp', 'TRANSPARENT') + event.add('class', 'PUBLIC') + event.add('created', datetime.now()) + event.add('last-modified', datetime.now()) + event.add('description', adventure['description']) + if adventure.get('location'): + event.add('location', adventure['location']) + if adventure.get('link'): + event.add('url', adventure['link']) + + organizer = vCalAddress(f'MAILTO:{user.email}') + organizer.params['cn'] = vText(name) + event.add('organizer', organizer) + + cal.add_component(event) + + response = HttpResponse(cal.to_ical(), content_type='text/calendar') + response['Content-Disposition'] = 'attachment; filename=adventures.ics' + return response \ No newline at end of file diff --git a/backend/server/adventures/views/lodging_view.py b/backend/server/adventures/views/lodging_view.py new file mode 100644 index 0000000..16114ba --- /dev/null +++ b/backend/server/adventures/views/lodging_view.py @@ -0,0 +1,84 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Lodging +from adventures.serializers import LodgingSerializer +from rest_framework.exceptions import PermissionDenied +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.permissions import IsAuthenticated + +class LodgingViewSet(viewsets.ModelViewSet): + queryset = Lodging.objects.all() + serializer_class = LodgingSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def list(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response(status=status.HTTP_403_FORBIDDEN) + queryset = Lodging.objects.filter( + Q(user_id=request.user.id) + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def get_queryset(self): + user = self.request.user + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures, user's own adventures and shared adventures + return Lodging.objects.filter( + Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id) + ).distinct().order_by('-updated_at') + # For other actions, include user's own adventures and shared adventures + return Lodging.objects.filter( + Q(user_id=user.id) | Q(collection__shared_with=user.id) + ).distinct().order_by('-updated_at') + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + user = request.user + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + if new_collection is not None and new_collection != instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection + def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/adventures/views/note_view.py b/backend/server/adventures/views/note_view.py new file mode 100644 index 0000000..5f5f314 --- /dev/null +++ b/backend/server/adventures/views/note_view.py @@ -0,0 +1,130 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Note +from adventures.serializers import NoteSerializer +from rest_framework.exceptions import PermissionDenied +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.decorators import action + +class NoteViewSet(viewsets.ModelViewSet): + queryset = Note.objects.all() + serializer_class = NoteSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + filterset_fields = ['is_public', 'collection'] + + # return error message if user is not authenticated on the root endpoint + def list(self, request, *args, **kwargs): + # Prevent listing all adventures + return Response({"detail": "Listing all notes is not allowed."}, + status=status.HTTP_403_FORBIDDEN) + + @action(detail=False, methods=['get']) + def all(self, request): + if not request.user.is_authenticated: + return Response({"error": "User is not authenticated"}, status=400) + queryset = Note.objects.filter( + Q(user_id=request.user.id) + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + + def get_queryset(self): + # if the user is not authenticated return only public transportations for retrieve action + if not self.request.user.is_authenticated: + if self.action == 'retrieve': + return Note.objects.filter(is_public=True).distinct().order_by('-updated_at') + return Note.objects.none() + + + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures + return Note.objects.filter( + Q(is_public=True) | Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ).distinct().order_by('-updated_at') + else: + # For other actions, include user's own adventures and shared adventures + return Note.objects.filter( + Q(user_id=self.request.user.id) | Q(collection__shared_with=self.request.user) + ).distinct().order_by('-updated_at') + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + user = request.user + print(new_collection) + + if new_collection is not None and new_collection!=instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection + def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner + serializer.save(user_id=self.request.user) diff --git a/backend/server/adventures/views/overpass_view.py b/backend/server/adventures/views/overpass_view.py new file mode 100644 index 0000000..a72b4a7 --- /dev/null +++ b/backend/server/adventures/views/overpass_view.py @@ -0,0 +1,183 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +import requests + +class OverpassViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + BASE_URL = "https://overpass-api.de/api/interpreter" + HEADERS = {'User-Agent': 'AdventureLog Server'} + + def make_overpass_query(self, query): + """ + Sends a query to the Overpass API and returns the response data. + Args: + query (str): The Overpass QL query string. + Returns: + dict: Parsed JSON response from the Overpass API. + Raises: + Response: DRF Response object with an error message in case of failure. + """ + url = f"{self.BASE_URL}?data={query}" + try: + response = requests.get(url, headers=self.HEADERS) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.RequestException: + return Response({"error": "Failed to connect to Overpass API"}, status=500) + except requests.exceptions.JSONDecodeError: + return Response({"error": "Invalid response from Overpass API"}, status=400) + + def parse_overpass_response(self, data, request): + """ + Parses the JSON response from the Overpass API and extracts relevant data, + turning it into an adventure-structured object. + + Args: + response (dict): The JSON response from the Overpass API. + + Returns: + list: A list of adventure objects with structured data. + """ + # Extract elements (nodes/ways/relations) from the response + nodes = data.get('elements', []) + adventures = [] + + # include all entries, even the ones that do not have lat long + all = request.query_params.get('all', False) + + for node in nodes: + # Ensure we are working with a "node" type (can also handle "way" or "relation" if needed) + if node.get('type') not in ['node', 'way', 'relation']: + continue + + # Extract tags and general data + tags = node.get('tags', {}) + adventure = { + "id": node.get('id'), # Include the unique OSM ID + "type": node.get('type'), # Type of element (node, way, relation) + "name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name' + "description": tags.get('description', None), # Additional descriptive information + "latitude": node.get('lat', None), # Use None for consistency with missing values + "longitude": node.get('lon', None), + "address": { + "city": tags.get('addr:city', None), + "housenumber": tags.get('addr:housenumber', None), + "postcode": tags.get('addr:postcode', None), + "state": tags.get('addr:state', None), + "street": tags.get('addr:street', None), + "country": tags.get('addr:country', None), # Add 'country' if available + "suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity + }, + "feature_id": tags.get('gnis:feature_id', None), + "tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None), + "contact": { + "phone": tags.get('phone', None), + "email": tags.get('contact:email', None), + "website": tags.get('website', None), + "facebook": tags.get('contact:facebook', None), # Social media links + "twitter": tags.get('contact:twitter', None), + }, + # "tags": tags, # Include all raw tags for future use + } + + # Filter out adventures with no name, latitude, or longitude + if (adventure["name"] and + adventure["latitude"] is not None and -90 <= adventure["latitude"] <= 90 and + adventure["longitude"] is not None and -180 <= adventure["longitude"] <= 180) or all: + adventures.append(adventure) + + return adventures + + + @action(detail=False, methods=['get']) + def query(self, request): + """ + Radius-based search for tourism-related locations around given coordinates. + """ + lat = request.query_params.get('lat') + lon = request.query_params.get('lon') + radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters + + valid_categories = ['lodging', 'food', 'tourism'] + category = request.query_params.get('category', 'all') + if category not in valid_categories: + return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400) + + if category == 'tourism': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"]; + node(around:{radius},{lat},{lon})["leisure"]; + node(around:{radius},{lat},{lon})["historic"]; + node(around:{radius},{lat},{lon})["sport"]; + node(around:{radius},{lat},{lon})["natural"]; + node(around:{radius},{lat},{lon})["attraction"]; + node(around:{radius},{lat},{lon})["museum"]; + node(around:{radius},{lat},{lon})["zoo"]; + node(around:{radius},{lat},{lon})["aquarium"]; + ); + out; + """ + if category == 'lodging': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"="hotel"]; + node(around:{radius},{lat},{lon})["tourism"="motel"]; + node(around:{radius},{lat},{lon})["tourism"="guest_house"]; + node(around:{radius},{lat},{lon})["tourism"="hostel"]; + node(around:{radius},{lat},{lon})["tourism"="camp_site"]; + node(around:{radius},{lat},{lon})["tourism"="caravan_site"]; + node(around:{radius},{lat},{lon})["tourism"="chalet"]; + node(around:{radius},{lat},{lon})["tourism"="alpine_hut"]; + node(around:{radius},{lat},{lon})["tourism"="apartment"]; + ); + out; + """ + if category == 'food': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["amenity"="restaurant"]; + node(around:{radius},{lat},{lon})["amenity"="cafe"]; + node(around:{radius},{lat},{lon})["amenity"="fast_food"]; + node(around:{radius},{lat},{lon})["amenity"="pub"]; + node(around:{radius},{lat},{lon})["amenity"="bar"]; + node(around:{radius},{lat},{lon})["amenity"="food_court"]; + node(around:{radius},{lat},{lon})["amenity"="ice_cream"]; + node(around:{radius},{lat},{lon})["amenity"="bakery"]; + node(around:{radius},{lat},{lon})["amenity"="confectionery"]; + ); + out; + """ + + # Validate required parameters + if not lat or not lon: + return Response( + {"error": "Latitude and longitude parameters are required."}, status=400 + ) + + data = self.make_overpass_query(query) + adventures = self.parse_overpass_response(data, request) + return Response(adventures) + + @action(detail=False, methods=['get'], url_path='search') + def search(self, request): + """ + Name-based search for nodes with the specified name. + """ + name = request.query_params.get('name') + + # Validate required parameter + if not name: + return Response({"error": "Name parameter is required."}, status=400) + + # Construct Overpass API query + query = f'[out:json];node["name"~"{name}",i];out;' + data = self.make_overpass_query(query) + + adventures = self.parse_overpass_response(data, request) + return Response(adventures) diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py new file mode 100644 index 0000000..4dc1d6b --- /dev/null +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -0,0 +1,121 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from worldtravel.models import Region, City, VisitedRegion, VisitedCity +from adventures.models import Adventure +from adventures.serializers import AdventureSerializer +import requests + +class ReverseGeocodeViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def extractIsoCode(self, data): + """ + Extract the ISO code from the response data. + Returns a dictionary containing the region name, country name, and ISO code if found. + """ + iso_code = None + town_city_or_county = None + display_name = None + country_code = None + city = None + visited_city = None + location_name = None + + # town = None + # city = None + # county = None + + if 'name' in data.keys(): + location_name = data['name'] + + 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_city_or_county = data['address']['town'] + if 'county' in keys: + town_city_or_county = data['address']['county'] + if 'city' in keys: + 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() + + region_visited = False + city_visited = False + country_code = iso_code[:2] + + if region: + 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: + region_visited = True + if visited_city: + city_visited = True + if region: + 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, 'location_name': location_name} + return {"error": "No region found"} + + @action(detail=False, methods=['get']) + def reverse_geocode(self, request): + lat = request.query_params.get('lat', '') + lon = request.query_params.get('lon', '') + url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" + headers = {'User-Agent': 'AdventureLog Server'} + response = requests.get(url, headers=headers) + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + return Response({"error": "Invalid response from geocoding service"}, status=400) + return Response(self.extractIsoCode(data)) + + @action(detail=False, methods=['post']) + def mark_visited_region(self, request): + # searches through all of the users adventures, if the serialized data is_visited, is true, runs reverse geocode on the adventures and if a region is found, marks it as visited. Use the extractIsoCode function to get the region + new_region_count = 0 + new_regions = {} + new_city_count = 0 + new_cities = {} + adventures = Adventure.objects.filter(user_id=self.request.user) + serializer = AdventureSerializer(adventures, many=True) + for adventure, serialized_adventure in zip(adventures, serializer.data): + if serialized_adventure['is_visited'] == True: + lat = adventure.latitude + lon = adventure.longitude + if not lat or not lon: + continue + url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" + headers = {'User-Agent': 'AdventureLog Server'} + response = requests.get(url, headers=headers) + try: + data = response.json() + except requests.exceptions.JSONDecodeError: + return Response({"error": "Invalid response from geocoding service"}, status=400) + extracted_region = self.extractIsoCode(data) + if 'error' not in extracted_region: + region = Region.objects.filter(id=extracted_region['region_id']).first() + visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first() + if not visited_region: + visited_region = VisitedRegion(region=region, user_id=self.request.user) + visited_region.save() + new_region_count += 1 + new_regions[region.id] = region.name + + if extracted_region['city_id'] is not None: + city = City.objects.filter(id=extracted_region['city_id']).first() + visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first() + if not visited_city: + visited_city = VisitedCity(city=city, user_id=self.request.user) + visited_city.save() + new_city_count += 1 + new_cities[city.id] = city.name + return Response({"new_regions": new_region_count, "regions": new_regions, "new_cities": new_city_count, "cities": new_cities}) \ No newline at end of file diff --git a/backend/server/adventures/views/stats_view.py b/backend/server/adventures/views/stats_view.py new file mode 100644 index 0000000..4b3a524 --- /dev/null +++ b/backend/server/adventures/views/stats_view.py @@ -0,0 +1,51 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.decorators import action +from django.shortcuts import get_object_or_404 +from worldtravel.models import City, Region, Country, VisitedCity, VisitedRegion +from adventures.models import Adventure, Collection +from users.serializers import CustomUserDetailsSerializer as PublicUserSerializer +from django.contrib.auth import get_user_model + +User = get_user_model() + +class StatsViewSet(viewsets.ViewSet): + """ + A simple ViewSet for listing the stats of a user. + """ + @action(detail=False, methods=['get'], url_path='counts/(?P[\w.@+-]+)') + def counts(self, request, username): + if request.user.username == username: + user = get_object_or_404(User, username=username) + else: + user = get_object_or_404(User, username=username, public_profile=True) + # serializer = PublicUserSerializer(user) + + # remove the email address from the response + user.email = None + + # get the counts for the user + adventure_count = Adventure.objects.filter( + user_id=user.id).count() + trips_count = Collection.objects.filter( + user_id=user.id).count() + visited_city_count = VisitedCity.objects.filter( + user_id=user.id).count() + total_cities = City.objects.count() + visited_region_count = VisitedRegion.objects.filter( + user_id=user.id).count() + total_regions = Region.objects.count() + visited_country_count = VisitedRegion.objects.filter( + user_id=user.id).values('region__country').distinct().count() + total_countries = Country.objects.count() + return Response({ + 'adventure_count': adventure_count, + 'trips_count': trips_count, + 'visited_city_count': visited_city_count, + 'total_cities': total_cities, + 'visited_region_count': visited_region_count, + 'total_regions': total_regions, + 'visited_country_count': visited_country_count, + 'total_countries': total_countries + }) \ No newline at end of file diff --git a/backend/server/adventures/views/transportation_view.py b/backend/server/adventures/views/transportation_view.py new file mode 100644 index 0000000..2bd1e8c --- /dev/null +++ b/backend/server/adventures/views/transportation_view.py @@ -0,0 +1,84 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.db.models import Q +from adventures.models import Transportation +from adventures.serializers import TransportationSerializer +from rest_framework.exceptions import PermissionDenied +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.permissions import IsAuthenticated + +class TransportationViewSet(viewsets.ModelViewSet): + queryset = Transportation.objects.all() + serializer_class = TransportationSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def list(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response(status=status.HTTP_403_FORBIDDEN) + queryset = Transportation.objects.filter( + Q(user_id=request.user.id) + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def get_queryset(self): + user = self.request.user + if self.action == 'retrieve': + # For individual adventure retrieval, include public adventures, user's own adventures and shared adventures + return Transportation.objects.filter( + Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id) + ).distinct().order_by('-updated_at') + # For other actions, include user's own adventures and shared adventures + return Transportation.objects.filter( + Q(user_id=user.id) | Q(collection__shared_with=user.id) + ).distinct().order_by('-updated_at') + + def partial_update(self, request, *args, **kwargs): + # Retrieve the current object + instance = self.get_object() + user = request.user + + # Partially update the instance with the request data + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + # Retrieve the collection from the validated data + new_collection = serializer.validated_data.get('collection') + + if new_collection is not None and new_collection != instance.collection: + # Check if the user is the owner of the new collection + if new_collection.user_id != user or instance.user_id != user: + raise PermissionDenied("You do not have permission to use this collection.") + elif new_collection is None: + # Handle the case where the user is trying to set the collection to None + if instance.collection is not None and instance.collection.user_id != user: + raise PermissionDenied("You cannot remove the collection as you are not the owner.") + + # Perform the update + self.perform_update(serializer) + + # Return the updated instance + return Response(serializer.data) + + def perform_update(self, serializer): + serializer.save() + + # when creating an adventure, make sure the user is the owner of the collection or shared with the collection + def perform_create(self, serializer): + # Retrieve the collection from the validated data + collection = serializer.validated_data.get('collection') + + # Check if a collection is provided + if collection: + user = self.request.user + # Check if the user is the owner or is in the shared_with list + if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists(): + # Return an error response if the user does not have permission + raise PermissionDenied("You do not have permission to use this collection.") + # if collection the owner of the adventure is the owner of the collection + serializer.save(user_id=collection.user_id) + return + + # Save the adventure with the current user as the owner + serializer.save(user_id=self.request.user) \ No newline at end of file diff --git a/backend/server/integrations/__init__.py b/backend/server/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/integrations/admin.py b/backend/server/integrations/admin.py new file mode 100644 index 0000000..d561cf4 --- /dev/null +++ b/backend/server/integrations/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from allauth.account.decorators import secure_admin_login + +from .models import ImmichIntegration + +admin.autodiscover() +admin.site.login = secure_admin_login(admin.site.login) + +admin.site.register(ImmichIntegration) \ No newline at end of file diff --git a/backend/server/integrations/apps.py b/backend/server/integrations/apps.py new file mode 100644 index 0000000..73adb7a --- /dev/null +++ b/backend/server/integrations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IntegrationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'integrations' diff --git a/backend/server/integrations/migrations/0001_initial.py b/backend/server/integrations/migrations/0001_initial.py new file mode 100644 index 0000000..1bf029b --- /dev/null +++ b/backend/server/integrations/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.8 on 2025-01-02 23:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImmichIntegration', + fields=[ + ('server_url', models.CharField(max_length=255)), + ('api_key', models.CharField(max_length=255)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/integrations/migrations/__init__.py b/backend/server/integrations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py new file mode 100644 index 0000000..9db8a07 --- /dev/null +++ b/backend/server/integrations/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.contrib.auth import get_user_model +import uuid + +User = get_user_model() + +class ImmichIntegration(models.Model): + server_url = models.CharField(max_length=255) + api_key = models.CharField(max_length=255) + user = models.ForeignKey( + User, on_delete=models.CASCADE) + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + + def __str__(self): + return self.user.username + ' - ' + self.server_url \ No newline at end of file diff --git a/backend/server/integrations/serializers.py b/backend/server/integrations/serializers.py new file mode 100644 index 0000000..cc92d21 --- /dev/null +++ b/backend/server/integrations/serializers.py @@ -0,0 +1,13 @@ +from .models import ImmichIntegration +from rest_framework import serializers + +class ImmichIntegrationSerializer(serializers.ModelSerializer): + class Meta: + model = ImmichIntegration + fields = '__all__' + read_only_fields = ['id', 'user'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + representation.pop('user', None) + return representation diff --git a/backend/server/integrations/tests.py b/backend/server/integrations/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/server/integrations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py new file mode 100644 index 0000000..a15bbd0 --- /dev/null +++ b/backend/server/integrations/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from integrations.views import ImmichIntegrationView, IntegrationView, ImmichIntegrationViewSet + +# Create the router and register the ViewSet +router = DefaultRouter() +router.register(r'immich', ImmichIntegrationView, basename='immich') +router.register(r'', IntegrationView, basename='integrations') +router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset') + +# Include the router URLs +urlpatterns = [ + path("", include(router.urls)), # Includes /immich/ routes +] diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py new file mode 100644 index 0000000..dbf383a --- /dev/null +++ b/backend/server/integrations/views.py @@ -0,0 +1,324 @@ +import os +from rest_framework.response import Response +from rest_framework import viewsets, status + +from .serializers import ImmichIntegrationSerializer +from .models import ImmichIntegration +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +import requests +from rest_framework.pagination import PageNumberPagination + +class IntegrationView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + def list(self, request): + """ + RESTful GET method for listing all integrations. + """ + immich_integrations = ImmichIntegration.objects.filter(user=request.user) + + return Response( + { + 'immich': immich_integrations.exists() + }, + status=status.HTTP_200_OK + ) + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 25 + page_size_query_param = 'page_size' + max_page_size = 1000 + +class ImmichIntegrationView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + pagination_class = StandardResultsSetPagination + def check_integration(self, request): + """ + Checks if the user has an active Immich integration. + Returns: + - None if the integration exists. + - A Response with an error message if the integration is missing. + """ + user_integrations = ImmichIntegration.objects.filter(user=request.user) + if not user_integrations.exists(): + return Response( + { + 'message': 'You need to have an active Immich integration to use this feature.', + 'error': True, + 'code': 'immich.integration_missing' + }, + status=status.HTTP_403_FORBIDDEN + ) + return ImmichIntegration.objects.first() + + @action(detail=False, methods=['get'], url_path='search') + def search(self, request): + """ + Handles the logic for searching Immich images. + """ + # Check for integration before proceeding + integration = self.check_integration(request) + if isinstance(integration, Response): + return integration + + query = request.query_params.get('query', '') + date = request.query_params.get('date', '') + + if not query and not date: + return Response( + { + 'message': 'Query or date is required.', + 'error': True, + 'code': 'immich.query_required' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + arguments = {} + if query: + arguments['query'] = query + if date: + arguments['takenBefore'] = date + + # check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code + try: + url = f'{integration.server_url}/search/{"smart" if query else "metadata"}' + immich_fetch = requests.post(url, headers={ + 'x-api-key': integration.api_key + }, + json = arguments + ) + res = immich_fetch.json() + except requests.exceptions.ConnectionError: + return Response( + { + 'message': 'The Immich server is currently down or unreachable.', + 'error': True, + 'code': 'immich.server_down' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + + if 'assets' in res and 'items' in res['assets']: + paginator = self.pagination_class() + # for each item in the items, we need to add the image url to the item so we can display it in the frontend + public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') + public_url = public_url.replace("'", "") + for item in res['assets']['items']: + item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}' + result_page = paginator.paginate_queryset(res['assets']['items'], request) + return paginator.get_paginated_response(result_page) + else: + return Response( + { + 'message': 'No items found.', + 'error': True, + 'code': 'immich.no_items_found' + }, + status=status.HTTP_404_NOT_FOUND + ) + + @action(detail=False, methods=['get'], url_path='get/(?P[^/.]+)') + def get(self, request, imageid=None): + """ + RESTful GET method for retrieving a specific Immich image by ID. + """ + # Check for integration before proceeding + integration = self.check_integration(request) + if isinstance(integration, Response): + return integration + + if not imageid: + return Response( + { + 'message': 'Image ID is required.', + 'error': True, + 'code': 'immich.imageid_required' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code + try: + immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={ + 'x-api-key': integration.api_key + }) + # should return the image file + from django.http import HttpResponse + return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK) + except requests.exceptions.ConnectionError: + return Response( + { + 'message': 'The Immich server is currently down or unreachable.', + 'error': True, + 'code': 'immich.server_down' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + + @action(detail=False, methods=['get']) + def albums(self, request): + """ + RESTful GET method for retrieving all Immich albums. + """ + # Check for integration before proceeding + integration = self.check_integration(request) + if isinstance(integration, Response): + return integration + + # check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code + try: + immich_fetch = requests.get(f'{integration.server_url}/albums', headers={ + 'x-api-key': integration.api_key + }) + res = immich_fetch.json() + except requests.exceptions.ConnectionError: + return Response( + { + 'message': 'The Immich server is currently down or unreachable.', + 'error': True, + 'code': 'immich.server_down' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + + return Response( + res, + status=status.HTTP_200_OK + ) + + @action(detail=False, methods=['get'], url_path='albums/(?P[^/.]+)') + def album(self, request, albumid=None): + """ + RESTful GET method for retrieving a specific Immich album by ID. + """ + # Check for integration before proceeding + integration = self.check_integration(request) + if isinstance(integration, Response): + return integration + + if not albumid: + return Response( + { + 'message': 'Album ID is required.', + 'error': True, + 'code': 'immich.albumid_required' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code + try: + immich_fetch = requests.get(f'{integration.server_url}/albums/{albumid}', headers={ + 'x-api-key': integration.api_key + }) + res = immich_fetch.json() + except requests.exceptions.ConnectionError: + return Response( + { + 'message': 'The Immich server is currently down or unreachable.', + 'error': True, + 'code': 'immich.server_down' + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + + if 'assets' in res: + paginator = self.pagination_class() + # for each item in the items, we need to add the image url to the item so we can display it in the frontend + public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') + public_url = public_url.replace("'", "") + for item in res['assets']: + item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}' + result_page = paginator.paginate_queryset(res['assets'], request) + return paginator.get_paginated_response(result_page) + else: + return Response( + { + 'message': 'No assets found in this album.', + 'error': True, + 'code': 'immich.no_assets_found' + }, + status=status.HTTP_404_NOT_FOUND + ) + +class ImmichIntegrationViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = ImmichIntegrationSerializer + queryset = ImmichIntegration.objects.all() + + def get_queryset(self): + return ImmichIntegration.objects.filter(user=self.request.user) + + def create(self, request): + """ + RESTful POST method for creating a new Immich integration. + """ + + # Check if the user already has an integration + user_integrations = ImmichIntegration.objects.filter(user=request.user) + if user_integrations.exists(): + return Response( + { + 'message': 'You already have an active Immich integration.', + 'error': True, + 'code': 'immich.integration_exists' + }, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response( + serializer.data, + status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, pk=None): + """ + RESTful DELETE method for deleting an existing Immich integration. + """ + integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first() + if not integration: + return Response( + { + 'message': 'Integration not found.', + 'error': True, + 'code': 'immich.integration_not_found' + }, + status=status.HTTP_404_NOT_FOUND + ) + integration.delete() + return Response( + { + 'message': 'Integration deleted successfully.' + }, + status=status.HTTP_200_OK + ) + + def list(self, request, *args, **kwargs): + # If the user has an integration, we only want to return that integration + + user_integrations = ImmichIntegration.objects.filter(user=request.user) + if user_integrations.exists(): + integration = user_integrations.first() + serializer = self.serializer_class(integration) + return Response( + serializer.data, + status=status.HTTP_200_OK + ) + else: + return Response( + { + 'message': 'No integration found.', + 'error': True, + 'code': 'immich.integration_not_found' + }, + status=status.HTTP_404_NOT_FOUND + ) \ No newline at end of file diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 7e4973b..406e37a 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -13,6 +13,8 @@ import os from dotenv import load_dotenv from os import getenv from pathlib import Path +from urllib.parse import urlparse +from publicsuffix2 import get_sld # Load environment variables from .env file load_dotenv() @@ -42,6 +44,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', + # "allauth_ui", 'rest_framework', 'rest_framework.authtoken', 'allauth', @@ -49,21 +52,29 @@ INSTALLED_APPS = ( 'allauth.mfa', 'allauth.headless', 'allauth.socialaccount', - # "widget_tweaks", - # "slippers", + 'allauth.socialaccount.providers.github', + 'allauth.socialaccount.providers.openid_connect', 'drf_yasg', 'corsheaders', 'adventures', 'worldtravel', 'users', + 'integrations', 'django.contrib.gis', + # 'achievements', # Not done yet, will be added later in a future update + # 'widget_tweaks', + # 'slippers', + ) MIDDLEWARE = ( 'whitenoise.middleware.WhiteNoiseMiddleware', + 'adventures.middleware.XSessionTokenMiddleware', + 'adventures.middleware.DisableCSRFForSessionTokenMiddleware', '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', @@ -119,18 +130,46 @@ USE_L10N = True USE_TZ = True +unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000') +FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"')) + +SESSION_COOKIE_SAMESITE = 'Lax' + +SESSION_COOKIE_NAME = 'sessionid' + +SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https') + +hostname = urlparse(FRONTEND_URL).hostname +is_ip_address = hostname.replace('.', '').isdigit() + +# Check if the hostname is single-label (no dots) +is_single_label = '.' not in hostname + +if is_ip_address or is_single_label: + # Do not set a domain for IP addresses or single-label hostnames + SESSION_COOKIE_DOMAIN = None +else: + # Use publicsuffix2 to calculate the correct cookie domain + cookie_domain = get_sld(hostname) + if cookie_domain: + SESSION_COOKIE_DOMAIN = f".{cookie_domain}" + else: + # Fallback to the hostname if parsing fails + SESSION_COOKIE_DOMAIN = hostname # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.7/howto/static-files/ +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + BASE_DIR = Path(__file__).resolve().parent.parent STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_URL = '/static/' MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root STATICFILES_DIRS = [BASE_DIR / 'static'] STORAGES = { @@ -142,6 +181,7 @@ STORAGES = { } } +SILENCED_SYSTEM_CHECKS = ["slippers.E001"] TEMPLATES = [ { @@ -164,9 +204,6 @@ TEMPLATES = [ DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True' DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.') -ALLAUTH_UI_THEME = "dark" -SILENCED_SYSTEM_CHECKS = ["slippers.E001"] - AUTH_USER_MODEL = 'users.CustomUser' ACCOUNT_ADAPTER = 'users.adapters.NoNewUsersAccountAdapter' @@ -175,7 +212,10 @@ ACCOUNT_SIGNUP_FORM_CLASS = 'users.form_overrides.CustomSignupForm' 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}}", @@ -187,6 +227,10 @@ HEADLESS_FRONTEND_URLS = { "socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback", } +AUTHENTICATION_BACKENDS = [ + 'users.backends.NoPasswordAuthBackend', +] + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' SITE_ID = 1 ACCOUNT_EMAIL_REQUIRED = True @@ -222,10 +266,16 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', } -SWAGGER_SETTINGS = { - 'LOGIN_URL': 'login', - 'LOGOUT_URL': 'logout', -} +if DEBUG: + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ( + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer', + ) +else: + REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ( + 'rest_framework.renderers.JSONRenderer', + ) + CORS_ALLOWED_ORIGINS = [origin.strip() for origin in getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost').split(',') if origin.strip()] @@ -258,5 +308,8 @@ LOGGING = { }, }, } + +# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') + # https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v2.4' \ No newline at end of file +COUNTRY_REGION_JSON_VERSION = 'v2.5' \ No newline at end of file diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 3e3c53f..3b8415e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -1,12 +1,9 @@ from django.urls import include, re_path, path 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, DisablePasswordAuthenticationView +from .views import get_csrf_token, get_public_url, serve_protected_media from drf_yasg.views import get_schema_view - from drf_yasg import openapi schema_view = get_schema_view( @@ -18,16 +15,24 @@ schema_view = get_schema_view( urlpatterns = [ path('api/', include('adventures.urls')), path('api/', include('worldtravel.urls')), - path("_allauth/", include("allauth.headless.urls")), + path("auth/", include("allauth.headless.urls")), + + # Serve protected media files + re_path(r'^media/(?P.*)$', serve_protected_media, name='serve-protected-media'), path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), - path('auth/user//', PublicUserDetailView.as_view(), name='public-user-detail'), + path('auth/user//', PublicUserDetailView.as_view(), name='public-user-detail'), path('auth/update-user/', UpdateUserMetadataView.as_view(), name='update-user-metadata'), path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'), + path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), + + path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'), + 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')), @@ -39,6 +44,7 @@ urlpatterns = [ # path('auth/account-confirm-email/', VerifyEmailView.as_view(), name='account_email_verification_sent'), path("accounts/", include("allauth.urls")), - # Include the API endpoints: - -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + path("api/integrations/", include("integrations.urls")), + + # Include the API endpoints: +] \ No newline at end of file diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 7a7507d..3393e13 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -1,6 +1,42 @@ from django.http import JsonResponse from django.middleware.csrf import get_token +from os import getenv +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.views.static import serve +from adventures.utils.file_permissions import checkFilePermission 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')}) + +protected_paths = ['images/', 'attachments/'] + +def serve_protected_media(request, path): + if any([path.startswith(protected_path) for protected_path in protected_paths]): + image_id = path.split('/')[1] + user = request.user + media_type = path.split('/')[0] + '/' + if checkFilePermission(image_id, user, media_type): + if settings.DEBUG: + # In debug mode, serve the file directly + return serve(request, path, document_root=settings.MEDIA_ROOT) + else: + # In production, use X-Accel-Redirect to serve the file using Nginx + response = HttpResponse() + response['Content-Type'] = '' + response['X-Accel-Redirect'] = '/protectedMedia/' + path + return response + else: + return HttpResponseForbidden() + else: + if settings.DEBUG: + return serve(request, path, document_root=settings.MEDIA_ROOT) + else: + response = HttpResponse() + response['Content-Type'] = '' + response['X-Accel-Redirect'] = '/protectedMedia/' + path + return response \ No newline at end of file diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index bae189f..dcd0125 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -1,4 +1,4 @@ -Django==5.0.8 +Django==5.0.11 djangorestframework>=3.15.2 django-allauth==0.63.3 drf-yasg==1.21.4 @@ -13,8 +13,12 @@ django-geojson setuptools gunicorn==23.0.0 qrcode==8.0 -# slippers==0.6.2 -# django-allauth-ui==1.5.1 -# django-widget-tweaks==1.5.0 +slippers==0.6.2 +django-allauth-ui==1.5.1 +django-widget-tweaks==1.5.0 django-ical==1.9.2 -icalendar==6.1.0 \ No newline at end of file +icalendar==6.1.0 +ijson==3.3.0 +tqdm==4.67.1 +overpy==0.7 +publicsuffix2==2.20191221 \ No newline at end of file diff --git a/backend/server/templates/base.html b/backend/server/templates/base.html index be712b7..9e1d48c 100644 --- a/backend/server/templates/base.html +++ b/backend/server/templates/base.html @@ -53,6 +53,7 @@ >Documentation +
  • Source Code
  • +
  • API Docs
  • diff --git a/backend/server/users/backends.py b/backend/server/users/backends.py new file mode 100644 index 0000000..a099f11 --- /dev/null +++ b/backend/server/users/backends.py @@ -0,0 +1,16 @@ +from django.contrib.auth.backends import ModelBackend +from allauth.socialaccount.models import SocialAccount + +class NoPasswordAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + print("NoPasswordAuthBackend") + # First, attempt normal authentication + user = super().authenticate(request, username=username, password=password, **kwargs) + if user is None: + return None + + if SocialAccount.objects.filter(user=user).exists() and user.disable_password: + # If yes, disable login via password + return None + + return user diff --git a/backend/server/users/forms.py b/backend/server/users/forms.py deleted file mode 100644 index 266bfd0..0000000 --- a/backend/server/users/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -from django import forms - -class CustomSignupForm(forms.Form): - first_name = forms.CharField(max_length=30, required=True) - last_name = forms.CharField(max_length=30, required=True) - - def signup(self, request, user): - # Delay the import to avoid circular import - from allauth.account.forms import SignupForm - - # No need to call super() from CustomSignupForm; use the SignupForm directly if needed - user.first_name = self.cleaned_data['first_name'] - user.last_name = self.cleaned_data['last_name'] - - # Save the user instance - user.save() - return user \ No newline at end of file diff --git a/backend/server/users/migrations/0004_customuser_disable_password.py b/backend/server/users/migrations/0004_customuser_disable_password.py new file mode 100644 index 0000000..3b11ad4 --- /dev/null +++ b/backend/server/users/migrations/0004_customuser_disable_password.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_customuser_email'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='disable_password', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/server/users/models.py b/backend/server/users/models.py index 670d6e3..bec028f 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -8,6 +8,7 @@ class CustomUser(AbstractUser): profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/') uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) public_profile = models.BooleanField(default=False) + disable_password = models.BooleanField(default=False) def __str__(self): return self.username \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index b85608c..9d9bd33 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -36,7 +36,7 @@ import os class UserDetailsSerializer(serializers.ModelSerializer): """ - User model w/o password + User model without exposing the password. """ @staticmethod @@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer): return username class Meta: + model = CustomUser extra_fields = ['profile_pic', 'uuid', 'public_profile'] - profile_pic = serializers.ImageField(required=False) if hasattr(UserModel, 'USERNAME_FIELD'): extra_fields.append(UserModel.USERNAME_FIELD) @@ -64,19 +64,16 @@ 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') + if hasattr(UserModel, 'disable_password'): + extra_fields.append('disable_password') - 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) - read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') + fields = ['pk', *extra_fields] + read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk', 'disable_password') 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 +88,39 @@ 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', 'disable_password'] + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password', 'disable_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) + # Remove the email field + representation.pop('email', None) + return representation diff --git a/backend/server/users/tests.py b/backend/server/users/tests.py index 7ce503c..a529ce5 100644 --- a/backend/server/users/tests.py +++ b/backend/server/users/tests.py @@ -1,3 +1,82 @@ -from django.test import TestCase +from rest_framework.test import APITestCase +from .models import CustomUser +from uuid import UUID -# Create your tests here. +from allauth.account.models import EmailAddress + +class UserAPITestCase(APITestCase): + + def setUp(self): + # Signup a new user + response = self.client.post('/auth/browser/v1/auth/signup', { + 'username': 'testuser', + 'email': 'testuser@example.com', + 'password': 'testpassword', + 'first_name': 'Test', + 'last_name': 'User', + }, format='json') + self.assertEqual(response.status_code, 200) + + def test_001_user(self): + # Fetch user metadata + response = self.client.get('/auth/user-metadata/', format='json') + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['username'], 'testuser') + self.assertEqual(data['email'], 'testuser@example.com') + self.assertEqual(data['first_name'], 'Test') + self.assertEqual(data['last_name'], 'User') + self.assertEqual(data['public_profile'], False) + self.assertEqual(data['profile_pic'], None) + self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser').uuid) + self.assertEqual(data['is_staff'], False) + self.assertEqual(data['has_password'], True) + + def test_002_user_update(self): + try: + userModel = CustomUser.objects.get(username='testuser2') + except: + userModel = None + + self.assertEqual(userModel, None) + # Update user metadata + response = self.client.patch('/auth/update-user/', { + 'username': 'testuser2', + 'first_name': 'Test2', + 'last_name': 'User2', + 'public_profile': True, + }, format='json') + self.assertEqual(response.status_code, 200) + + data = response.json() + # Note that the email field is not updated because that is a seperate endpoint + userModel = CustomUser.objects.get(username='testuser2') + self.assertEqual(data['username'], 'testuser2') + self.assertEqual(data['email'], 'testuser@example.com') + self.assertEqual(data['first_name'], 'Test2') + self.assertEqual(data['last_name'], 'User2') + self.assertEqual(data['public_profile'], True) + self.assertEqual(data['profile_pic'], None) + self.assertEqual(UUID(data['uuid']), CustomUser.objects.get(username='testuser2').uuid) + self.assertEqual(data['is_staff'], False) + self.assertEqual(data['has_password'], True) + + def test_003_user_add_email(self): + # Update user email + response = self.client.post('/auth/browser/v1/account/email', { + 'email': 'testuser2@example.com', + }, format='json') + self.assertEqual(response.status_code, 200) + + data = response.json() + email_data = data['data'][0] + + self.assertEqual(email_data['email'], 'testuser2@example.com') + self.assertEqual(email_data['primary'], False) + self.assertEqual(email_data['verified'], False) + + emails = EmailAddress.objects.filter(user=CustomUser.objects.get(username='testuser')) + self.assertEqual(emails.count(), 2) + # assert email are testuser@example and testuser2@example.com + self.assertEqual(emails[1].email, 'testuser@example.com') + self.assertEqual(emails[0].email, 'testuser2@example.com') diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 109d04b..7c763f5 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -1,3 +1,4 @@ +from os import getenv from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -9,6 +10,10 @@ 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 +from adventures.serializers import AdventureSerializer, CollectionSerializer +from adventures.models import Adventure, Collection +from allauth.socialaccount.models import SocialAccount User = get_user_model() @@ -64,6 +69,10 @@ class PublicUserListView(APIView): for user in users: user.email = None serializer = PublicUserSerializer(users, many=True) + # for every user, remove the field has_password + for user in serializer.data: + user.pop('has_password', None) + user.pop('disable_password', None) return Response(serializer.data, status=status.HTTP_200_OK) class PublicUserDetailView(APIView): @@ -77,12 +86,29 @@ class PublicUserDetailView(APIView): }, operation_description="Get public user information." ) - def get(self, request, user_id): - user = get_object_or_404(User, uuid=user_id, public_profile=True) + def get(self, request, username): + if request.user.username == username: + user = get_object_or_404(User, username=username) + else: + user = get_object_or_404(User, username=username, public_profile=True) + serializer = PublicUserSerializer(user) + # for every user, remove the field has_password + serializer.data.pop('has_password', None) + # remove the email address from the response user.email = None - serializer = PublicUserSerializer(user) - return Response(serializer.data, status=status.HTTP_200_OK) + + # Get the users adventures and collections to include in the response + adventures = Adventure.objects.filter(user_id=user, is_public=True) + collections = Collection.objects.filter(user_id=user, is_public=True) + adventure_serializer = AdventureSerializer(adventures, many=True) + collection_serializer = CollectionSerializer(collections, many=True) + + return Response({ + 'user': serializer.data, + 'adventures': adventure_serializer.data, + 'collections': collection_serializer.data + }, status=status.HTTP_200_OK) class UserMetadataView(APIView): permission_classes = [IsAuthenticated] @@ -120,4 +146,62 @@ class UpdateUserMetadataView(APIView): if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class EnabledSocialProvidersView(APIView): + """ + Get enabled social providers for social authentication. This is used to determine which buttons to show on the frontend. Also returns a URL for each to start the authentication flow. + """ + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Enabled social providers'), + 400: 'Bad Request' + }, + operation_description="Get enabled social providers." + ) + def get(self, request): + social_providers = SocialApp.objects.filter(sites=settings.SITE_ID) + providers = [] + for provider in social_providers: + if provider.provider == 'openid_connect': + new_provider = f'oidc/{provider.client_id}' + else: + new_provider = provider.provider + providers.append({ + 'provider': provider.provider, + 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", + 'name': provider.name + }) + return Response(providers, status=status.HTTP_200_OK) + + +class DisablePasswordAuthenticationView(APIView): + """ + Disable password authentication for a user. This is used when a user signs up with a social provider. + """ + +# Allows the user to set the disable_password field to True if they have a social account linked + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Password authentication disabled'), + 400: 'Bad Request' + }, + operation_description="Disable password authentication." + ) + def post(self, request): + user = request.user + if SocialAccount.objects.filter(user=user).exists(): + user.disable_password = True + user.save() + return Response({"detail": "Password authentication disabled."}, status=status.HTTP_200_OK) + return Response({"detail": "No social account linked."}, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request): + user = request.user + user.disable_password = False + user.save() + return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK) + \ No newline at end of file diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index c9dff83..f5c5702 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -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,37 +38,60 @@ 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'] country_capital = country['capital'] + longitude = round(float(country['longitude']), 6) if country['longitude'] else None + latitude = round(float(country['latitude']), 6) if country['latitude'] else None processed_country_codes.add(country_code) @@ -76,18 +100,21 @@ class Command(BaseCommand): country_obj.name = country_name country_obj.subregion = country_subregion country_obj.capital = country_capital + country_obj.longitude = longitude + country_obj.latitude = latitude countries_to_update.append(country_obj) else: country_obj = Country( name=country_name, country_code=country_code, subregion=country_subregion, - capital=country_capital + capital=country_capital, + longitude=longitude, + latitude=latitude ) 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']: @@ -96,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: @@ -114,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) @@ -130,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')) \ No newline at end of file diff --git a/backend/server/worldtravel/migrations/0011_country_latitude_country_longitude.py b/backend/server/worldtravel/migrations/0011_country_latitude_country_longitude.py new file mode 100644 index 0000000..8916896 --- /dev/null +++ b/backend/server/worldtravel/migrations/0011_country_latitude_country_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.8 on 2025-01-02 00:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0010_country_capital'), + ] + + operations = [ + migrations.AddField( + model_name='country', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='country', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/backend/server/worldtravel/migrations/0012_city.py b/backend/server/worldtravel/migrations/0012_city.py new file mode 100644 index 0000000..d14b088 --- /dev/null +++ b/backend/server/worldtravel/migrations/0012_city.py @@ -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', + }, + ), + ] diff --git a/backend/server/worldtravel/migrations/0013_visitedcity.py b/backend/server/worldtravel/migrations/0013_visitedcity.py new file mode 100644 index 0000000..3b1e294 --- /dev/null +++ b/backend/server/worldtravel/migrations/0013_visitedcity.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.8 on 2025-01-09 17:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0012_city'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VisitedCity', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')), + ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py new file mode 100644 index 0000000..f2ea944 --- /dev/null +++ b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.8 on 2025-01-09 18:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('worldtravel', '0013_visitedcity'), + ] + + operations = [ + migrations.AlterModelOptions( + name='visitedcity', + options={'verbose_name_plural': 'Visited Cities'}, + ), + ] diff --git a/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py new file mode 100644 index 0000000..5d7223b --- /dev/null +++ b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py @@ -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), + ), + ] diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py index f7f8f99..6c7ebb8 100644 --- a/backend/server/worldtravel/models.py +++ b/backend/server/worldtravel/models.py @@ -15,6 +15,9 @@ class Country(models.Model): country_code = models.CharField(max_length=2, unique=True) #iso2 code subregion = models.CharField(max_length=100, blank=True, null=True) capital = models.CharField(max_length=100, blank=True, null=True) + longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + 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" @@ -29,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 @@ -45,4 +63,21 @@ class VisitedRegion(models.Model): def save(self, *args, **kwargs): if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists(): raise ValidationError("Region already visited by user.") - super().save(*args, **kwargs) \ No newline at end of file + super().save(*args, **kwargs) + +class VisitedCity(models.Model): + id = models.AutoField(primary_key=True) + user_id = models.ForeignKey( + User, on_delete=models.CASCADE, default=default_user_id) + city = models.ForeignKey(City, on_delete=models.CASCADE) + + def __str__(self): + return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}' + + def save(self, *args, **kwargs): + if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists(): + raise ValidationError("City already visited by user.") + super().save(*args, **kwargs) + + class Meta: + verbose_name_plural = "Visited Cities" \ No newline at end of file diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py index 0f2ed73..6472125 100644 --- a/backend/server/worldtravel/serializers.py +++ b/backend/server/worldtravel/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Country, Region, VisitedRegion +from .models import Country, Region, VisitedRegion, City, VisitedCity from rest_framework import serializers from main.utils import CustomModelSerializer @@ -29,14 +29,28 @@ class CountrySerializer(serializers.ModelSerializer): class Meta: model = Country fields = '__all__' - read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits'] + read_only_fields = ['id', 'name', 'country_code', 'subregion', 'flag_url', 'num_regions', 'num_visits', 'longitude', 'latitude', 'capital'] class RegionSerializer(serializers.ModelSerializer): + num_cities = serializers.SerializerMethodField() + country_name = serializers.CharField(source='country.name', read_only=True) class Meta: model = Region fields = '__all__' - read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude'] + read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities', 'country_name'] + + def get_num_cities(self, obj): + return City.objects.filter(region=obj).count() + +class CitySerializer(serializers.ModelSerializer): + region_name = serializers.CharField(source='region.name', read_only=True) + country_name = serializers.CharField(source='region.country.name', read_only=True + ) + class Meta: + model = City + fields = '__all__' + read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude', 'region_name', 'country_name'] class VisitedRegionSerializer(CustomModelSerializer): longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True) @@ -46,4 +60,14 @@ class VisitedRegionSerializer(CustomModelSerializer): class Meta: model = VisitedRegion fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name'] + read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] + +class VisitedCitySerializer(CustomModelSerializer): + longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True) + latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True) + name = serializers.CharField(source='city.name', read_only=True) + + class Meta: + model = VisitedCity + fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name'] read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name'] \ No newline at end of file diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index 46fe197..f28beda 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,15 +2,17 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country - +from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region router = DefaultRouter() router.register(r'countries', CountryViewSet, basename='countries') router.register(r'regions', RegionViewSet, basename='regions') router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') +router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity') urlpatterns = [ path('', include(router.urls)), path('/regions/', regions_by_country, name='regions-by-country'), - path('/visits/', visits_by_country, name='visits-by-country') + path('/visits/', visits_by_country, name='visits-by-country'), + path('regions//cities/', cities_by_region, name='cities-by-region'), + path('regions//cities/visits/', visits_by_region, name='visits-by-region'), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index 5a92e39..c77309d 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -1,6 +1,6 @@ from django.shortcuts import render -from .models import Country, Region, VisitedRegion -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 @@ -93,4 +110,46 @@ class VisitedRegionViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, **kwargs): + # delete by region id + region = get_object_or_404(Region, id=kwargs['pk']) + visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region) + if visited_region.exists(): + visited_region.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND) + +class VisitedCityViewSet(viewsets.ModelViewSet): + serializer_class = VisitedCitySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return VisitedCity.objects.filter(user_id=self.request.user.id) + + def perform_create(self, serializer): + serializer.save(user_id=self.request.user) + + def create(self, request, *args, **kwargs): + request.data['user_id'] = request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + # 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) \ No newline at end of file diff --git a/brand/adventurelog.png b/brand/adventurelog.png new file mode 100644 index 0000000..21325e5 Binary files /dev/null and b/brand/adventurelog.png differ diff --git a/brand/adventurelog.svg b/brand/adventurelog.svg new file mode 100644 index 0000000..92667f2 --- /dev/null +++ b/brand/adventurelog.svg @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/brand/banner.png b/brand/banner.png new file mode 100644 index 0000000..a0dd0ae Binary files /dev/null and b/brand/banner.png differ diff --git a/brand/screenshots/adventures.png b/brand/screenshots/adventures.png new file mode 100644 index 0000000..fd61d54 Binary files /dev/null and b/brand/screenshots/adventures.png differ diff --git a/brand/screenshots/countries.png b/brand/screenshots/countries.png new file mode 100644 index 0000000..3b8e534 Binary files /dev/null and b/brand/screenshots/countries.png differ diff --git a/brand/screenshots/dashboard.png b/brand/screenshots/dashboard.png new file mode 100644 index 0000000..af4d8bb Binary files /dev/null and b/brand/screenshots/dashboard.png differ diff --git a/brand/screenshots/details.png b/brand/screenshots/details.png new file mode 100644 index 0000000..6ae57eb Binary files /dev/null and b/brand/screenshots/details.png differ diff --git a/brand/screenshots/edit.png b/brand/screenshots/edit.png new file mode 100644 index 0000000..123160d Binary files /dev/null and b/brand/screenshots/edit.png differ diff --git a/brand/screenshots/itinerary.png b/brand/screenshots/itinerary.png new file mode 100644 index 0000000..f153263 Binary files /dev/null and b/brand/screenshots/itinerary.png differ diff --git a/brand/screenshots/map.png b/brand/screenshots/map.png new file mode 100644 index 0000000..22b13b9 Binary files /dev/null and b/brand/screenshots/map.png differ diff --git a/brand/screenshots/regions.png b/brand/screenshots/regions.png new file mode 100644 index 0000000..6092dc6 Binary files /dev/null and b/brand/screenshots/regions.png differ diff --git a/cdn/.gitignore b/cdn/.gitignore new file mode 100644 index 0000000..adbb97d --- /dev/null +++ b/cdn/.gitignore @@ -0,0 +1 @@ +data/ \ No newline at end of file diff --git a/cdn/Dockerfile b/cdn/Dockerfile new file mode 100644 index 0000000..f47e79a --- /dev/null +++ b/cdn/Dockerfile @@ -0,0 +1,36 @@ +# Use an official Python image as a base +FROM python:3.11-slim + +# Set the working directory +WORKDIR /app + +# Install required Python packages +RUN pip install --no-cache-dir requests osm2geojson + +# Copy the script into the container +COPY main.py /app/main.py + +# Run the script to generate the data folder and GeoJSON files (this runs inside the container) +RUN python -u /app/main.py + +# Install Nginx +RUN apt update && apt install -y nginx && rm -rf /var/lib/apt/lists/* + +# Copy the entire generated data folder to the Nginx serving directory +RUN mkdir -p /var/www/html/data && cp -r /app/data/* /var/www/html/data/ + +# Copy Nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy the index.html file to the Nginx serving directory +COPY index.html /usr/share/nginx/html/index.html + +# Expose port 80 for Nginx +EXPOSE 80 + +# Copy the entrypoint script into the container +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Set the entrypoint script as the default command +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/cdn/README.md b/cdn/README.md new file mode 100644 index 0000000..f935c14 --- /dev/null +++ b/cdn/README.md @@ -0,0 +1,3 @@ +This folder contains the scripts to generate AdventureLOG CDN files. + +Special thanks to [@larsl-net](https://github.com/larsl-net) for the GeoJSON generation script. diff --git a/cdn/docker-compose.yml b/cdn/docker-compose.yml new file mode 100644 index 0000000..5699dd8 --- /dev/null +++ b/cdn/docker-compose.yml @@ -0,0 +1,9 @@ +services: + cdn: + build: . + container_name: adventurelog-cdn + ports: + - "8080:80" + restart: unless-stopped + volumes: + - ./data:/app/data # Ensures new data files persist diff --git a/cdn/entrypoint.sh b/cdn/entrypoint.sh new file mode 100644 index 0000000..fffe244 --- /dev/null +++ b/cdn/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Any setup tasks or checks can go here (if needed) +echo "AdventureLog CDN has started!" +echo "Refer to the documentation for information about connecting your AdventureLog instance to this CDN." +echo "Thanks to our data providers for making this possible! You can find them on the CDN site." + +# Start Nginx in the foreground (as the main process) +nginx -g 'daemon off;' diff --git a/cdn/index.html b/cdn/index.html new file mode 100644 index 0000000..7b78153 --- /dev/null +++ b/cdn/index.html @@ -0,0 +1,90 @@ + + + + + + + AdventureLog CDN + + + + +
    +
    +

    Welcome to the AdventureLog CDN

    +

    + This is a content delivery network for the AdventureLog project. You + can browse the content by clicking the button below. +

    + Browse Content +
    +
    +

    About AdventureLog

    +

    + AdventureLog is a project that aims to provide a platform for users to + log their adventures and share them with the world. The project is + developed by Sean Morley and is + open source. View it on GitHub here: + AdventureLog. +

    +
    + +
    +
    +
    +

    Data Attributions

    +

    + The data provided in this CDN is sourced from the following + repositories: +

    + +
    +
    +
    +
    + + diff --git a/cdn/main.py b/cdn/main.py new file mode 100644 index 0000000..2c052fa --- /dev/null +++ b/cdn/main.py @@ -0,0 +1,87 @@ +import requests +import json +import os + +# The version of the CDN, this should be updated when the CDN data is updated so the client can check if it has the latest version +ADVENTURELOG_CDN_VERSION = 'v0.0.1' + +# https://github.com/dr5hn/countries-states-cities-database/tags +COUNTRY_REGION_JSON_VERSION = 'v2.5' # Test on past and latest versions to ensure that the data schema is consistent before updating + +def makeDataDir(): + """ + Creates the data directory if it doesn't exist + """ + path = os.path.join(os.path.dirname(__file__), 'data') + if not os.path.exists(path): + os.makedirs(path) + +def saveCdnVersion(): + """ + Saves the CDN version to a JSON file so the client can check if it has the latest version + """ + path = os.path.join(os.path.dirname(__file__), 'data', 'version.json') + with open(path, 'w') as f: + json.dump({'version': ADVENTURELOG_CDN_VERSION}, f) + print('CDN Version saved') + +def downloadCountriesStateCities(): + """ + Downloads the countries, states and cities data from the countries-states-cities-database repository + """ + res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json') + + path = os.path.join(os.path.dirname(__file__), 'data', f'countries_states_cities.json') + + with open(path, 'w') as f: + f.write(res.text) + print('Countries, states and cities data downloaded successfully') + +def saveCountryFlag(country_code, name): + """ + Downloads the flag of a country and saves it in the data/flags directory + """ + # For standards, use the lowercase country_code + country_code = country_code.lower() + # Save the flag in the data/flags directory + flags_dir = os.path.join(os.path.dirname(__file__), 'data', 'flags') + + # Check if the flags directory exists, if not, create it + if not os.path.exists(flags_dir): + os.makedirs(flags_dir) + + # Check if the flag already exists in the media folder + flag_path = os.path.join(flags_dir, f'{country_code}.png') + if os.path.exists(flag_path): + # remove the flag if it already exists + os.remove(flag_path) + print(f'Flag for {country_code} ({name}) removed') + + res = requests.get(f'https://flagcdn.com/h240/{country_code}.png'.lower()) + if res.status_code == 200: + with open(flag_path, 'wb') as f: + f.write(res.content) + print(f'Flag for {country_code} downloaded') + else: + print(f'Error downloading flag for {country_code} ({name})') + +def saveCountryFlags(): + """ + Downloads the flags of all countries and saves them in the data/flags directory + """ + # Load the countries data + with open(os.path.join(os.path.dirname(__file__), 'data', f'countries_states_cities.json')) as f: + data = json.load(f) + + for country in data: + country_code = country['iso2'] + name = country['name'] + saveCountryFlag(country_code, name) + +# Run the functions +print('Starting CDN update') +makeDataDir() +saveCdnVersion() +downloadCountriesStateCities() +saveCountryFlags() +print('CDN update complete') \ No newline at end of file diff --git a/cdn/nginx.conf b/cdn/nginx.conf new file mode 100644 index 0000000..44a0df9 --- /dev/null +++ b/cdn/nginx.conf @@ -0,0 +1,13 @@ +events {} + +http { + server { + listen 80; + server_name _; + + location /data/ { + root /var/www/html; + autoindex on; # Enable directory listing + } + } +} diff --git a/cdn/requirements.txt b/cdn/requirements.txt new file mode 100644 index 0000000..1f11fab --- /dev/null +++ b/cdn/requirements.txt @@ -0,0 +1 @@ +osm2geojson==0.2.5 \ No newline at end of file diff --git a/docker-compose-traefik.yaml b/docker-compose-traefik.yaml index 6fb2f03..16a1805 100644 --- a/docker-compose-traefik.yaml +++ b/docker-compose-traefik.yaml @@ -39,9 +39,11 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.adventurelogweb.entrypoints=websecure" - - "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain + - "traefik.http.routers.adventurelogweb.rule=Host(`yourdomain.com`) && !(PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" # Replace with your domain - "traefik.http.routers.adventurelogweb.tls=true" - - "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt" + - "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt" + depends_on: + - server server: image: ghcr.io/seanmorley15/adventurelog-backend:latest @@ -64,9 +66,11 @@ services: labels: - "traefik.enable=true" - "traefik.http.routers.adventurelogserver.entrypoints=websecure" - - "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`))" # Replace with your domain + - "traefik.http.routers.adventurelogserver.rule=Host(`yourdomain.com`) && (PathPrefix(`/media`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/accounts`))" # Replace with your domain - "traefik.http.routers.adventurelogserver.tls=true" - - "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt" + - "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt" + depends_on: + - db volumes: postgres-data: diff --git a/docker-compose.yml b/docker-compose.yml index efaf95d..eca6a8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: adventurelog-frontend restart: unless-stopped environment: - - PUBLIC_SERVER_URL=http://server:8000 # MOST DOCKER USERS WILL NOT NEED TO CHANGE THIS EVER EVEN IF YOU CHANGE THE OUTWARD PORT + - PUBLIC_SERVER_URL=http://server:8000 # Should be the service name of the backend with port 8000, even if you change the port in the backend service - ORIGIN=http://localhost:8015 - BODY_SIZE_LIMIT=Infinity ports: diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 1c47f29..dbd9299 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -69,6 +69,7 @@ export default defineConfig({ text: "Kubernetes and Kustomize 🌐", link: "/docs/install/kustomize", }, + { text: "Unraid 🧡", link: "/docs/install/unraid" }, { text: "With A Reverse Proxy", @@ -87,6 +88,32 @@ export default defineConfig({ text: "Configuration", collapsed: false, items: [ + { + 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", @@ -131,6 +158,10 @@ export default defineConfig({ text: "Changelogs", collapsed: false, items: [ + { + text: "v0.8.0", + link: "/docs/changelogs/v0-8-0", + }, { text: "v0.7.1", link: "/docs/changelogs/v0-7-1", @@ -147,6 +178,8 @@ export default defineConfig({ { icon: "github", link: "https://github.com/seanmorley15/AdventureLog" }, { icon: "discord", link: "https://discord.gg/wRbQ9Egr8C" }, { icon: "buymeacoffee", link: "https://buymeacoffee.com/seanmorley15" }, + { icon: "x", link: "https://x.com/AdventureLogApp" }, + { icon: "mastodon", link: "https://mastodon.social/@adventurelog" }, ], }, }); diff --git a/documentation/docs/changelogs/v0-8-0.md b/documentation/docs/changelogs/v0-8-0.md new file mode 100644 index 0000000..90f6e43 --- /dev/null +++ b/documentation/docs/changelogs/v0-8-0.md @@ -0,0 +1,105 @@ +# AdventureLog v0.8.0 - Immich Integration, Calendar and Customization + +Released 01-08-2025 + +Hi everyone! 🚀 +I’m thrilled to announce the release of **AdventureLog v0.8.0**, a huge update packed with new features, improvements, and enhancements. This release focuses on delivering a better user experience, improved functionality, and expanded customization options. Let’s dive into what’s new! + +--- + +## What's New ✨ + +### Immich Integration + +- AdventureLog now integrates seamlessly with [Immich](https://github.com/immich-app), the amazing self-hostable photo library. +- Import your photos from Immich directly into AdventureLog adventures and collections. + - Use Immich Smart Search to search images to import based on natural queries. + - Sort by photo album to easily import your trips photos to an adventure. + +### 🚗 Transportation + +- **New Transportation Edit Modal**: Includes detailed origin and destination location information for better trip planning. +- **Autocomplete for Airport Codes**: Quickly find and add airport codes while planning transportation. +- **New Transportation Card Design**: Redesigned for better clarity and aesthetics. + +--- + +### 📝 Notes and Checklists + +- **New Modals for Notes and Checklists**: Simplified creation and editing of your notes and checklists. +- **Delete Confirmation**: Added a confirmation step when deleting notes, checklists, or transportation to prevent accidental deletions. + +--- + +### 📍Adventures + +- **Markdown Editor and Preview**: Write and format adventure descriptions with a markdown editor. +- **Custom Categories**: Organize your adventures with personalized categories and icons. +- **Primary Images**: Adventure images can now be marked as the "primary image" and will be the first one to be displayed in adventure views. + +--- + +### 🗓️ Calendar + +- **Calendar View**: View your adventures and transportation in a calendar layout. +- **ICS File Export**: Export your calendar as an ICS file for use with external apps like Google Calendar or Outlook. + +--- + +### 🌐 Localization + +- Added support for **Polish** language (@dymek37). +- Improved Swedish language data (@nordtechtiger) + +--- + +### 🔒 Authentication + +- **New Authentication System**: Includes MFA for added security. +- **Admin Page Authentication**: Enhanced protection for admin operations. + > [!IMPORTANT] + > Ensure you know your credentials as you will be signed out after updating! + +--- + +### 🖌️ UI & Theming + +- **Nord Theme**: A sleek new theme option for a modern and clean interface. +- **New Home Dashboard**: A revamped dashboard experience to access everything you need quickly and view your travel stats. + +--- + +### ⚙️ Settings + +- **Overhauled Settings Page**: Redesigned for better navigation and usability. + +--- + +### 🐛 Bug Fixes and Improvements + +- Fixed the **NGINX Upload Size Bug**: Upload larger files without issues. +- **Prevents Duplicate Emails**: Improved account management; users can now add multiple emails to a single account. +- General **code cleanliness** for better performance and stability. +- Fixes Django Admin access through Traefik (@PascalBru) + +--- + +### 🌐 Infrastructure + +- Added **Kubernetes Configurations** for scalable deployments (@MaximUltimatum). +- Launched a **New [Documentation Site](https://adventurelog.app)** for better guidance and support. + +--- + +## Sponsorship 💖 + +[![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/seanmorley15) +As always, AdventureLog continues to grow thanks to your incredible support and feedback. If you love using the app and want to help shape its future, consider supporting me on **Buy Me A Coffee**. Your contributions go a long way in allowing for AdventureLog to continue to improve and thrive 😊 + +--- + +Enjoy the update! 🎉 +Feel free to share your feedback, ideas, or questions in the discussion below or on the official [discord server](https://discord.gg/wRbQ9Egr8C)! + +Happy travels, +**Sean Morley** (@seanmorley15) diff --git a/documentation/docs/configuration/email.md b/documentation/docs/configuration/email.md index f3fb313..e83ac6c 100644 --- a/documentation/docs/configuration/email.md +++ b/documentation/docs/configuration/email.md @@ -13,12 +13,22 @@ environment: ```yaml environment: - - EMAIL_BACKEND='email' - - EMAIL_HOST='smtp.gmail.com' - - EMAIL_USE_TLS=False + - EMAIL_BACKEND=email + - EMAIL_HOST=smtp.gmail.com + - EMAIL_USE_TLS=True - EMAIL_PORT=587 - - EMAIL_USE_SSL=True - - EMAIL_HOST_USER='user' - - EMAIL_HOST_PASSWORD='password' - - DEFAULT_FROM_EMAIL='user@example.com' + - EMAIL_USE_SSL=False + - EMAIL_HOST_USER=user + - EMAIL_HOST_PASSWORD=password + - DEFAULT_FROM_EMAIL=user@example.com ``` + +## Customizing Emails + +By default, the email will display `[example.com]` in the subject. You can customize this in the admin site. + +1. Go to the admin site (serverurl/admin) +2. Click on `Sites` +3. Click on first site, it will probably be `example.com` +4. Change the `Domain name` and `Display name` to your desired values +5. Click `Save` diff --git a/documentation/docs/configuration/immich_integration.md b/documentation/docs/configuration/immich_integration.md new file mode 100644 index 0000000..1581e42 --- /dev/null +++ b/documentation/docs/configuration/immich_integration.md @@ -0,0 +1,28 @@ +# Immich Integration + +### What is Immich? + + + +![Immich Banner](https://repository-images.githubusercontent.com/455229168/ebba3238-9ef5-4891-ad58-a3b0223b12bd) + +Immich is a self-hosted, open-source platform that allows users to backup and manage their photos and videos similar to Google Photos, but with the advantage of storing data on their own private server, ensuring greater privacy and control over their media. + +- [Immich Website and Documentation](https://immich.app/) +- [GitHub Repository](https://github.com/immich-app/immich) + +### How to integrate Immich with AdventureLog? + +To integrate Immich with AdventureLog, you need to have an Immich server running and accessible from where AdventureLog is running. + +1. Obtain the Immich API Key from the Immich server. + - In the Immich web interface, click on your user profile picture, go to `Account Settings` > `API Keys`. + - Click `New API Key` and name it something like `AdventureLog`. + - Copy the generated API Key, you will need it in the next step. +2. Go to the AdventureLog web interface, click on your user profile picture, go to `Settings` and scroll down to the `Immich Integration` section. + - Enter the URL of your Immich server, e.g. `https://immich.example.com/api`. Note that `localhost` or `127.0.0.1` will probably not work because Immich and AdventureLog are running on different docker networks. It is recommended to use the IP address of the server where Immich is running ex `http://my-server-ip:port` or a domain name. + - Paste the API Key you obtained in the previous step. + - Click `Enable Immich` to save the settings. +3. Now, when you are adding images to an adventure, you will see an option to search for images in Immich or upload from an album. + +Enjoy the privacy and control of managing your travel media with Immich and AdventureLog! 🎉 diff --git a/documentation/docs/configuration/social_auth.md b/documentation/docs/configuration/social_auth.md new file mode 100644 index 0000000..68214b9 --- /dev/null +++ b/documentation/docs/configuration/social_auth.md @@ -0,0 +1,15 @@ +# Social Authentication + +AdventureLog support authentication 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 existing AdventureLog accounts to these services for easier access. + +The steps for each service varies so please refer to the specific service's documentation for more information. + +## Supported Services + +- [Authentik](social_auth/authentik.md) (self-hosted) +- [GitHub](social_auth/github.md) +- [Open ID Connect](social_auth/oidc.md) + +## Linking Existing Accounts + +If you already have an AdventureLog account and would like to link it to a 3rd party service, you can do so by logging in to AdventureLog and navigating to the `Account Settings` page. From there, scroll down to `Social and OIDC Authentication` and click the `Launch Account Connections` button. If identity providers have been enabled on your instance, you will see a list of available services to link to. diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md new file mode 100644 index 0000000..ff57ac3 --- /dev/null +++ b/documentation/docs/configuration/social_auth/authentik.md @@ -0,0 +1,70 @@ +# Authentik OIDC Authentication + +Authentik Logo + +Authentik is a self-hosted identity provider that supports OpenID Connect and OAuth2. AdventureLog can be configured to use Authentik as an identity provider for social authentication. Learn more about Authentik at [goauthentik.io](https://goauthentik.io/). + +Once Authentik is configured by the administrator, users can log in to AdventureLog using their Authentik account and link existing AdventureLog accounts to Authentik for easier access. + +# Configuration + +To enable Authentik as an identity provider, the administrator must first configure Authentik to allow AdventureLog to authenticate users. + +### Authentik Configuration + +1. Log in to Authentik and navigate to the `Providers` page and create a new provider. +2. Select `OAuth2/OpenID Provider` as the provider type. +3. Name it `AdventureLog` or any other name you prefer. +4. Set the `Redirect URI` of type `Regex` to `^http:///accounts/oidc/.*$` where `` is the URL of your AdventureLog Server service. +5. Copy the `Client ID` and `Client Secret` generated by Authentik, you will need these to configure AdventureLog. +6. Create an application in Authentik and assign the provider to it, name the `slug` `adventurelog` or any other name you prefer. +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 panel 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: Authentik Client ID + - Name: `Authentik` + - Client ID: Authentik Client ID + - Secret Key: Authentik Client Secret + - Key: can be left blank + - Settings: (make sure http/https is set correctly) + + ```json + { + "server_url": "http:///application/o/[YOUR_SLUG]/" + } + ``` + + ::: warning + `localhost` is most likely not a valid `server_url` for Authentik in this instance because `localhost` is the server running AdventureLog, not Authentik. You should use the IP address of the server running Authentik or the domain name if you have one. + +- Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/authentik_settings.png) + +4. Save the configuration. + +Ensure that the Authentik server is running and accessible by AdventureLog. Users should now be able to log in to AdventureLog using their Authentik account. + +## Linking to Existing Account + +If a user has an existing AdventureLog account and wants to link it to their Authentik account, they can do so by logging in to their AdventureLog account and navigating to the `Settings` page. There is a button that says `Launch Account Connections`, click that and then choose the provider to link to the existing account. + +## Troubleshooting + +### 404 error when logging in. + +Ensure the `/accounts` path is routed to the backend, as it shouldn't hit the frontend when it's properly configured. + +### Authentik - No Permission + +In the Authentik instance, check access to the AdventureLog application from a specific user by using the Check Access/Test button on the Application dashboard. If the user doesn't have access, you can add an existing user/group policy to give your specific user/group access to the AdventureLog application. diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md new file mode 100644 index 0000000..51efb74 --- /dev/null +++ b/documentation/docs/configuration/social_auth/github.md @@ -0,0 +1,49 @@ +# 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: `` where `` is the URL of your AdventureLog Frontend service. + - Application Description: `AdventureLog` or any other description you prefer. + - Authorization callback URL: `http:///accounts/github/login/callback/` where `` 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 panel 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. + +4. Save the configuration. + +Users should now be able to log in to AdventureLog using their GitHub account, and link it to existing accounts. + +## Linking to Existing Account + +If a user has an existing AdventureLog account and wants to link it to their Github account, they can do so by logging in to their AdventureLog account and navigating to the `Settings` page. There is a button that says `Launch Account Connections`, click that and then choose the provider to link to the existing account. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/github_settings.png) + diff --git a/documentation/docs/configuration/social_auth/oidc.md b/documentation/docs/configuration/social_auth/oidc.md new file mode 100644 index 0000000..0b0384d --- /dev/null +++ b/documentation/docs/configuration/social_auth/oidc.md @@ -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. diff --git a/documentation/docs/configuration/updating.md b/documentation/docs/configuration/updating.md index c4eb3d3..85fec59 100644 --- a/documentation/docs/configuration/updating.md +++ b/documentation/docs/configuration/updating.md @@ -1,6 +1,6 @@ # Updating -Updating AdventureLog when using docker can be quite easy. Run the folowing commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case! +Updating AdventureLog when using docker can be quite easy. Run the following commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case! Note: Make sure you are in the same directory as your `docker-compose.yml` file. @@ -20,5 +20,5 @@ docker exec -it 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 ``` diff --git a/documentation/docs/guides/v0-7-1_migration.md b/documentation/docs/guides/v0-7-1_migration.md index 548d9de..77aaeaf 100644 --- a/documentation/docs/guides/v0-7-1_migration.md +++ b/documentation/docs/guides/v0-7-1_migration.md @@ -1,6 +1,6 @@ # AdventureLog v0.7.1 Migration -In order to make installation easier, the AdventureLog v0.7.1 release has **removed the need for a seperate nginx container** and cofig to serve the media files. Instead, the media files are now served by an instance of nginx running in the same container as the Django application. +In order to make installation easier, the AdventureLog v0.7.1 release has **removed the need for a separate nginx container** and config to serve the media files. Instead, the media files are now served by an instance of nginx running in the same container as the Django application. ## Docker Compose Changes diff --git a/documentation/docs/install/docker.md b/documentation/docs/install/docker.md index 43fe68f..0f987d2 100644 --- a/documentation/docs/install/docker.md +++ b/documentation/docs/install/docker.md @@ -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 ``` @@ -19,30 +25,31 @@ wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-com Here is a summary of the configuration options available in the `docker-compose.yml` file: - + ### Frontend Container (web) -| 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 | +| 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 access 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 **kilobytes**. | ```Infinity``` | ### Backend Container (server) -| 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 publically accessible url to the **frontend** container. This link should be accessable for all users. Used for email generation. | http://localhost:8015 | +| Name | Required | Description | Default Value | +| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| `PGHOST` | Yes | Database host. | ```db``` | +| `PGDATABASE` | Yes | Database. | ```database``` | +| `PGUSER` | Yes | Database user. | ```adventure``` | +| `PGPASSWORD` | Yes | Database password. | ```changeme123``` | +| `PGPORT` | No | Database port. | ```5432``` | +| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | ```admin``` | +| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after initial 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 origins where you use your backend server and frontend. These values are comma separated. | ```http://localhost:8016``` | +| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | ```http://localhost:8015``` | ## Running the Containers diff --git a/documentation/docs/install/getting_started.md b/documentation/docs/install/getting_started.md index d8dd5f8..aa5187c 100644 --- a/documentation/docs/install/getting_started.md +++ b/documentation/docs/install/getting_started.md @@ -6,6 +6,7 @@ AdventureLog can be installed in a variety of ways. The following are the most c - [Proxmox LXC](proxmox_lxc.md) 🐧 - [Synology NAS](synology_nas.md) ☁️ - [Kubernetes and Kustomize](kustomize.md) 🌐 +- [Unraid](unraid.md) 🧡 ### Other Options diff --git a/documentation/docs/install/kustomize.md b/documentation/docs/install/kustomize.md index 5e9d074..2c6e09b 100644 --- a/documentation/docs/install/kustomize.md +++ b/documentation/docs/install/kustomize.md @@ -25,7 +25,7 @@ You must [expose tailnet IPs to your cluster](https://tailscale.com/kb/1438/kube ## Getting Started -Take a look at the [example config](https://github.com/seanmorley15/AdventureLog/blob/main/kustomization.yml) and modify it for your usecase. +Take a look at the [example config](https://github.com/seanmorley15/AdventureLog/blob/main/kustomization.yml) and modify it for your use case. ## Environment Variables diff --git a/documentation/docs/install/nginx_proxy_manager.md b/documentation/docs/install/nginx_proxy_manager.md index fc58b7a..a6b661a 100644 --- a/documentation/docs/install/nginx_proxy_manager.md +++ b/documentation/docs/install/nginx_proxy_manager.md @@ -21,7 +21,7 @@ Ensure that the Nginx Proxy Manager and AdventureLog containers are on the same docker network create nginx-proxy-manager ``` -Add the folowing to the bottom of the `docker-compose.yml` file for the Nginx Proxy Manager service and the AdventureLog service. +Add the following to the bottom of the `docker-compose.yml` file for the Nginx Proxy Manager service and the AdventureLog service. ```yaml networks: diff --git a/documentation/docs/install/unraid.md b/documentation/docs/install/unraid.md new file mode 100644 index 0000000..c8726f6 --- /dev/null +++ b/documentation/docs/install/unraid.md @@ -0,0 +1,71 @@ +# Installation with Unraid + +AdventureLog is available in the Unraid Community Applications store. You can install it by searching for `AdventureLog` in the Community Applications store, where you will find the frontend and the backend. The database can be found by searching `PostGIS`. + +Community Applications Page for AdventureLog: [AdventureLog on CA Store](https://unraid.net/community/apps?q=AdventureLog)\ +Community Applications Page for PostGIS: [PostGIS on CA Store](https://unraid.net/community/apps?q=PostGIS) + +## Installation Configuration + +- **Note:** It is recommended to install the applications in the order of these instructions, as failing to do so could cause issues.\ +- Container names can be set to whatever you desire. +- Also ensure they are all on the same custom network so they can communicate with one another. You can create one by running the following command in your command line, with `example` being set to your desired name. This network will then show up for selection when making the apps/containers. + +```bash +docker network create example +``` + +## Database + +- Network type should be set to your **custom network**. +- There is **no** AdventureLog---Database app, to find the database application search for `PostGIS` on the Unraid App Store then add and fill out the fields as shown below +- Change the repository version to `postgis/postgis:15-3.3` +- Ensure that the variables ```POSTGRES_DB```, ```POSTGRES_USER```, and ```POSTGRES_PASSWORD``` are set in the ```PostGIS``` container. If not, then add them as custom variables. The template should have ```POSTGRES_PASSWORD``` already and you will simply have to add ```POSTGRES_DB``` and ```POSTGRES_USER```. +- The forwarded port of ```5012``` is not needed unless you plan to access the database outside of the container's network. + +| Name | Required | Description | Default Value | +| ------------------- | -------- | -------------------------------------------------------------------------------- | --------------- | +| `POSTGRES_DB` | Yes | The name of the database in PostGIS. | `N/A` | +| `POSTGRES_USER` | Yes | Name of the user generated on first start that will have access to the database. | `N/A` | +| `POSTGRES_PASSWORD` | Yes | Password of the user that will be generated on first start. | `N/A` | + +- Here's some visual instructions of how to configure the database template, click the image to open larger version in new tab.\ +[![/static/img/unraid-config-2.png](/unraid-config-2.png)](/unraid-config-2.png) + +## Backend + +- Network type should be set to your **custom network**. +- **Note:** If you're running the server in a docker network that is other than "host" (for example "bridge"), then you need to add the IP of the host machine in the CSRF Trusted Origins variable instead of using localhost. This is only necessary when accessing locally, otherwise you will use the domain name. + +| Name | Required | Description | Default Value | +| ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `API Port` | Yes | This is the port of the backend. This is a port, not a variable. | `8016` | +| `PGHOST` | Yes | This is how the backend will access the database. Use the database container's name. | `N/A` | +| `PGDATABASE` | Yes | Name of the database in PostGIS to access. | `N/A` | +| `PGUSER` | Yes | Name of the user to access with. This is the same as the variable in the database. | `N/A` | +| `PGPASSWORD` | Yes | Password of the user it's accessing with. This is the same as the variable in the database. | `N/A` | +| `SECRET_KEY` | Yes | Secret Backend Key. Change to anything. | `N/A` | +| `DJANGO_ADMIN_USERNAME` | Yes | Default username for admin access. | `admin` | +| `DJANGO_ADMIN_EMAIL` | Yes | Default admin user's email. **Note:** You cannot make more than one user with each email. | `N/A` | +| `DJANGO_ADMIN_PASSWORD` | Yes | Default password for admin access. Change after initial login. | `N/A` | +| `PUBLIC_URL` | Yes | This needs to match how you will connect to the backend, so either local ip with matching port or domain. It is used for the creation of image URLs. | `http://IP_ADDRESS:8016` | +| `FRONTEND_URL` | Yes | This needs to match how you will connect to the frontend, so either local ip with matching port or domain. This link should be available for all users. Used for email generation. | `http://IP_ADDRESS:8015` | +| `CSRF_TRUSTED_ORIGINS` | Yes | This needs to be changed to the URLs of how you connect to your backend server and frontend. These values are comma-separated and usually the same as the 2 above values. | `http://IP_ADDRESS:8016,http://IP_ADDRESS:8015` | + +- Here's some visual instructions of how to configure the backend template, click the image to open larger version in new tab.\ +[![static/img/unraid-config-1.png](/unraid-config-1.png)](/unraid-config-1.png) + +## Frontend + +- Network type should be set to your **custom network**. +- **Note:** The default value for ```PUBLIC_SERVER_URL``` is ```http://IP_ADDRESS:8000```, however ```IP_ADDRESS``` **should be changed** to the name of the backend container for simplicity. + +| Name | Required | Description | Default Value | +| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | +| `WEB UI Port` | Yes | The port of the frontend. This is not a variable. | `8015` | +| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. Change `IP_ADDRESS` to the name of the backend container. | `http://IP_ADDRESS:8000` | +| `ORIGIN` | Sometimes| Set to the URL you will access the frontend from, such as localhost with correct port, or set it to the domain of what you will access the app from. | `http://IP_ADDRESS: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 **bytes**. | `Infinity` | + +- Here's some visual instructions of how to configure the frontend template, click the image to open larger version in new tab.\ +[![/static/img/unraid-config-3.png](/unraid-config-3.png)](/unraid-config-3.png) diff --git a/documentation/docs/troubleshooting/nginx_failed.md b/documentation/docs/troubleshooting/nginx_failed.md index b133f54..72e6a94 100644 --- a/documentation/docs/troubleshooting/nginx_failed.md +++ b/documentation/docs/troubleshooting/nginx_failed.md @@ -1,5 +1,9 @@ # Troubleshooting: `Starting nginx: nginx failed!` in the Backend Container +::: tip +As of 1-10-2024, this should be resolved in the latest version of AdventureLog. If you are still experiencing this issue, please reach out to the AdventureLog community on Discord or GitHub for further assistance. +::: + The AdventureLog backend container uses a built-in Nginx container with a built-in nginx config that relies on the name `server` to be the service name of the backend container. If the Nginx service fails to start in the backend container, the whole backend service will keep restarting and fail to start. **The primary reason for this error is changing the backend service name `server` to something different** diff --git a/documentation/package.json b/documentation/package.json index 44e3008..c17d74f 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -11,4 +11,4 @@ "prettier": "^3.3.3", "vue": "^3.5.13" } -} \ No newline at end of file +} diff --git a/documentation/pnpm-lock.yaml b/documentation/pnpm-lock.yaml index 109ad48..c5fa66d 100644 --- a/documentation/pnpm-lock.yaml +++ b/documentation/pnpm-lock.yaml @@ -709,8 +709,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} + vite@5.4.14: + resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -1105,9 +1105,9 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue@5.2.0(vite@5.4.11)(vue@3.5.13)': + '@vitejs/plugin-vue@5.2.0(vite@5.4.14)(vue@3.5.13)': dependencies: - vite: 5.4.11 + vite: 5.4.14 vue: 3.5.13 '@vue/compiler-core@3.5.13': @@ -1475,7 +1475,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@5.4.11: + vite@5.4.14: dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -1492,7 +1492,7 @@ snapshots: '@shikijs/transformers': 1.23.1 '@shikijs/types': 1.23.1 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.0(vite@5.4.11)(vue@3.5.13) + '@vitejs/plugin-vue': 5.2.0(vite@5.4.14)(vue@3.5.13) '@vue/devtools-api': 7.6.4 '@vue/shared': 3.5.13 '@vueuse/core': 11.3.0(vue@3.5.13) @@ -1501,7 +1501,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.1.1 shiki: 1.23.1 - vite: 5.4.11 + vite: 5.4.14 vue: 3.5.13 optionalDependencies: postcss: 8.4.49 diff --git a/documentation/public/authentik_settings.png b/documentation/public/authentik_settings.png new file mode 100644 index 0000000..d48e2c3 Binary files /dev/null and b/documentation/public/authentik_settings.png differ diff --git a/documentation/public/github_settings.png b/documentation/public/github_settings.png new file mode 100644 index 0000000..5d54369 Binary files /dev/null and b/documentation/public/github_settings.png differ diff --git a/documentation/public/unraid-config-1.png b/documentation/public/unraid-config-1.png new file mode 100644 index 0000000..36df25c Binary files /dev/null and b/documentation/public/unraid-config-1.png differ diff --git a/documentation/public/unraid-config-2.png b/documentation/public/unraid-config-2.png new file mode 100644 index 0000000..073b5a8 Binary files /dev/null and b/documentation/public/unraid-config-2.png differ diff --git a/documentation/public/unraid-config-3.png b/documentation/public/unraid-config-3.png new file mode 100644 index 0000000..6a0e49b Binary files /dev/null and b/documentation/public/unraid-config-3.png differ diff --git a/frontend/package.json b/frontend/package.json index e2b9a26..a2ed840 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "adventurelog-frontend", - "version": "0.7.1", + "version": "0.8.0", "scripts": { "dev": "vite dev", "django": "cd .. && cd backend/server && python3 manage.py runserver", @@ -16,7 +16,6 @@ "@event-calendar/day-grid": "^3.7.1", "@event-calendar/time-grid": "^3.7.1", "@iconify-json/mdi": "^1.1.67", - "@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-vercel": "^5.4.1", "@sveltejs/kit": "^2.8.3", @@ -35,13 +34,17 @@ "tslib": "^2.6.3", "typescript": "^5.5.2", "unplugin-icons": "^0.19.0", - "vite": "^5.3.6" + "vite": "^5.4.12" }, "type": "module", "dependencies": { "@lukulent/svelte-umami": "^0.0.3", + "@mapbox/togeojson": "^0.16.2", + "dompurify": "^3.2.4", "emoji-picker-element": "^1.26.0", + "gsap": "^3.12.7", "marked": "^15.0.4", + "psl": "^1.15.0", "qrcode": "^1.5.4", "svelte-i18n": "^4.0.1", "svelte-maplibre": "^0.9.8" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1c141b3..318a11b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,12 +11,24 @@ importers: '@lukulent/svelte-umami': specifier: ^0.0.3 version: 0.0.3(svelte@4.2.19) + '@mapbox/togeojson': + specifier: ^0.16.2 + version: 0.16.2 + dompurify: + specifier: ^3.2.4 + version: 3.2.4 emoji-picker-element: specifier: ^1.26.0 version: 1.26.0 + gsap: + specifier: ^3.12.7 + version: 3.12.7 marked: specifier: ^15.0.4 version: 15.0.4 + psl: + specifier: ^1.15.0 + version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -39,21 +51,18 @@ importers: '@iconify-json/mdi': specifier: ^1.1.67 version: 1.1.67 - '@sveltejs/adapter-auto': - specifier: ^3.2.2 - version: 3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) '@sveltejs/adapter-node': specifier: ^5.2.0 - version: 5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) + version: 5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) '@sveltejs/adapter-vercel': specifier: ^5.4.1 - version: 5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))) + version: 5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) '@sveltejs/kit': specifier: ^2.8.3 - version: 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + version: 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@sveltejs/vite-plugin-svelte': specifier: ^3.1.1 - version: 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + version: 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.4) @@ -97,8 +106,8 @@ importers: specifier: ^0.19.0 version: 0.19.0 vite: - specifier: ^5.3.6 - version: 5.3.6(@types/node@22.5.4) + specifier: ^5.4.12 + version: 5.4.12(@types/node@22.5.4) packages: @@ -477,6 +486,10 @@ packages: '@mapbox/tiny-sdf@2.0.6': resolution: {integrity: sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==} + '@mapbox/togeojson@0.16.2': + resolution: {integrity: sha512-DcApudmw4g/grOrpM5gYPZfts6Kr8litBESN6n/27sDsjR2f+iJhx4BA0J2B+XrLlnHyJkKztYApe6oCUZpzFA==} + hasBin: true + '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} @@ -555,85 +568,175 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.31.0': + resolution: {integrity: sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.24.0': resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.31.0': + resolution: {integrity: sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.24.0': resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.31.0': + resolution: {integrity: sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.24.0': resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.31.0': + resolution: {integrity: sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.31.0': + resolution: {integrity: sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.31.0': + resolution: {integrity: sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': + resolution: {integrity: sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.24.0': resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.31.0': + resolution: {integrity: sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.24.0': resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.31.0': + resolution: {integrity: sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.24.0': resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.31.0': + resolution: {integrity: sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': + resolution: {integrity: sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': + resolution: {integrity: sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.24.0': resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.31.0': + resolution: {integrity: sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.24.0': resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.31.0': + resolution: {integrity: sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.24.0': resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.31.0': + resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.24.0': resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.31.0': + resolution: {integrity: sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.24.0': resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.31.0': + resolution: {integrity: sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.24.0': resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.31.0': + resolution: {integrity: sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.24.0': resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} cpu: [x64] os: [win32] - '@sveltejs/adapter-auto@3.2.2': - resolution: {integrity: sha512-Mso5xPCA8zgcKrv+QioVlqMZkyUQ5MjDJiEPuG/Z7cV/5tmwV7LmcVWk5tZ+H0NCOV1x12AsoSpt/CwFwuVXMA==} - peerDependencies: - '@sveltejs/kit': ^2.0.0 + '@rollup/rollup-win32-x64-msvc@4.31.0': + resolution: {integrity: sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==} + cpu: [x64] + os: [win32] '@sveltejs/adapter-node@5.2.0': resolution: {integrity: sha512-HVZoei2078XSyPmvdTHE03VXDUD0ytTvMuMHMQP0j6zX4nPDpCcKrgvU7baEblMeCCMdM/shQvstFxOJPQKlUQ==} @@ -716,11 +819,18 @@ packages: '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vercel/nft@0.27.2': resolution: {integrity: sha512-7LeioS1yE5hwPpQfD3DdH04tuugKjo5KrJk3yK5kAI3Lh76iSsK/ezoFQfzuT08X3ZASQOd1y9ePjLNI9+TxTQ==} engines: {node: '>=16'} hasBin: true + '@xmldom/xmldom@0.8.10': + resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} + engines: {node: '>=10.0.0'} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -825,6 +935,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -892,6 +1005,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} @@ -982,6 +1099,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dompurify@3.2.4: + resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==} + earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} @@ -1165,6 +1285,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.12.7: + resolution: {integrity: sha512-V4GsyVamhmKefvcAKaoy0h6si0xX7ogwBoBSs2CTJwt7luW0oZzC0LhdkyuKV8PJAXr7Yaj8pMjCKD4GJ+eEMg==} + has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} @@ -1559,8 +1682,8 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.0: - resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -1629,8 +1752,8 @@ packages: resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + postcss@8.5.1: + resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} potpack@2.0.0: @@ -1650,6 +1773,13 @@ packages: protocol-buffers-schema@3.6.0: resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -1713,6 +1843,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.31.0: + resolution: {integrity: sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1963,6 +2098,9 @@ packages: type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'} @@ -2017,8 +2155,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@5.3.6: - resolution: {integrity: sha512-es78AlrylO8mTVBygC0gTC0FENv0C6T496vvd33ydbjF/mIi9q3XQ9A3NWo5qLGFKywvz10J26813OkLvcQleA==} + vite@5.4.12: + resolution: {integrity: sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2026,6 +2164,7 @@ packages: less: '*' lightningcss: ^1.21.0 sass: '*' + sass-embedded: '*' stylus: '*' sugarss: '*' terser: ^5.4.0 @@ -2038,6 +2177,8 @@ packages: optional: true sass: optional: true + sass-embedded: + optional: true stylus: optional: true sugarss: @@ -2400,6 +2541,12 @@ snapshots: '@mapbox/tiny-sdf@2.0.6': {} + '@mapbox/togeojson@0.16.2': + dependencies: + '@xmldom/xmldom': 0.8.10 + concat-stream: 2.0.0 + minimist: 1.2.8 + '@mapbox/unitbezier@0.0.1': {} '@mapbox/vector-tile@1.3.1': @@ -2480,76 +2627,128 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.24.0': optional: true + '@rollup/rollup-android-arm-eabi@4.31.0': + optional: true + '@rollup/rollup-android-arm64@4.24.0': optional: true + '@rollup/rollup-android-arm64@4.31.0': + optional: true + '@rollup/rollup-darwin-arm64@4.24.0': optional: true + '@rollup/rollup-darwin-arm64@4.31.0': + optional: true + '@rollup/rollup-darwin-x64@4.24.0': optional: true + '@rollup/rollup-darwin-x64@4.31.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.31.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.31.0': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.24.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.31.0': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.24.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.31.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.24.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.31.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.24.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.24.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.31.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.24.0': optional: true + '@rollup/rollup-linux-x64-musl@4.31.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.24.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.31.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.24.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.31.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true - '@sveltejs/adapter-auto@3.2.2(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': - dependencies: - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) - import-meta-resolve: 4.1.0 + '@rollup/rollup-win32-x64-msvc@4.31.0': + optional: true - '@sveltejs/adapter-node@5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': + '@sveltejs/adapter-node@5.2.0(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))': dependencies: '@rollup/plugin-commonjs': 26.0.1(rollup@4.24.0) '@rollup/plugin-json': 6.1.0(rollup@4.24.0) '@rollup/plugin-node-resolve': 15.2.3(rollup@4.24.0) - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) rollup: 4.24.0 - '@sveltejs/adapter-vercel@5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))': + '@sveltejs/adapter-vercel@5.4.1(@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))': dependencies: - '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/kit': 2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@vercel/nft': 0.27.2 esbuild: 0.21.5 transitivePeerDependencies: - encoding - supports-color - '@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/kit@2.8.3(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 @@ -2563,28 +2762,28 @@ snapshots: sirv: 3.0.0 svelte: 4.2.19 tiny-glob: 0.2.9 - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) debug: 4.3.5 svelte: 4.2.19 - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4))': + '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.3.6(@types/node@22.5.4)) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) debug: 4.3.5 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.10 svelte: 4.2.19 svelte-hmr: 0.16.0(svelte@4.2.19) - vite: 5.3.6(@types/node@22.5.4) - vitefu: 0.2.5(vite@5.3.6(@types/node@22.5.4)) + vite: 5.4.12(@types/node@22.5.4) + vitefu: 0.2.5(vite@5.4.12(@types/node@22.5.4)) transitivePeerDependencies: - supports-color @@ -2638,6 +2837,9 @@ snapshots: dependencies: '@types/geojson': 7946.0.14 + '@types/trusted-types@2.0.7': + optional: true + '@vercel/nft@0.27.2': dependencies: '@mapbox/node-pre-gyp': 1.0.11 @@ -2656,6 +2858,8 @@ snapshots: - encoding - supports-color + '@xmldom/xmldom@0.8.10': {} + abbrev@1.1.1: {} acorn-import-attributes@1.9.5(acorn@8.12.0): @@ -2750,6 +2954,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} + builtin-modules@3.3.0: {} bytewise-core@1.2.3: @@ -2821,6 +3027,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.1.7: {} console-control-strings@1.1.0: {} @@ -2893,6 +3106,10 @@ snapshots: dlv@1.1.3: {} + dompurify@3.2.4: + optionalDependencies: + '@types/trusted-types': 2.0.7 + earcut@2.2.4: {} eastasianwidth@0.2.0: {} @@ -3141,6 +3358,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.12.7: {} + has-unicode@2.0.1: {} hasown@2.0.2: @@ -3499,7 +3718,7 @@ snapshots: picocolors@1.0.1: {} - picocolors@1.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -3562,10 +3781,10 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 - postcss@8.4.47: + postcss@8.5.1: dependencies: nanoid: 3.3.8 - picocolors: 1.1.0 + picocolors: 1.1.1 source-map-js: 1.2.1 potpack@2.0.0: {} @@ -3579,6 +3798,12 @@ snapshots: protocol-buffers-schema@3.6.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -3653,6 +3878,31 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.24.0 fsevents: 2.3.3 + rollup@4.31.0: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.31.0 + '@rollup/rollup-android-arm64': 4.31.0 + '@rollup/rollup-darwin-arm64': 4.31.0 + '@rollup/rollup-darwin-x64': 4.31.0 + '@rollup/rollup-freebsd-arm64': 4.31.0 + '@rollup/rollup-freebsd-x64': 4.31.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.31.0 + '@rollup/rollup-linux-arm-musleabihf': 4.31.0 + '@rollup/rollup-linux-arm64-gnu': 4.31.0 + '@rollup/rollup-linux-arm64-musl': 4.31.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.31.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.31.0 + '@rollup/rollup-linux-riscv64-gnu': 4.31.0 + '@rollup/rollup-linux-s390x-gnu': 4.31.0 + '@rollup/rollup-linux-x64-gnu': 4.31.0 + '@rollup/rollup-linux-x64-musl': 4.31.0 + '@rollup/rollup-win32-arm64-msvc': 4.31.0 + '@rollup/rollup-win32-ia32-msvc': 4.31.0 + '@rollup/rollup-win32-x64-msvc': 4.31.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3927,6 +4177,8 @@ snapshots: type@2.7.3: {} + typedarray@0.0.6: {} + typescript@5.5.2: {} typewise-core@1.2.0: {} @@ -3973,18 +4225,18 @@ snapshots: util-deprecate@1.0.2: {} - vite@5.3.6(@types/node@22.5.4): + vite@5.4.12(@types/node@22.5.4): dependencies: esbuild: 0.21.5 - postcss: 8.4.47 - rollup: 4.24.0 + postcss: 8.5.1 + rollup: 4.31.0 optionalDependencies: '@types/node': 22.5.4 fsevents: 2.3.3 - vitefu@0.2.5(vite@5.3.6(@types/node@22.5.4)): + vitefu@0.2.5(vite@5.4.12(@types/node@22.5.4)): optionalDependencies: - vite: 5.3.6(@types/node@22.5.4) + vite: 5.4.12(@types/node@22.5.4) vt-pbf@3.1.3: dependencies: diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 166ff33..cfe51ed 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -15,6 +15,8 @@ declare global { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; + disable_password: boolean; } | null; locale: string; } diff --git a/frontend/src/app.html b/frontend/src/app.html index f2516ae..abea469 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,7 @@ + %sveltekit.head% diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 98830a8..12cd017 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; export const authHook: Handle = async ({ event, resolve }) => { + event.cookies.delete('csrftoken', { path: '/' }); try { let sessionid = event.cookies.get('sessionid'); @@ -23,7 +24,7 @@ export const authHook: Handle = async ({ event, resolve }) => { if (!userFetch.ok) { event.locals.user = null; - event.cookies.delete('sessionid', { path: '/' }); + event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' }); return await resolve(event); } @@ -47,19 +48,19 @@ export const authHook: Handle = async ({ event, resolve }) => { path: '/', httpOnly: true, sameSite: 'lax', - secure: true, + secure: event.url.protocol === 'https:', expires: expiryDate }); } } } else { event.locals.user = null; - event.cookies.delete('sessionid', { path: '/' }); + event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' }); } } catch (error) { console.error('Error in authHook:', error); event.locals.user = null; - event.cookies.delete('sessionid', { path: '/' }); + event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' }); } return await resolve(event); diff --git a/frontend/src/lib/assets/immich.svg b/frontend/src/lib/assets/immich.svg new file mode 100644 index 0000000..70aa672 --- /dev/null +++ b/frontend/src/lib/assets/immich.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/lib/components/ActivityComplete.svelte b/frontend/src/lib/components/ActivityComplete.svelte index ef39c52..099898c 100644 --- a/frontend/src/lib/components/ActivityComplete.svelte +++ b/frontend/src/lib/components/ActivityComplete.svelte @@ -18,9 +18,7 @@ 'Content-Type': 'application/json' } }); - console.log(res); let data = await res.json(); - console.log('ACTIVITIES' + data.activities); if (data && data.activities) { allActivities = data.activities; } diff --git a/frontend/src/lib/components/AdventureCard.svelte b/frontend/src/lib/components/AdventureCard.svelte index 0d903ee..2e9dd61 100644 --- a/frontend/src/lib/components/AdventureCard.svelte +++ b/frontend/src/lib/components/AdventureCard.svelte @@ -60,11 +60,8 @@ } async function deleteAdventure() { - let res = await fetch(`/adventures/${adventure.id}?/delete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } + let res = await fetch(`/api/adventures/${adventure.id}`, { + method: 'DELETE' }); if (res.ok) { addToast('info', $t('adventures.adventure_delete_success')); @@ -191,7 +188,7 @@ {#if type != 'link'} - {#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with.includes(user.uuid))} + {#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))} -
    - -
    - {$t('adventures.location_information')} -
    -
    - -
    -
    -
    - - {#if is_custom_location} - - {/if} -
    -
    - -
    -
    - - - -
    -
    - {#if places.length > 0} -
    -

    {$t('adventures.search_results')}

    - -
    - {#each places as place} - - {/each} -
    -
    - {:else if noPlaces} -

    {$t('adventures.no_results')}

    - {/if} - -
    - - - - - {#each markers as marker} - - {/each} - - {#if reverseGeocodePlace} -
    -

    {reverseGeocodePlace.region}, {reverseGeocodePlace.country}

    -

    - {reverseGeocodePlace.is_visited - ? $t('adventures.visited') - : $t('adventures.not_visited')} -

    -
    - {#if !reverseGeocodePlace.is_visited} - - {/if} - {/if} -
    -
    -
    +
    @@ -754,7 +652,6 @@ it would also work to just use on:click on the MapLibre component itself. -->
    -
    @@ -915,86 +812,189 @@ it would also work to just use on:click on the MapLibre component itself. -->
    {:else} -

    {$t('adventures.upload_images_here')}

    - -
    -
    -
    -
    +
    +
    + +
    + {$t('adventures.images')} ({adventure.images?.length || 0}) +
    +
    + +
    -
    -
    -
    -
    - - -
    -
    -
    - - -
    -
    - {#if images.length > 0} -

    {$t('adventures.my_images')}

    - {:else} -

    {$t('adventures.no_images')}

    - {/if} -
    - {#each images as image} -
    - - {image.id}
    - {/each} +
    + +
    + +
    + + +
    + {#if wikiImageError} +

    {$t('adventures.wiki_image_error')}

    + {/if} +
    + + {#if immichIntegration} + { + url = e.detail; + fetchImage(); + }} + /> + {/if} + +
    + + {#if images.length > 0} +

    {$t('adventures.my_images')}

    +
    + {#each images as image} +
    + + {#if !image.is_primary} + + {:else} + + +
    + +
    + {/if} + {image.id} +
    + {/each} +
    + {:else} +

    {$t('adventures.no_images')}

    + {/if}
    - +
    {/if} + {#if adventure.is_public && adventure.id} -
    +

    {$t('adventures.share_adventure')}

    diff --git a/frontend/src/lib/components/AttachmentCard.svelte b/frontend/src/lib/components/AttachmentCard.svelte new file mode 100644 index 0000000..72f8a2f --- /dev/null +++ b/frontend/src/lib/components/AttachmentCard.svelte @@ -0,0 +1,101 @@ + + +

    + + +
    window.open(attachment.file, '_blank')} + role="button" + tabindex="0" + aria-label={attachment.file.split('/').pop()} + > + {#if !['.jpg', '.jpeg', '.png', '.gif', '.webp'].some((ext) => attachment.file.endsWith(ext))} +
    +

    + {attachment.name}
    + {attachment.extension.toUpperCase()} +

    +
    + + {/if} +
    + + +
    + {$t('adventures.attachment')} +
    +
    + {attachment.extension} +
    + + +
    + + {attachment.name} + +
    + {#if !allowEdit} + + {/if} + {#if allowEdit} + + + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index e9c7033..dbf6b22 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -30,13 +30,18 @@

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

    -
  • +
  • + +
  • -
  • + {#if user.is_staff} +
  • + {/if}
  • diff --git a/frontend/src/lib/components/CardCarousel.svelte b/frontend/src/lib/components/CardCarousel.svelte index 07718bd..cb238d0 100644 --- a/frontend/src/lib/components/CardCarousel.svelte +++ b/frontend/src/lib/components/CardCarousel.svelte @@ -9,7 +9,11 @@ let image_url: string | null = null; $: adventure_images = adventures.flatMap((adventure) => - adventure.images.map((image) => ({ image: image.image, adventure: adventure })) + adventure.images.map((image) => ({ + image: image.image, + adventure: adventure, + is_primary: image.is_primary + })) ); $: { @@ -18,6 +22,19 @@ } } + $: { + // sort so that any image in adventure_images .is_primary is first + adventure_images.sort((a, b) => { + if (a.is_primary && !b.is_primary) { + return -1; + } else if (!a.is_primary && b.is_primary) { + return 1; + } else { + return 0; + } + }); + } + function changeSlide(direction: string) { if (direction === 'next' && currentSlide < adventure_images.length - 1) { currentSlide = currentSlide + 1; @@ -78,11 +95,14 @@
    {:else} - - No image available + +
    + +
    + {$t('adventures.no_image_found')} +
    +
    {/if} diff --git a/frontend/src/lib/components/CategoryDropdown.svelte b/frontend/src/lib/components/CategoryDropdown.svelte index d509370..1de8638 100644 --- a/frontend/src/lib/components/CategoryDropdown.svelte +++ b/frontend/src/lib/components/CategoryDropdown.svelte @@ -86,7 +86,7 @@ bind:value={new_category.icon} /> - {#if category.name != 'general'} - + {category.display_name} {category.icon} +
    + - {:else} - - {/if} + {#if category.name != 'general'} + + {:else} + + {/if} +
    + {/each} + {#if categories.length === 0} +

    {$t('categories.no_categories_found')}

    + {/if} + {:else} +
    +
    - {/each} - {#if categories.length === 0} -

    {$t('categories.no_categories_found')}

    {/if} {#if category_to_edit} diff --git a/frontend/src/lib/components/CityCard.svelte b/frontend/src/lib/components/CityCard.svelte new file mode 100644 index 0000000..6be3b9e --- /dev/null +++ b/frontend/src/lib/components/CityCard.svelte @@ -0,0 +1,65 @@ + + +
    +
    +

    {city.name}

    +
    +
    + {city.region_name}, {city.country_name} +
    +
    {city.region}
    +
    +
    + {#if !visited} + + {/if} + {#if visited} + + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/CountryCard.svelte b/frontend/src/lib/components/CountryCard.svelte index a2574cc..3894631 100644 --- a/frontend/src/lib/components/CountryCard.svelte +++ b/frontend/src/lib/components/CountryCard.svelte @@ -1,9 +1,8 @@ + +
    + +
    +
    + (currentAlbum = '')} + type="radio" + class="join-item btn" + bind:group={searchCategory} + value="search" + aria-label="Search" + /> + + +
    +
    + {#if searchCategory === 'search'} + + + + + {:else if searchCategory === 'date'} + + {:else if searchCategory === 'album'} + + {/if} +
    +
    + +

    {immichError}

    +
    + {#if loading} +
    + +
    + {/if} + + {#each immichImages as image} +
    + + Image from Immich +

    + {image.fileCreatedAt?.split('T')[0] || 'Unknown'} +

    + +
    + {/each} + {#if immichNextURL} + + {/if} +
    +
    diff --git a/frontend/src/lib/components/LocationDropdown.svelte b/frontend/src/lib/components/LocationDropdown.svelte new file mode 100644 index 0000000..1ca4b12 --- /dev/null +++ b/frontend/src/lib/components/LocationDropdown.svelte @@ -0,0 +1,356 @@ + + +
    + +
    + {$t('adventures.location_information')} +
    +
    + +
    +
    +
    + + {#if is_custom_location} + + {/if} +
    +
    + +
    +
    + + + +
    +
    + {#if places.length > 0} +
    +

    {$t('adventures.search_results')}

    + +
    + {#each places as place} + + {/each} +
    +
    + {:else if noPlaces} +

    {$t('adventures.no_results')}

    + {/if} + +
    + + + + + {#each markers as marker} + + {/each} + + {#if reverseGeocodePlace} +
    +

    {$t('adventures.location_details')}

    +

    + {$t('adventures.display_name')}: + {reverseGeocodePlace.city + ? reverseGeocodePlace.city + ', ' + : ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country} +

    +

    + {$t('adventures.region')}: + {reverseGeocodePlace.region} + {reverseGeocodePlace.region_visited ? '✅' : '❌'} +

    + {#if reverseGeocodePlace.city} +

    + {$t('adventures.city')}: + {reverseGeocodePlace.city} + {reverseGeocodePlace.city_visited ? '✅' : '❌'} +

    + {/if} +
    + {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} + + {/if} + {#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)} + + {/if} + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/LodgingCard.svelte b/frontend/src/lib/components/LodgingCard.svelte new file mode 100644 index 0000000..a7bd34c --- /dev/null +++ b/frontend/src/lib/components/LodgingCard.svelte @@ -0,0 +1,183 @@ + + +{#if isWarningModalOpen} + (isWarningModalOpen = false)} + on:confirm={deleteTransportation} + /> +{/if} + +
    +
    + +
    +

    {lodging.name}

    +
    +
    + {$t(`lodging.${lodging.type}`) + ' ' + getLodgingIcon(lodging.type)} +
    + +
    +
    + {#if unlinked} +
    {$t('adventures.out_of_range')}
    + {/if} + + +
    + {#if lodging.location} +
    + {$t('adventures.location')}: +

    {lodging.location}

    +
    + {/if} + + {#if lodging.check_in && lodging.check_out} +
    + {$t('adventures.dates')}: +

    + {new Date(lodging.check_in).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric' + })} + - + {new Date(lodging.check_out).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric' + })} +

    +
    + {/if} + {#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))} + {#if lodging.reservation_number} +
    + {$t('adventures.reservation_number')}: +

    {lodging.reservation_number}

    +
    + {/if} + {#if lodging.price} +
    + {$t('adventures.price')}: +

    {lodging.price}

    +
    + {/if} + {/if} +
    + + + {#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))} +
    + + +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/LodgingModal.svelte b/frontend/src/lib/components/LodgingModal.svelte new file mode 100644 index 0000000..d91155c --- /dev/null +++ b/frontend/src/lib/components/LodgingModal.svelte @@ -0,0 +1,428 @@ + + + + + + + diff --git a/frontend/src/lib/components/MarkdownEditor.svelte b/frontend/src/lib/components/MarkdownEditor.svelte index a280d7e..1cfa649 100644 --- a/frontend/src/lib/components/MarkdownEditor.svelte +++ b/frontend/src/lib/components/MarkdownEditor.svelte @@ -1,6 +1,7 @@ @@ -61,14 +54,31 @@ >

    {region.name}

    -

    {region.id}

    +
    +
    +

    {region.country_name}

    +
    +
    +

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

    +
    +
    +

    {region.id}

    +
    +
    - {#if !visited} - + {#if !visited && visited !== undefined} + {/if} - {#if visited} - + {#if visited && visited !== undefined} + + {/if} + {#if region.num_cities > 0} + {/if}
    diff --git a/frontend/src/lib/components/TOTPModal.svelte b/frontend/src/lib/components/TOTPModal.svelte index 77da717..6f7a67e 100644 --- a/frontend/src/lib/components/TOTPModal.svelte +++ b/frontend/src/lib/components/TOTPModal.svelte @@ -37,7 +37,7 @@ } async function fetchSetupInfo() { - const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { + const res = await fetch('/auth/browser/v1/account/authenticators/totp', { method: 'GET' }); const data = await res.json(); @@ -53,7 +53,7 @@ } async function sendTotp() { - const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { + const res = await fetch('/auth/browser/v1/account/authenticators/totp', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -78,7 +78,7 @@ async function getRecoveryCodes() { console.log('getting recovery codes'); - const res = await fetch('/_allauth/browser/v1/account/authenticators/recovery-codes', { + const res = await fetch('/auth/browser/v1/account/authenticators/recovery-codes', { method: 'GET' }); if (res.ok) { diff --git a/frontend/src/lib/components/TransportationModal.svelte b/frontend/src/lib/components/TransportationModal.svelte index 9258b10..9012dd9 100644 --- a/frontend/src/lib/components/TransportationModal.svelte +++ b/frontend/src/lib/components/TransportationModal.svelte @@ -94,6 +94,7 @@ 'User-Agent': `AdventureLog / ${appVersion} ` } }); + console.log(query); let data = await res.json(); return data; }; @@ -243,6 +244,20 @@ {$t('adventures.basic_information')}
    + +
    + + +
    - -
    - - -

    @@ -464,7 +465,7 @@ bind:value={starting_airport} name="starting_airport" class="input input-bordered w-full" - placeholder="Enter starting airport code (e.g., JFK)" + placeholder={$t('transportation.starting_airport_desc')} />
    {/if} diff --git a/frontend/src/lib/components/UserCard.svelte b/frontend/src/lib/components/UserCard.svelte index aefa17e..3224241 100644 --- a/frontend/src/lib/components/UserCard.svelte +++ b/frontend/src/lib/components/UserCard.svelte @@ -50,7 +50,7 @@
    {#if !sharing} - {:else if shared_with && !shared_with.includes(user.uuid)} diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 3b9fde7..d201582 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.7.1'; -export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.7.1'; +export let appVersion = 'v0.8.0'; +export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.8.0'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2025'; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index b2201ca..0e14369 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -5,6 +5,7 @@ import type { Background, Checklist, Collection, + Lodging, Note, Transportation, User @@ -149,6 +150,50 @@ export function groupTransportationsByDate( return groupedTransportations; } +export function groupLodgingByDate( + transportations: Lodging[], + startDate: Date, + numberOfDays: number +): Record { + const groupedTransportations: Record = {}; + + // Initialize all days in the range + for (let i = 0; i < numberOfDays; i++) { + const currentDate = new Date(startDate); + currentDate.setUTCDate(startDate.getUTCDate() + i); + const dateString = currentDate.toISOString().split('T')[0]; + groupedTransportations[dateString] = []; + } + + transportations.forEach((transportation) => { + if (transportation.check_in) { + const transportationDate = new Date(transportation.check_in).toISOString().split('T')[0]; + if (transportation.check_out) { + const endDate = new Date(transportation.check_out).toISOString().split('T')[0]; + + // Loop through all days and include transportation if it falls within the range + for (let i = 0; i < numberOfDays; i++) { + const currentDate = new Date(startDate); + currentDate.setUTCDate(startDate.getUTCDate() + i); + const dateString = currentDate.toISOString().split('T')[0]; + + // Include the current day if it falls within the transportation date range + if (dateString >= transportationDate && dateString <= endDate) { + if (groupedTransportations[dateString]) { + groupedTransportations[dateString].push(transportation); + } + } + } + } else if (groupedTransportations[transportationDate]) { + // If there's no end date, add transportation to the start date only + groupedTransportations[transportationDate].push(transportation); + } + } + }); + + return groupedTransportations; +} + export function groupNotesByDate( notes: Note[], startDate: Date, @@ -279,6 +324,20 @@ export let ADVENTURE_TYPE_ICONS = { other: '❓' }; +export let LODGING_TYPES_ICONS = { + hotel: '🏨', + hostel: '🛏️', + resort: '🏝️', + bnb: '🍳', + campground: '🏕️', + cabin: '🏚️', + apartment: '🏢', + house: '🏠', + villa: '🏡', + motel: '🚗🏨', + other: '❓' +}; + export function getAdventureTypeLabel(type: string) { // return the emoji ADVENTURE_TYPE_ICONS label for the given type if not found return ? emoji if (type in ADVENTURE_TYPE_ICONS) { @@ -296,7 +355,7 @@ export function getRandomBackground() { const newYearsStart = new Date(today.getFullYear() - 1, 11, 31); newYearsStart.setHours(0, 0, 0, 0); - const newYearsEnd = new Date(today.getFullYear(), 0, 7); + const newYearsEnd = new Date(today.getFullYear(), 0, 2); newYearsEnd.setHours(23, 59, 59, 999); if (today >= newYearsStart && today <= newYearsEnd) { return { @@ -347,3 +406,130 @@ export let themes = [ { name: 'aestheticDark', label: 'Aesthetic Dark' }, { name: 'northernLights', label: 'Northern Lights' } ]; + +export function osmTagToEmoji(tag: string) { + switch (tag) { + case 'camp_site': + return '🏕️'; + case 'slipway': + return '🛳️'; + case 'playground': + return '🛝'; + case 'viewpoint': + return '👀'; + case 'cape': + return '🏞️'; + case 'beach': + return '🏖️'; + case 'park': + return '🌳'; + case 'museum': + return '🏛️'; + case 'theme_park': + return '🎢'; + case 'nature_reserve': + return '🌲'; + case 'memorial': + return '🕊️'; + case 'monument': + return '🗿'; + case 'wood': + return '🌲'; + case 'zoo': + return '🦁'; + case 'attraction': + return '🎡'; + case 'ruins': + return '🏚️'; + case 'bay': + return '🌊'; + case 'hotel': + return '🏨'; + case 'motel': + return '🏩'; + case 'pub': + return '🍺'; + case 'restaurant': + return '🍽️'; + case 'cafe': + return '☕'; + case 'bakery': + return '🥐'; + case 'archaeological_site': + return '🏺'; + case 'lighthouse': + return '🗼'; + case 'tree': + return '🌳'; + case 'cliff': + return '⛰️'; + case 'water': + return '💧'; + case 'fishing': + return '🎣'; + case 'golf_course': + return '⛳'; + case 'swimming_pool': + return '🏊'; + case 'stadium': + return '🏟️'; + case 'cave_entrance': + return '🕳️'; + case 'anchor': + return '⚓'; + case 'garden': + return '🌼'; + case 'disc_golf_course': + return '🥏'; + case 'natural': + return '🌿'; + case 'ice_rink': + return '⛸️'; + case 'horse_riding': + return '🐎'; + case 'wreck': + return '🚢'; + case 'water_park': + return '💦'; + case 'picnic_site': + return '🧺'; + case 'axe_throwing': + return '🪓'; + case 'fort': + return '🏰'; + case 'amusement_arcade': + return '🕹️'; + case 'tepee': + return '🏕️'; + case 'track': + return '🏃'; + case 'trampoline_park': + return '🤸'; + case 'dojo': + return '🥋'; + case 'tree_stump': + return '🪵'; + case 'peak': + return '🏔️'; + case 'fitness_centre': + return '🏋️'; + case 'artwork': + return '🎨'; + case 'fast_food': + return '🍔'; + case 'ice_cream': + return '🍦'; + default: + return '📍'; // Default placeholder emoji for unknown tags + } +} + +export function debounce(func: Function, timeout: number) { + let timer: number | NodeJS.Timeout; + return (...args: any) => { + clearTimeout(timer); + timer = setTimeout(() => { + func(...args); + }, timeout); + }; +} diff --git a/frontend/src/lib/json/backgrounds.json b/frontend/src/lib/json/backgrounds.json index be43594..7b1b896 100644 --- a/frontend/src/lib/json/backgrounds.json +++ b/frontend/src/lib/json/backgrounds.json @@ -24,6 +24,11 @@ "url": "backgrounds/adventurelog_showcase_5.webp", "author": "Sean Morley", "location": "Hoboken, New Jersey, USA" + }, + { + "url": "backgrounds/adventurelog_showcase_6.webp", + "author": "Sean Morley", + "location": "Smugglers' Notch Resort, Vermont, USA" } ] } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9dac462..fd0eadb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,7 +1,6 @@ export type User = { pk: number; username: string; - email: string | null; first_name: string | null; last_name: string | null; date_joined: string | null; @@ -9,12 +8,13 @@ export type User = { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; + disable_password: boolean; }; export type Adventure = { id: string; user_id: string | null; - type: string; name: string; location?: string | null; activity_types?: string[] | null; @@ -24,6 +24,7 @@ export type Adventure = { images: { id: string; image: string; + is_primary: boolean; }[]; visits: { id: string; @@ -39,6 +40,8 @@ export type Adventure = { updated_at?: string | null; is_visited?: boolean; category: Category | null; + attachments: Attachment[]; + user?: User | null; }; export type Country = { @@ -50,19 +53,42 @@ export type Country = { capital: string; num_regions: number; num_visits: number; + longitude: number | null; + latitude: number | null; }; export type Region = { - id: number; + id: string; name: string; - country: number; + country: string; latitude: number; longitude: number; + num_cities: number; + country_name: string; +}; + +export type City = { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + region: string; + region_name: string; + country_name: 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; @@ -91,6 +117,7 @@ export type Collection = { end_date: string | null; transportations?: Transportation[]; notes?: Note[]; + lodging?: Lodging[]; checklists?: Checklist[]; is_archived?: boolean; shared_with: string[] | undefined; @@ -179,11 +206,15 @@ 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; + location_name: string; }; export type Category = { @@ -194,3 +225,67 @@ export type Category = { user_id: string; num_adventures?: number | null; }; + +export type ImmichIntegration = { + id: string; + server_url: string; + api_key: string; +}; + +export type ImmichAlbum = { + albumName: string; + description: string; + albumThumbnailAssetId: string; + createdAt: string; + updatedAt: string; + id: string; + ownerId: string; + owner: { + id: string; + email: string; + name: string; + profileImagePath: string; + avatarColor: string; + profileChangedAt: string; + }; + albumUsers: any[]; + shared: boolean; + hasSharedLink: boolean; + startDate: string; + endDate: string; + assets: any[]; + assetCount: number; + isActivityEnabled: boolean; + order: string; + lastModifiedAssetTimestamp: string; +}; + +export type Attachment = { + id: string; + file: string; + adventure: string; + extension: string; + user_id: string; + name: string; +}; + +export type Lodging = { + id: string; + user_id: string; + name: string; + type: string; + description: string | null; + rating: number | null; + link: string | null; + check_in: string | null; // ISO 8601 date string + check_out: string | null; // ISO 8601 date string + reservation_number: string | null; + price: number | null; + latitude: number | null; + longitude: number | null; + location: string | null; + is_public: boolean; + collection: string | null; + created_at: string; // ISO 8601 date string + updated_at: string; // ISO 8601 date string +}; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index c2485ca..56c84dc 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -1,37 +1,37 @@ { "about": { - "about": "Um", + "about": "Über", "close": "Schließen", "license": "Lizenziert unter der GPL-3.0-Lizenz.", "message": "Hergestellt mit ❤️ in den Vereinigten Staaten.", "nominatim_1": "Standortsuche und Geokodierung werden bereitgestellt von", - "nominatim_2": "Ihre Daten werden unter der ODbL-Lizenz lizenziert.", - "oss_attributions": "Open-Source-Zuschreibungen", + "nominatim_2": "Deren Daten sind unter der ODbL-Lizenz lizenziert.", + "oss_attributions": "Open Source Quellenangaben", "other_attributions": "Weitere Hinweise finden Sie in der README-Datei.", "source_code": "Quellcode" }, "adventures": { "activities": { "activity": "Aktivität 🏄", - "art_museums": "Kunst", - "attraction": "Attraktion 🎢", + "art_museums": "Kunst & Museen", + "attraction": "Sehenswürdigkeit 🎢", "culture": "Kultur 🎭", "dining": "Essen 🍽️", "event": "Veranstaltung 🎉", - "festivals": "Feste 🎪", + "festivals": "Festivals 🎪", "fitness": "Fitness 🏋️", "general": "Allgemein 🌍", "hiking": "Wandern 🥾", - "historical_sites": "Historische Stätten 🏛️", - "lodging": "Unterkunft 🛌", - "music_concerts": "Musik", + "historical_sites": "Historische Denkmäler 🏛️", + "lodging": "Herberge 🛌", + "music_concerts": "Musik & Konzerte", "nightlife": "Nachtleben 🌃", - "other": "Andere", - "outdoor": "Draußen 🏞️", + "other": "Sonstiges", + "outdoor": "Outdoor 🏞️", "shopping": "Einkaufen 🛍️", "spiritual_journeys": "Spirituelle Reisen 🧘‍♀️", "transportation": "Transport 🚗", - "volunteer_work": "Freiwilligenarbeit 🤝", + "volunteer_work": "Ehrenamt 🤝", "water_sports": "Wassersport 🚤", "wildlife": "Wildtiere 🦒" }, @@ -44,8 +44,8 @@ "delete": "Löschen", "edit_adventure": "Abenteuer bearbeiten", "no_image_found": "Kein Bild gefunden", - "open_details": "Details öffnen", - "remove_from_collection": "Aus der Sammlung entfernen", + "open_details": "Details", + "remove_from_collection": "Aus Sammlung entfernen", "adventure": "Abenteuer", "adventure_delete_success": "Abenteuer erfolgreich gelöscht!", "adventure_details": "Abenteuerdetails", @@ -55,13 +55,13 @@ "archived_collection_message": "Sammlung erfolgreich archiviert!", "archived_collections": "Archivierte Sammlungen", "ascending": "Aufsteigend", - "cancel": "Stornieren", + "cancel": "Abbrechen", "category_filter": "Kategoriefilter", - "clear": "Klar", + "clear": "zurücksetzen", "close_filters": "Filter schließen", "collection": "Sammlung", - "collection_adventures": "Schließen Sie Sammlungsabenteuer ein", - "count_txt": "Ergebnisse, die Ihrer Suche entsprechen", + "collection_adventures": "Abenteuer aus Sammlung berücksichtigen", + "count_txt": "Suchergebnisse", "date": "Datum", "dates": "Termine", "delete_adventure": "Abenteuer löschen", @@ -77,24 +77,24 @@ "image_removed_success": "Bild erfolgreich entfernt!", "image_upload_error": "Fehler beim Hochladen des Bildes", "image_upload_success": "Bild erfolgreich hochgeladen!", - "latitude": "Breite", - "longitude": "Länge", + "latitude": "Breitengrad", + "longitude": "Längengrad", "my_collections": "Meine Sammlungen", "name": "Name", "no_image_url": "Unter dieser URL wurde kein Bild gefunden.", "not_found": "Abenteuer nicht gefunden", - "not_found_desc": "Das von Ihnen gesuchte Abenteuer konnte nicht gefunden werden. \nBitte probieren Sie ein anderes Abenteuer aus oder schauen Sie später noch einmal vorbei.", - "open_filters": "Öffnen Sie Filter", - "order_by": "Bestellen nach", - "order_direction": "Bestellrichtung", + "not_found_desc": "Das von Ihnen gesuchte Abenteuer konnte nicht gefunden werden. \nBitte versuchen Sie ein anderes Abenteuer aus oder schauen Sie später noch mal vorbei.", + "open_filters": "Filter öffnen", + "order_by": "Sortieren nach", + "order_direction": "Sortierreihenfolge", "planned": "Geplant", "private": "Privat", "public": "Öffentlich", "rating": "Bewertung", - "share": "Aktie", + "share": "Teilen", "sort": "Sortieren", "sources": "Quellen", - "start_before_end_error": "Das Startdatum muss vor dem Enddatum liegen", + "start_before_end_error": "Das Start- muss vor dem Enddatum liegen", "unarchive": "Dearchivieren", "unarchived_collection_message": "Sammlung erfolgreich dearchiviert!", "updated": "Aktualisiert", @@ -106,18 +106,18 @@ "activity": "Aktivität", "activity_types": "Aktivitätstypen", "add": "Hinzufügen", - "add_an_activity": "Fügen Sie eine Aktivität hinzu", + "add_an_activity": "Aktivität hinzufügen", "add_notes": "Notizen hinzufügen", "adventure_create_error": "Das Abenteuer konnte nicht erstellt werden", - "adventure_created": "Abenteuer geschaffen", + "adventure_created": "Abenteuer erstellt", "adventure_update_error": "Das Abenteuer konnte nicht aktualisiert werden", "adventure_updated": "Abenteuer aktualisiert", - "basic_information": "Grundlegende Informationen", + "basic_information": "Basisdaten", "category": "Kategorie", - "clear_map": "Klare Karte", + "clear_map": "Karte leeren", "copy_link": "Link kopieren", "create_new": "Neu erstellen...", - "date_constrain": "Auf Abholtermine beschränken", + "date_constrain": "Beschränke auf Sammlungstermine", "description": "Beschreibung", "end_date": "Enddatum", "fetch_image": "Bild abrufen", @@ -137,8 +137,8 @@ "no_start_date": "Bitte geben Sie ein Startdatum ein", "public_adventure": "Öffentliches Abenteuer", "remove": "Entfernen", - "save_next": "Speichern", - "search_for_location": "Suchen Sie nach einem Ort", + "save_next": "Speichern & weiter", + "search_for_location": "Nach einem Ort suchen", "search_results": "Suchergebnisse", "see_adventures": "Siehe Abenteuer", "select_adventure_category": "Wählen Sie die Abenteuerkategorie", @@ -150,92 +150,124 @@ "warning": "Warnung", "wiki_desc": "Ruft einen Auszug aus einem Wikipedia-Artikel ab, der zum Namen des Abenteuers passt.", "wikipedia": "Wikipedia", - "adventure_not_found": "Es sind keine Abenteuer zum Anzeigen vorhanden. \nFügen Sie einige über die Plus-Schaltfläche unten rechts hinzu oder versuchen Sie, die Filter zu ändern!", + "adventure_not_found": "Keine Abenteuer vorhanden. \nFügen Sie welche über die Plus-Schaltfläche unten rechts hinzu oder versuchen Sie, die Filter zu ändern!", "all": "Alle", "error_updating_regions": "Fehler beim Aktualisieren der Regionen", "mark_region_as_visited": "Region {region}, {country} als besucht markieren?", - "mark_visited": "Mark besucht", + "mark_visited": "als besucht markieren", "my_adventures": "Meine Abenteuer", "no_adventures_found": "Keine Abenteuer gefunden", - "no_collections_found": "Es wurden keine Sammlungen gefunden, zu denen dieses Abenteuer hinzugefügt werden kann.", + "no_collections_found": "Es wurden keine Sammlungen gefunden, die zu diesem Abenteuer hinzugefügt werden können.", "no_linkable_adventures": "Es wurden keine Abenteuer gefunden, die mit dieser Sammlung verknüpft werden können.", "not_visited": "Nicht besucht", "regions_updated": "Regionen aktualisiert", "update_visited_regions": "Besuchte Regionen aktualisieren", "update_visited_regions_disclaimer": "Dies kann je nach Anzahl der Abenteuer, die Sie besucht haben, eine Weile dauern.", "visited_region_check": "Überprüfung der besuchten Region", - "visited_region_check_desc": "Wenn Sie diese Option auswählen, überprüft der Server alle von Ihnen besuchten Abenteuer und markiert die Regionen, in denen sie sich befinden, als im Rahmen von Weltreisen besucht.", - "add_new": "Neu hinzufügen...", + "visited_region_check_desc": "Wenn Sie diese Option auswählen, überprüft der Server alle von Ihnen besuchten Abenteuer und markiert die Regionen, in denen sie sich befinden, im Bereich Weltreisen als besucht.", + "add_new": "Neu...", "checklist": "Checkliste", "checklists": "Checklisten", - "collection_completed": "Du hast diese Sammlung vervollständigt!", + "collection_completed": "Du hast die Sammlung vervollständigt!", "collection_stats": "Sammlungsstatistiken", "days": "Tage", "itineary_by_date": "Reiseroute nach Datum", - "keep_exploring": "Entdecken Sie weiter!", - "link_new": "Link Neu...", + "keep_exploring": "Weiter erkunden!", + "link_new": "Neuer Link...", "linked_adventures": "Verknüpfte Abenteuer", "links": "Links", - "no_end_date": "Bitte geben Sie ein Enddatum ein", + "no_end_date": "Bitte ein Enddatum eingeben", "note": "Notiz", "notes": "Notizen", - "nothing_planned": "Für diesen Tag ist nichts geplant. \nGenieße die Reise!", + "nothing_planned": "Für heute ist nichts geplant. \nGenieße die Reise!", "transportation": "Transport", "transportations": "Transporte", - "visit_link": "Besuchen Sie den Link", - "collection_archived": "Diese Sammlung wurde archiviert.", + "visit_link": "Besuche Link", + "collection_archived": "Die Sammlung wurde archiviert.", "day": "Tag", - "add_a_tag": "Fügen Sie ein Tag hinzu", + "add_a_tag": "Fügen Sie ein Schlagwort hinzu", "tags": "Schlagworte", "set_to_pin": "Auf „Anpinnen“ setzen", "category_fetch_error": "Fehler beim Abrufen der Kategorien", "copied_to_clipboard": "In die Zwischenablage kopiert!", "copy_failed": "Das Kopieren ist fehlgeschlagen", "adventure_calendar": "Abenteuerkalender", - "emoji_picker": "Emoji-Picker", + "emoji_picker": "Emoji-Wähler", "hide": "Verstecken", "show": "Zeigen", "download_calendar": "Kalender herunterladen", - "md_instructions": "Schreiben Sie hier Ihren Abschlag...", + "md_instructions": "Hier den Markdowntext schreiben...", "preview": "Vorschau", - "checklist_delete_confirm": "Sind Sie sicher, dass Sie diese Checkliste löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.", + "checklist_delete_confirm": "Sind Sie sicher, dass Sie diese Checkliste löschen möchten? \nDies kann nicht rückgängig gemacht werden.", "clear_location": "Standort löschen", "date_information": "Datumsinformationen", "delete_checklist": "Checkliste löschen", "delete_note": "Notiz löschen", "delete_transportation": "Transport löschen", "end": "Ende", - "ending_airport": "Endflughafen", + "ending_airport": "Zielflughafen", "flight_information": "Fluginformationen", - "from": "Aus", - "no_location_found": "Kein Standort gefunden", - "note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.", - "out_of_range": "Nicht im Datumsbereich der Reiseroute", + "from": "Von", + "no_location_found": "Keinen Standort gefunden", + "note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDies kann nicht rückgängig gemacht werden!", + "out_of_range": "Außerhalb des geplanten Reisezeitraums", "show_region_labels": "Regionsbeschriftungen anzeigen", "start": "Start", "starting_airport": "Startflughafen", - "to": "Zu", - "transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden." + "to": "Nach", + "transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDies lässt sich nicht rückgängig machen.", + "show_map": "Karte anzeigen", + "will_be_marked": "wird als besucht markiert, sobald das Abenteuer gespeichert wird.", + "cities_updated": "Städte aktualisiert", + "create_adventure": "Erstelle Abenteuer", + "no_adventures_to_recommendations": "Keine Abenteuer gefunden. \nFügen Sie mindestens ein Abenteuer hinzu, um Empfehlungen zu erhalten.", + "finding_recommendations": "Entdecken Sie verborgene Schätze für Ihr nächstes Abenteuer", + "attachment": "Anhang", + "attachment_delete_success": "Anhang gelöscht!", + "attachment_name": "Anhangsname", + "attachment_update_error": "Fehler beim Aktualisieren des Anhangs", + "attachment_update_success": "Anhang erfolgreich aktualisiert!", + "attachment_upload_error": "Fehler beim Hochladen des Anhangs", + "attachment_upload_success": "Anhang erfolgreich hochgeladen!", + "attachments": "Anhänge", + "gpx_tip": "Laden Sie GPX-Dateien als Anhänge hoch, um sie auf der Karte anzuzeigen!", + "images": "Bilder", + "primary": "Primär", + "upload": "Hochladen", + "view_attachment": "Anhang anzeigen", + "of": "von", + "city": "Stadt", + "display_name": "Anzeigename", + "location_details": "Standortdetails", + "lodging": "Unterkunft", + "region": "Region", + "delete_lodging": "Unterkunft löschen", + "lodging_delete_confirm": "Sind Sie sicher, dass Sie diese Unterkunft löschen möchten? \nDies lässt sich nicht rückgängig machen!", + "lodging_information": "Informationen zur Unterkunft", + "price": "Preis", + "reservation_number": "Reservierungsnummer", + "welcome_map_info": "Frei zugängliche Abenteuer auf diesem Server", + "open_in_maps": "In Karten öffnen" }, "home": { - "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", - "desc_2": "AdventureLog wurde entwickelt, um Ihre Reise zu vereinfachen und Ihnen die Tools und Ressourcen zur Verfügung zu stellen, mit denen Sie Ihr nächstes unvergessliches Abenteuer planen, packen und navigieren können.", + "desc_1": "Entdecken, planen und erkunden Sie mühelos", + "desc_2": "AdventureLog wurde entwickelt, um Ihre Reise zu vereinfachen und stellt Ihnen alle nötigen Werkzeuge und Ressourcen zur Verfügung, mit denen Sie Ihr nächstes unvergessliches Abenteuer planen, packen und erleben können.", "feature_1": "Reisetagebuch", - "feature_1_desc": "Behalten Sie den Überblick über Ihre Abenteuer mit einem personalisierten Reisetagebuch und teilen Sie Ihre Erlebnisse mit Freunden und Familie.", + "feature_1_desc": "Dokumentieren Sie Ihre Abenteuer mit einem persönlichen Reisetagebuch und teilen Sie Ihre Erlebnisse mit Freunden und Familie.", "feature_2": "Reiseplanung", "feature_3": "Reisekarte", - "feature_3_desc": "Sehen Sie sich Ihre Reisen rund um die Welt mit einer interaktiven Karte an und erkunden Sie neue Reiseziele.", - "go_to": "Gehen Sie zu AdventureLog", + "feature_3_desc": "Betrachten Sie Ihre Reisen rund um die Welt auf einer interaktiven Karte und entdecken Sie neue Ziele.", + "go_to": "AdventureLog öffnen", "hero_1": "Entdecken Sie die aufregendsten Abenteuer der Welt", - "hero_2": "Entdecken und planen Sie Ihr nächstes Abenteuer mit AdventureLog. \nEntdecken Sie atemberaubende Reiseziele, erstellen Sie individuelle Reiserouten und bleiben Sie unterwegs in Verbindung.", + "hero_2": "Entdecken und planen Sie Ihr nächstes Abenteuer mit AdventureLog. Erkunden Sie atemberaubende Reiseziele, erstellen Sie individuelle Reisepläne und bleiben Sie unterwegs stets verbunden.", "key_features": "Hauptmerkmale", - "feature_2_desc": "Erstellen Sie ganz einfach individuelle Reiserouten und erhalten Sie eine tagesaktuelle Aufschlüsselung Ihrer Reise." + "feature_2_desc": "Erstellen Sie mühelos individuelle Reisepläne und erhalten Sie eine detaillierte Tagesübersicht Ihrer Reise." }, "navbar": { "about": "Über AdventureLog", "adventures": "Abenteuer", "collections": "Sammlungen", - "discord": "Zwietracht", + "discord": "Discord", "documentation": "Dokumentation", "greeting": "Hallo", "logout": "Abmelden", @@ -245,24 +277,25 @@ "search": "Suchen", "settings": "Einstellungen", "shared_with_me": "Mit mir geteilt", - "theme_selection": "Themenauswahl", + "theme_selection": "Design", "themes": { "aqua": "Aqua", "dark": "Dunkel", "forest": "Wald", - "light": "Licht", + "light": "Hell", "night": "Nacht", "aestheticDark": "Ästhetisches Dunkel", - "aestheticLight": "Ästhetisches Licht", - "northernLights": "Nordlicht" + "aestheticLight": "Ästhetisches Hell", + "northernLights": "Nordlichter" }, "users": "Benutzer", "worldtravel": "Weltreisen", - "my_tags": "Meine Tags", - "tag": "Etikett", - "language_selection": "Sprache", + "my_tags": "Meine Schlagworte", + "tag": "Schlagwort", + "language_selection": "Sprachauswahl", "support": "Unterstützung", - "calendar": "Kalender" + "calendar": "Kalender", + "admin_panel": "Administration" }, "auth": { "confirm_password": "Passwort bestätigen", @@ -271,7 +304,7 @@ "forgot_password": "Passwort vergessen?", "last_name": "Nachname", "login": "Login", - "login_error": "Anmeldung mit den angegebenen Anmeldeinformationen nicht möglich.", + "login_error": "Die Anmeldung ist mit den angegebenen Anmeldeinformationen nicht möglich.", "password": "Passwort", "registration_disabled": "Die Registrierung ist derzeit deaktiviert.", "signup": "Melden Sie sich an", @@ -282,10 +315,15 @@ "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", + "no_public_adventures": "Keine öffentlichen Abenteuer gefunden", + "no_public_collections": "Keine öffentlichen Sammlungen gefunden", + "user_adventures": "Benutzerabenteuer", + "user_collections": "Benutzersammlungen" }, "users": { - "no_users_found": "Keine Benutzer mit öffentlichen Profilen gefunden." + "no_users_found": "Keine Benutzer mit öffentlichem Profil gefunden." }, "worldtravel": { "all": "Alle", @@ -296,92 +334,123 @@ "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 besucht in", + "cities": "Städte", + "failed_to_mark_visit": "Fehler beim Markieren des Besuchs von", + "failed_to_remove_visit": "Fehler beim Entfernen des Besuchs", + "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 von" }, "settings": { - "account_settings": "Benutzerkontoeinstellungen", - "current_email": "Aktuelle E-Mail", + "account_settings": "Benutzerkonto", + "current_email": "Bisherige E-Mail", "email_change": "E-Mail ändern", "new_email": "Neue E-Mail", "new_password": "Neues Passwort", "no_email_set": "Keine E-Mail-Adresse festgelegt", "password_change": "Kennwort ändern", - "settings_page": "Einstellungsseite", + "settings_page": "Einstellungen", "update": "Aktualisieren", "update_error": "Fehler beim Aktualisieren der Einstellungen", "update_success": "Einstellungen erfolgreich aktualisiert!", "change_password": "Kennwort ändern", "confirm_new_password": "Bestätigen Sie das neue Passwort", - "invalid_token": "Token ist ungültig oder abgelaufen", - "login_redir": "Anschließend werden Sie zur Anmeldeseite weitergeleitet.", + "invalid_token": "Das Token ist ungültig oder abgelaufen", + "login_redir": "Anschließend erfolgt eine Weiterleitung zur Anmeldeseite.", "missing_email": "Bitte geben Sie eine E-Mail-Adresse ein", "password_does_not_match": "Passwörter stimmen nicht überein", "password_is_required": "Passwort ist erforderlich", "possible_reset": "Wenn die von Ihnen angegebene E-Mail-Adresse mit einem Konto verknüpft ist, erhalten Sie eine E-Mail mit Anweisungen zum Zurücksetzen Ihres Passworts!", "reset_password": "Passwort zurücksetzen", - "submit": "Einreichen", + "submit": "Absenden", "token_required": "Zum Zurücksetzen des Passworts sind Token und UID erforderlich.", "about_this_background": "Über diesen Hintergrund", "join_discord": "Treten Sie dem Discord bei", "join_discord_desc": "um Ihre eigenen Fotos zu teilen. \nVeröffentlichen Sie sie im", - "photo_by": "Foto von", - "change_password_error": "Passwort kann nicht geändert werden. \nUngültiges aktuelles Passwort oder ungültiges neues Passwort.", + "photo_by": "Foto aufgenommen von", + "change_password_error": "Das Passwort kann nicht geändert werden. \nUngültiges aktuelles oder neues Passwort.", "current_password": "Aktuelles Passwort", "password_change_lopout_warning": "Nach der Passwortänderung werden Sie abgemeldet.", - "authenticator_code": "Authentifikatorcode", + "authenticator_code": "Authentifizierungscode", "copy": "Kopie", "disable_mfa": "Deaktivieren Sie MFA", - "email_added": "E-Mail erfolgreich hinzugefügt!", + "email_added": "E-Mail hinzugefügt!", "email_added_error": "Fehler beim Hinzufügen der E-Mail", - "email_removed": "E-Mail erfolgreich entfernt!", + "email_removed": "E-Mail entfernt!", "email_removed_error": "Fehler beim Entfernen der E-Mail", - "email_set_primary": "E-Mail erfolgreich als primäre E-Mail-Adresse festgelegt!", - "email_set_primary_error": "Fehler beim Festlegen der E-Mail-Adresse als primär", - "email_verified": "E-Mail erfolgreich bestätigt!", + "email_set_primary": "E-Mail als primäre E-Mail-Adresse festgelegt!", + "email_set_primary_error": "Die E-Mail-Adresse konnte nicht als primäre Adresse festgelegt werden", + "email_verified": "E-Mail bestätigt!", "email_verified_erorr_desc": "Ihre E-Mail-Adresse konnte nicht bestätigt werden. \nBitte versuchen Sie es erneut.", - "email_verified_error": "Fehler bei der E-Mail-Bestätigung", + "email_verified_error": "Fehler bei der Verifizierung der E-Mail-Adresse", "email_verified_success": "Ihre E-Mail-Adresse wurde bestätigt. \nSie können sich jetzt anmelden.", "enable_mfa": "Aktivieren Sie MFA", "error_change_password": "Fehler beim Ändern des Passworts. \nBitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut.", "generic_error": "Bei der Bearbeitung Ihrer Anfrage ist ein Fehler aufgetreten.", "invalid_code": "Ungültiger MFA-Code", "invalid_credentials": "Ungültiger Benutzername oder Passwort", - "make_primary": "Machen Sie primär", - "mfa_disabled": "Multi-Faktor-Authentifizierung erfolgreich deaktiviert!", - "mfa_enabled": "Multi-Faktor-Authentifizierung erfolgreich aktiviert!", - "mfa_not_enabled": "MFA ist nicht aktiviert", - "mfa_page_title": "Multi-Faktor-Authentifizierung", - "mfa_required": "Eine Multi-Faktor-Authentifizierung ist erforderlich", + "make_primary": "Als primär festlegen", + "mfa_disabled": "MFA-Authentifizierung deaktiviert!", + "mfa_enabled": "MFA-Authentifizierung aktiviert!", + "mfa_not_enabled": "MFA nicht aktiviert", + "mfa_page_title": "Multi-Faktor-Authentifizierung (MFA)", + "mfa_required": "MFA erforderlich", "no_emai_set": "Keine E-Mail-Adresse festgelegt", "not_verified": "Nicht verifiziert", "primary": "Primär", "recovery_codes": "Wiederherstellungscodes", - "recovery_codes_desc": "Dies sind Ihre Wiederherstellungscodes. \nBewahren Sie sie sicher auf. \nSie werden sie nicht mehr sehen können.", + "recovery_codes_desc": "Dies sind Ihre Wiederherstellungscodes. \nBewahren Sie sie sicher auf. \nSie werden nicht erneut angezeigt.", "reset_session_error": "Bitte melden Sie sich ab und wieder an, um Ihre Sitzung zu aktualisieren, und versuchen Sie es erneut.", "verified": "Verifiziert", "verify": "Verifizieren", "verify_email_error": "Fehler bei der E-Mail-Bestätigung. \nVersuchen Sie es in ein paar Minuten noch einmal.", - "verify_email_success": "E-Mail-Bestätigung erfolgreich gesendet!", - "add_email_blocked": "Sie können keine E-Mail-Adresse zu einem Konto hinzufügen, das durch die Zwei-Faktor-Authentifizierung geschützt ist.", + "verify_email_success": "E-Mail-Bestätigung gesendet!", + "add_email_blocked": "Sie können keine E-Mail-Adresse zu einem Konto hinzufügen, das durch die Zwei-Faktor-Authentifizierung (MFA) geschützt ist.", "required": "Dieses Feld ist erforderlich", - "csrf_failed": "CSRF-Token konnte nicht abgerufen werden", + "csrf_failed": "Fehler beim Abrufen des CSRF-Tokens", "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": "Administrationseinstellungen", + "documentation_link": "Dokumentation", + "launch_account_connections": "Kontoverbindungen starten", + "launch_administration_panel": "Administrationseinstellungen öffnen", + "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", + "disable_password": "Passwort deaktivieren", + "password_disable": "Deaktivieren Sie die Passwortauthentifizierung", + "password_disable_desc": "Durch Deaktivieren der Kennwortauthentifizierung werden Sie daran hindern, sich mit einem Kennwort anzumelden. \nSie müssen einen sozialen oder OIDC-Anbieter verwenden, um sich anzumelden. Sollte Ihr sozialer Anbieter nicht verknüpft werden, wird die Kennwortauthentifizierung automatisch wieder aufgenommen, auch wenn diese Einstellung deaktiviert ist.", + "password_disable_warning": "Derzeit ist die Kennwortauthentifizierung deaktiviert. \nAnmelden Sie über einen sozialen oder OIDC -Anbieter erforderlich.", + "password_disabled": "Kennwortauthentifizierung deaktiviert", + "password_disabled_error": "Fehler beim Deaktivieren der Kennwortauthentifizierung. \nStellen Sie sicher, dass ein sozialer oder OIDC -Anbieter mit Ihrem Konto verknüpft ist.", + "password_enabled": "Kennwortauthentifizierung aktiviert", + "password_enabled_error": "Fehler zur Kennwortauthentifizierung." }, "checklist": { - "add_item": "Artikel hinzufügen", + "add_item": "Eintrag hinzufügen", "checklist_delete_error": "Fehler beim Löschen der Checkliste", - "checklist_deleted": "Checkliste erfolgreich gelöscht!", + "checklist_deleted": "Checkliste gelöscht!", "checklist_editor": "Checklisten-Editor", "checklist_public": "Diese Checkliste ist öffentlich, da sie sich in einer öffentlichen Sammlung befindet.", "editing_checklist": "Checkliste bearbeiten", "failed_to_save": "Checkliste konnte nicht gespeichert werden", - "item": "Artikel", - "item_already_exists": "Artikel existiert bereits", - "item_cannot_be_empty": "Das Element darf nicht leer sein", - "items": "Artikel", - "new_item": "Neuer Artikel", + "item": "Eintrag", + "item_already_exists": "Dieser Eintrag existiert bereits", + "item_cannot_be_empty": "Der Eintrag darf nicht leer sein", + "items": "Einträge", + "new_item": "Neuer Eintrag", "save": "Speichern", "checklist_viewer": "Checklisten-Viewer", "new_checklist": "Neue Checkliste" @@ -393,7 +462,7 @@ "edit_collection": "Sammlung bearbeiten", "error_creating_collection": "Fehler beim Erstellen der Sammlung", "error_editing_collection": "Fehler beim Bearbeiten der Sammlung", - "new_collection": "Neue Kollektion", + "new_collection": "Neue Sammlung", "public_collection": "Öffentliche Sammlung" }, "notes": { @@ -405,7 +474,7 @@ "note_deleted": "Notiz erfolgreich gelöscht!", "note_editor": "Notizeditor", "note_public": "Diese Notiz ist öffentlich, da sie sich in einer öffentlichen Sammlung befindet.", - "open": "Offen", + "open": "Öffnen", "save": "Speichern", "invalid_url": "Ungültige URL", "note_viewer": "Notizenbetrachter" @@ -414,9 +483,9 @@ "date_and_time": "Datum", "date_time": "Startdatum", "edit": "Bearbeiten", - "edit_transportation": "Transport bearbeiten", + "edit_transportation": "Verkehrsmittel bearbeiten", "end_date_time": "Enddatum", - "error_editing_transportation": "Fehler beim Bearbeiten des Transports", + "error_editing_transportation": "Fehler beim Bearbeiten des Verkehrsmittels", "flight_number": "Flugnummer", "from_location": "Vom Standort", "modes": { @@ -429,16 +498,19 @@ "plane": "Flugzeug", "train": "Zug" }, - "transportation_added": "Transport erfolgreich hinzugefügt!", - "transportation_delete_error": "Fehler beim Löschen des Transports", - "transportation_deleted": "Transport erfolgreich gelöscht!", - "transportation_edit_success": "Transport erfolgreich bearbeitet!", + "transportation_added": "Verkehrsmittel erfolgreich hinzugefügt!", + "transportation_delete_error": "Fehler beim Löschen des Verkehrsmittels", + "transportation_deleted": "Verkehrsmittel erfolgreich gelöscht!", + "transportation_edit_success": "Verkehrsmittel erfolgreich bearbeitet!", "type": "Typ", - "new_transportation": "Neue Transportmittel", + "new_transportation": "Neues Verkehrsmittel", "provide_start_date": "Bitte geben Sie ein Startdatum an", "start": "Start", "to_location": "Zum Standort", - "transport_type": "Transporttyp" + "transport_type": "Verkehrsmittel", + "ending_airport_desc": "Geben Sie den Flughafencode des Zielflughafens ein (z. B. LAX)", + "fetch_location_information": "Standortinformationen abrufen", + "starting_airport_desc": "Geben Sie den Flughafencode des Startflughafens ein (z. B. JFK)" }, "search": { "adventurelog_results": "AdventureLog-Ergebnisse", @@ -454,34 +526,25 @@ "show_visited_regions": "Besuchte Regionen anzeigen", "view_details": "Details anzeigen" }, - "languages": { - "de": "Deutsch", - "en": "Englisch", - "es": "Spanisch", - "fr": "Französisch", - "it": "Italienisch", - "nl": "Niederländisch", - "sv": "Schwedisch", - "zh": "chinesisch", - "pl": "Polnisch" - }, + "languages": {}, "share": { "no_users_shared": "Keine Benutzer geteilt mit", "not_shared_with": "Nicht geteilt mit", - "share_desc": "Teilen Sie diese Sammlung mit anderen Benutzern.", + "share_desc": "Sammlung mit anderen Benutzern teilen.", "shared": "Geteilt", "shared_with": "Geteilt mit", "unshared": "Nicht geteilt", "with": "mit", - "go_to_settings": "Gehen Sie zu den Einstellungen", + "go_to_settings": "Gehe zu Einstellungen", "no_shared_found": "Es wurden keine Sammlungen gefunden, die mit Ihnen geteilt wurden.", - "set_public": "Damit Benutzer Inhalte mit Ihnen teilen können, muss Ihr Profil auf „Öffentlich“ eingestellt sein." + "set_public": "Damit Benutzer Inhalte mit Ihnen teilen können, muss Ihr Profil auf „Öffentlich“ gesetzt sein." }, "profile": { "member_since": "Mitglied seit", "user_stats": "Benutzerstatistiken", "visited_countries": "Besuchte Länder", - "visited_regions": "Besuchte Regionen" + "visited_regions": "Besuchte Regionen", + "visited_cities": "Besuchte Städte" }, "categories": { "category_name": "Kategoriename", @@ -489,16 +552,79 @@ "icon": "Symbol", "manage_categories": "Kategorien verwalten", "no_categories_found": "Keine Kategorien gefunden.", - "select_category": "Kategorie auswählen", + "select_category": "Kategorie wählen", "update_after_refresh": "Die Abenteuerkarten werden aktualisiert, sobald Sie die Seite aktualisieren." }, "dashboard": { - "add_some": "Warum beginnen Sie nicht mit der Planung Ihres nächsten Abenteuers? \nSie können ein neues Abenteuer hinzufügen, indem Sie auf die Schaltfläche unten klicken.", + "add_some": "Warum nicht gleich Ihr nächstes Abenteuer planen? Sie können ein neues Abenteuer hinzufügen, indem Sie auf den Button unten klicken.", "countries_visited": "Besuchte Länder", "no_recent_adventures": "Keine aktuellen Abenteuer?", "recent_adventures": "Aktuelle Abenteuer", - "total_adventures": "Totale Abenteuer", + "total_adventures": "Abenteuer insgesamt", "total_visited_regions": "Insgesamt besuchte Regionen", - "welcome_back": "Willkommen zurück" + "welcome_back": "Willkommen zurück", + "total_visited_cities": "Insgesamt besuchte Städte" + }, + "immich": { + "api_key": "Immich-API-Schlüssel", + "api_note": "Hinweis: Dies muss die URL zum Immich-API-Server sein, daher endet sie wahrscheinlich mit /api, es sei denn, Sie haben eine benutzerdefinierte Konfiguration.", + "disable": "Deaktivieren", + "enable_immich": "Immich aktivieren", + "imageid_required": "Bild-ID ist erforderlich", + "immich": "Immich", + "immich_desc": "Integrieren Sie Ihr Immich-Konto mit AdventureLog, damit Sie Ihre Fotobibliothek durchsuchen und Fotos für Ihre Abenteuer importieren können.", + "immich_disabled": "Immich-Integration deaktiviert!", + "immich_enabled": "Immich-Integration aktiviert!", + "immich_error": "Fehler beim aktualisieren der Immich-Integration", + "immich_updated": "Immich-Einstellungen erfolgreich aktualisiert!", + "integration_enabled": "Integration aktiviert", + "integration_fetch_error": "Fehler beim Abrufen der Daten aus der Immich-Integration", + "integration_missing": "Im Backend fehlt die Immich-Integration", + "load_more": "Mehr laden", + "no_items_found": "Keine Artikel gefunden", + "query_required": "Bitte geben Sie eine Suchanfrage ein", + "server_down": "Der Immich-Server ist derzeit nicht erreichbar", + "server_url": "Immich-Server-URL", + "update_integration": "Integration updaten", + "immich_integration": "Immich-Integration", + "documentation": "Dokumentation zur Immich-Integration", + "localhost_note": "Hinweis: localhost wird höchstwahrscheinlich nicht funktionieren, es sei denn, Sie haben Docker-Netzwerke entsprechend eingerichtet. \nEs wird empfohlen, die IP-Adresse des Servers oder den Domänennamen zu verwenden." + }, + "recomendations": { + "address": "Adresse", + "contact": "Kontakt", + "phone": "Telefon", + "recommendation": "Empfehlung", + "website": "Webseite" + }, + "lodging": { + "apartment": "Wohnung", + "bnb": "Bed & Breakfast", + "cabin": "Hütte", + "campground": "Campingplatz", + "check_in": "Check-in", + "check_out": "Check-out", + "date_and_time": "Datum und Uhrzeit", + "edit": "Bearbeiten", + "edit_lodging": "Unterkunft bearbeiten", + "error_editing_lodging": "Fehler beim Bearbeiten der Unterkunft", + "hostel": "Hostel", + "hotel": "Hotel", + "house": "Haus", + "lodging_added": "Unterkunft erfolgreich hinzugefügt!", + "lodging_delete_error": "Fehler beim Löschen der Unterkunft", + "lodging_deleted": "Unterkunft gelöscht!", + "lodging_edit_success": "Unterbringung bearbeitet!", + "lodging_type": "Unterkunftstyp", + "motel": "Motel", + "new_lodging": "Neue Unterkunft", + "other": "Sonstige", + "provide_start_date": "Bitte geben Sie einen Startdatum an", + "reservation_number": "Reservierungsnummer", + "resort": "Ferienanlage", + "start": "Start", + "type": "Typ", + "villa": "Villa", + "current_timezone": "Aktuelle Zeitzone" } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d847826..25a79de 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -21,6 +21,7 @@ "support": "Support", "calendar": "Calendar", "theme_selection": "Theme Selection", + "admin_panel": "Admin Panel", "themes": { "light": "Light", "dark": "Dark", @@ -67,9 +68,11 @@ "checklist_delete_confirm": "Are you sure you want to delete this checklist? This action cannot be undone.", "note_delete_confirm": "Are you sure you want to delete this note? This action cannot be undone.", "transportation_delete_confirm": "Are you sure you want to delete this transportation? This action cannot be undone.", + "lodging_delete_confirm": "Are you sure you want to delete this lodging location? This action cannot be undone.", "delete_checklist": "Delete Checklist", "delete_note": "Delete Note", "delete_transportation": "Delete Transportation", + "delete_lodging": "Delete Lodging", "open_details": "Open Details", "edit_adventure": "Edit Adventure", "remove_from_collection": "Remove from Collection", @@ -110,6 +113,7 @@ "copy_link": "Copy Link", "image": "Image", "upload_image": "Upload Image", + "open_in_maps": "Open in Maps", "url": "URL", "fetch_image": "Fetch Image", "wikipedia": "Wikipedia", @@ -129,6 +133,11 @@ "search_results": "Searh results", "no_results": "No results found", "wiki_desc": "Pulls excerpt from Wikipedia article matching the name of the adventure.", + "attachments": "Attachments", + "attachment": "Attachment", + "images": "Images", + "primary": "Primary", + "view_attachment": "View Attachment", "generate_desc": "Generate Description", "public_adventure": "Public Adventure", "location_information": "Location Information", @@ -160,6 +169,7 @@ "delete_collection_success": "Collection deleted successfully!", "delete_collection_warning": "Are you sure you want to delete this collection? This will also delete all of the linked adventures. This action cannot be undone.", "cancel": "Cancel", + "of": "of", "delete_collection": "Delete Collection", "delete_adventure": "Delete Adventure", "adventure_delete_success": "Adventure deleted successfully!", @@ -185,18 +195,23 @@ "no_description_found": "No description found", "adventure_created": "Adventure created", "adventure_create_error": "Failed to create adventure", + "lodging": "Lodging", + "create_adventure": "Create Adventure", "adventure_updated": "Adventure updated", "adventure_update_error": "Failed to update adventure", "set_to_pin": "Set to Pin", "category_fetch_error": "Error fetching categories", "new_adventure": "New Adventure", "basic_information": "Basic Information", + "no_adventures_to_recommendations": "No adventures found. Add at least one adventure to get recommendations.", + "display_name": "Display Name", "adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!", "no_adventures_found": "No adventures found", "mark_region_as_visited": "Mark region {region}, {country} as visited?", "mark_visited": "Mark Visited", "error_updating_regions": "Error updating regions", "regions_updated": "regions updated", + "cities_updated": "cities updated", "visited_region_check": "Visited Region Check", "visited_region_check_desc": "By selecting this, the server will check all of your visited adventures and mark the regions they are located in as visited in world travel.", "update_visited_regions": "Update Visited Regions", @@ -229,16 +244,31 @@ "no_location_found": "No location found", "from": "From", "to": "To", + "will_be_marked": "will be marked as visited once the adventure is saved.", "start": "Start", "end": "End", + "show_map": "Show Map", "emoji_picker": "Emoji Picker", "download_calendar": "Download Calendar", "date_information": "Date Information", "flight_information": "Flight Information", "out_of_range": "Not in itinerary date range", "preview": "Preview", + "finding_recommendations": "Discovering hidden gems for your next adventure", + "location_details": "Location Details", + "city": "City", + "region": "Region", "md_instructions": "Write your markdown here...", "days": "days", + "attachment_upload_success": "Attachment uploaded successfully!", + "attachment_upload_error": "Error uploading attachment", + "upload": "Upload", + "attachment_delete_success": "Attachment deleted successfully!", + "attachment_update_success": "Attachment updated successfully!", + "attachment_name": "Attachment Name", + "gpx_tip": "Upload GPX files to attachments to view them on the map!", + "welcome_map_info": "Public adventures on this server", + "attachment_update_error": "Error updating attachment", "activities": { "general": "General 🌍", "outdoor": "Outdoor 🏞️", @@ -262,7 +292,10 @@ "spiritual_journeys": "Spiritual Journeys 🧘‍♀️", "volunteer_work": "Volunteer Work 🤝", "other": "Other" - } + }, + "lodging_information": "Lodging Information", + "price": "Price", + "reservation_number": "Reservation Number" }, "worldtravel": { "country_list": "Country List", @@ -273,7 +306,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", @@ -293,7 +339,12 @@ "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", + "no_public_adventures": "No public adventures found", + "no_public_collections": "No public collections found", + "user_adventures": "User Adventures", + "user_collections": "User Collections" }, "users": { "no_users_found": "No users found with public profiles." @@ -304,6 +355,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", @@ -367,7 +419,24 @@ "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", + "password_disable": "Disable Password Authentication", + "password_disable_desc": "Disabling password authentication will prevent you from logging in with a password. You will need to use a social or OIDC provider to log in. Should your social provider be unlinked, password authentication will be automatically re-enabled even if this setting is disabled.", + "disable_password": "Disable Password", + "password_enabled": "Password authentication enabled", + "password_disabled": "Password authentication disabled", + "password_disable_warning": "Currently, password authentication is disabled. Login via a social or OIDC provider is required.", + "password_disabled_error": "Error disabling password authentication. Make sure a social or OIDC provider is linked to your account.", + "password_enabled_error": "Error enabling password authentication." }, "collection": { "collection_created": "Collection created successfully!", @@ -424,6 +493,9 @@ "flight_number": "Flight Number", "from_location": "From Location", "to_location": "To Location", + "fetch_location_information": "Fetch Location Information", + "starting_airport_desc": "Enter starting airport code (e.g., JFK)", + "ending_airport_desc": "Enter ending airport code (e.g., LAX)", "edit": "Edit", "modes": { "car": "Car", @@ -440,6 +512,36 @@ "start": "Start", "date_and_time": "Date & Time" }, + "lodging": { + "lodging_deleted": "Lodging deleted successfully!", + "lodging_delete_error": "Error deleting lodging", + "provide_start_date": "Please provide a start date", + "lodging_type": "Lodging Type", + "type": "Type", + "lodging_added": "Lodging added successfully!", + "error_editing_lodging": "Error editing lodging", + "new_lodging": "New Lodging", + "check_in": "Check In", + "check_out": "Check Out", + "edit": "Edit", + "lodging_edit_success": "Lodging edited successfully!", + "edit_lodging": "Edit Lodging", + "start": "Start", + "date_and_time": "Date & Time", + "hotel": "Hotel", + "hostel": "Hostel", + "resort": "Resort", + "bnb": "Bed and Breakfast", + "campground": "Campground", + "cabin": "Cabin", + "apartment": "Apartment", + "house": "House", + "villa": "Villa", + "motel": "Motel", + "other": "Other", + "reservation_number": "Reservation Number", + "current_timezone": "Current Timezone" + }, "search": { "adventurelog_results": "AdventureLog Results", "public_adventures": "Public Adventures", @@ -466,22 +568,13 @@ "set_public": "In order to allow users to share with you, you need your profile set to public.", "go_to_settings": "Go to settings" }, - "languages": { - "en": "English", - "de": "German", - "es": "Spanish", - "fr": "French", - "it": "Italian", - "nl": "Dutch", - "sv": "Swedish", - "zh": "Chinese", - "pl": "Polish" - }, + "languages": {}, "profile": { "member_since": "Member since", "user_stats": "User Stats", "visited_countries": "Visited Countries", - "visited_regions": "Visited Regions" + "visited_regions": "Visited Regions", + "visited_cities": "Visited Cities" }, "categories": { "manage_categories": "Manage Categories", @@ -497,8 +590,41 @@ "countries_visited": "Countries Visited", "total_adventures": "Total Adventures", "total_visited_regions": "Total Visited Regions", + "total_visited_cities": "Total Visited Cities", "recent_adventures": "Recent Adventures", "no_recent_adventures": "No recent adventures?", "add_some": "Why not start planning your next adventure? You can add a new adventure by clicking the button below." + }, + "immich": { + "immich": "Immich", + "integration_fetch_error": "Error fetching data from the Immich integration", + "integration_missing": "The Immich integration is missing from the backend", + "query_required": "Query is required", + "server_down": "The Immich server is currently down or unreachable", + "no_items_found": "No items found", + "imageid_required": "Image ID is required", + "load_more": "Load More", + "immich_updated": "Immich settings updated successfully!", + "immich_enabled": "Immich integration enabled successfully!", + "immich_error": "Error updating Immich integration", + "immich_disabled": "Immich integration disabled successfully!", + "immich_desc": "Integrate your Immich account with AdventureLog to allow you to search your photos library and import photos for your adventures.", + "integration_enabled": "Integration Enabled", + "disable": "Disable", + "server_url": "Immich Server URL", + "api_note": "Note: this must be the URL to the Immich API server so it likely ends with /api unless you have a custom config.", + "api_key": "Immich API Key", + "enable_immich": "Enable Immich", + "update_integration": "Update Integration", + "immich_integration": "Immich Integration", + "localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.", + "documentation": "Immich Integration Documentation" + }, + "recomendations": { + "address": "Address", + "phone": "Phone", + "contact": "Contact", + "website": "Website", + "recommendation": "Recommendation" } } diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 1164d25..3814ec2 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -30,7 +30,8 @@ "tag": "Etiqueta", "language_selection": "Idioma", "support": "Apoyo", - "calendar": "Calendario" + "calendar": "Calendario", + "admin_panel": "Panel de administración" }, "about": { "about": "Acerca de", @@ -262,7 +263,39 @@ "start": "Comenzar", "starting_airport": "Aeropuerto de inicio", "to": "A", - "transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer." + "transportation_delete_confirm": "¿Está seguro de que desea eliminar este transporte? \nEsta acción no se puede deshacer.", + "show_map": "Mostrar mapa", + "will_be_marked": "se marcará como visitado una vez guardada la aventura.", + "cities_updated": "ciudades actualizadas", + "create_adventure": "Crear aventura", + "no_adventures_to_recommendations": "No se encontraron aventuras. \nAñade al menos una aventura para obtener recomendaciones.", + "finding_recommendations": "Descubriendo gemas ocultas para tu próxima aventura", + "attachment": "Adjunto", + "attachment_delete_success": "¡El archivo adjunto se eliminó exitosamente!", + "attachment_update_error": "Error al actualizar el archivo adjunto", + "attachment_update_success": "¡Adjunto actualizado exitosamente!", + "attachment_upload_error": "Error al cargar el archivo adjunto", + "attachment_upload_success": "¡El archivo adjunto se cargó exitosamente!", + "attachments": "Adjuntos", + "gpx_tip": "¡Sube archivos GPX a archivos adjuntos para verlos en el mapa!", + "images": "Imágenes", + "primary": "Primario", + "upload": "Subir", + "view_attachment": "Ver archivo adjunto", + "attachment_name": "Nombre del archivo adjunto", + "of": "de", + "city": "Ciudad", + "delete_lodging": "Eliminar alojamiento", + "display_name": "Nombre para mostrar", + "location_details": "Detalles de la ubicación", + "lodging": "Alojamiento", + "lodging_delete_confirm": "¿Estás seguro de que quieres eliminar este lugar de alojamiento? \nEsta acción no se puede deshacer.", + "lodging_information": "Información de alojamiento", + "price": "Precio", + "region": "Región", + "reservation_number": "Número de reserva", + "welcome_map_info": "Aventuras públicas en este servidor", + "open_in_maps": "Abrir en mapas" }, "worldtravel": { "all": "Todo", @@ -273,7 +306,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?", @@ -293,7 +339,12 @@ "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", + "no_public_adventures": "No se encontraron aventuras públicas", + "no_public_collections": "No se encontraron colecciones públicas", + "user_adventures": "Aventuras de usuario", + "user_collections": "Colecciones de usuarios" }, "users": { "no_users_found": "No se encontraron usuarios con perfiles públicos." @@ -367,7 +418,25 @@ "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.", + "disable_password": "Desactivar contraseña", + "password_disable": "Desactivar la autenticación de contraseña", + "password_disable_desc": "Desactivar la autenticación de contraseña le impedirá iniciar sesión con una contraseña. \nDeberá utilizar un proveedor social o OIDC para iniciar sesión. Si su proveedor social no está vinculado, la autenticación de contraseña se volverá a habilitar automáticamente incluso si esta configuración está deshabilitada.", + "password_disable_warning": "Actualmente, la autenticación de contraseña está deshabilitada. \nIniciar sesión a través de un proveedor social o OIDC se requiere.", + "password_disabled": "Autenticación de contraseña deshabilitada", + "password_disabled_error": "Error a deshabilitar la autenticación de contraseña. \nAsegúrese de que un proveedor social o OIDC esté vinculado a su cuenta.", + "password_enabled": "Autenticación de contraseña habilitada", + "password_enabled_error": "Error al habilitar la autenticación de contraseña." }, "checklist": { "add_item": "Agregar artículo", @@ -438,7 +507,10 @@ "flight_number": "Número de vuelo", "from_location": "Desde la ubicación", "transportation_added": "¡Transporte agregado exitosamente!", - "transportation_delete_error": "Error al eliminar el transporte" + "transportation_delete_error": "Error al eliminar el transporte", + "ending_airport_desc": "Ingrese el código de aeropuerto final (por ejemplo, LAX)", + "fetch_location_information": "Información de ubicación para obtener", + "starting_airport_desc": "Ingrese el código de aeropuerto inicial (por ejemplo, JFK)" }, "search": { "adventurelog_results": "Resultados del registro de aventuras", @@ -466,22 +538,13 @@ "no_shared_found": "No se encontraron colecciones que se compartan contigo.", "set_public": "Para permitir que los usuarios compartan contenido con usted, necesita que su perfil esté configurado como público." }, - "languages": { - "de": "Alemán", - "en": "Inglés", - "es": "Español", - "fr": "Francés", - "it": "italiano", - "nl": "Holandés", - "sv": "sueco", - "zh": "Chino", - "pl": "Polaco" - }, + "languages": {}, "profile": { "member_since": "Miembro desde", "user_stats": "Estadísticas de usuario", "visited_countries": "Países visitados", - "visited_regions": "Regiones visitadas" + "visited_regions": "Regiones visitadas", + "visited_cities": "Ciudades Visitadas" }, "categories": { "category_name": "Nombre de categoría", @@ -499,6 +562,69 @@ "recent_adventures": "Aventuras recientes", "total_adventures": "Aventuras totales", "total_visited_regions": "Total de regiones visitadas", - "welcome_back": "Bienvenido de nuevo" + "welcome_back": "Bienvenido de nuevo", + "total_visited_cities": "Total de ciudades visitadas" + }, + "immich": { + "api_key": "Clave API de Immich", + "api_note": "Nota: esta debe ser la URL del servidor API de Immich, por lo que probablemente termine con /api a menos que tenga una configuración personalizada.", + "disable": "Desactivar", + "enable_immich": "Habilitar Immich", + "imageid_required": "Se requiere identificación con imagen", + "immich": "immicha", + "immich_desc": "Integre su cuenta de Immich con AdventureLog para permitirle buscar en su biblioteca de fotos e importar fotos para sus aventuras.", + "immich_disabled": "¡La integración de Immich se deshabilitó exitosamente!", + "immich_enabled": "¡La integración de Immich se habilitó exitosamente!", + "immich_error": "Error al actualizar la integración de Immich", + "immich_updated": "¡La configuración de Immich se actualizó exitosamente!", + "integration_enabled": "Integración habilitada", + "integration_fetch_error": "Error al obtener datos de la integración de Immich", + "integration_missing": "Falta la integración de Immich en el backend", + "load_more": "Cargar más", + "no_items_found": "No se encontraron artículos", + "query_required": "Se requiere consulta", + "server_down": "El servidor Immich está actualmente inactivo o inaccesible", + "server_url": "URL del servidor Immich", + "update_integration": "Integración de actualización", + "immich_integration": "Integración Immich", + "documentation": "Documentación de integración de Immich", + "localhost_note": "Nota: lo más probable es que localhost no funcione a menos que haya configurado las redes acoplables en consecuencia. \nSe recomienda utilizar la dirección IP del servidor o el nombre de dominio." + }, + "recomendations": { + "address": "DIRECCIÓN", + "contact": "Contacto", + "phone": "Teléfono", + "recommendation": "Recomendación", + "website": "Sitio web" + }, + "lodging": { + "apartment": "Departamento", + "bnb": "Cama y desayuno", + "cabin": "Cabina", + "campground": "Terreno de camping", + "check_in": "Registrarse", + "check_out": "Verificar", + "date_and_time": "Fecha", + "edit": "Editar", + "error_editing_lodging": "Error de edición de alojamiento", + "hostel": "Albergue", + "hotel": "Hotel", + "house": "Casa", + "lodging_added": "¡Alojamiento agregado con éxito!", + "lodging_delete_error": "Error de eliminación de alojamiento", + "lodging_deleted": "¡Alojamiento eliminado con éxito!", + "lodging_edit_success": "¡Alojamiento editado con éxito!", + "lodging_type": "Tipo de alojamiento", + "motel": "Motel", + "new_lodging": "Nuevo alojamiento", + "other": "Otro", + "provide_start_date": "Proporcione una fecha de inicio", + "reservation_number": "Número de reserva", + "resort": "Complejo", + "start": "Comenzar", + "type": "Tipo", + "villa": "Villa", + "edit_lodging": "Editar alojamiento", + "current_timezone": "Zona horaria" } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c66bf03..3e6ec37 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -215,7 +215,39 @@ "start": "Commencer", "starting_airport": "Aéroport de départ", "to": "À", - "transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée." + "transportation_delete_confirm": "Etes-vous sûr de vouloir supprimer ce transport ? \nCette action ne peut pas être annulée.", + "show_map": "Afficher la carte", + "will_be_marked": "sera marqué comme visité une fois l’aventure sauvegardée.", + "cities_updated": "villes mises à jour", + "create_adventure": "Créer une aventure", + "no_adventures_to_recommendations": "Aucune aventure trouvée. \nAjoutez au moins une aventure pour obtenir des recommandations.", + "finding_recommendations": "Découvrir des trésors cachés pour votre prochaine aventure", + "attachment": "Pièce jointe", + "attachment_delete_success": "Pièce jointe supprimée avec succès !", + "attachment_name": "Nom de la pièce jointe", + "attachment_update_error": "Erreur lors de la mise à jour de la pièce jointe", + "attachment_update_success": "Pièce jointe mise à jour avec succès !", + "attachment_upload_error": "Erreur lors du téléchargement de la pièce jointe", + "attachment_upload_success": "Pièce jointe téléchargée avec succès !", + "attachments": "Pièces jointes", + "gpx_tip": "Téléchargez des fichiers GPX en pièces jointes pour les afficher sur la carte !", + "images": "Images", + "primary": "Primaire", + "upload": "Télécharger", + "view_attachment": "Voir la pièce jointe", + "of": "de", + "city": "Ville", + "delete_lodging": "Supprimer l'hébergement", + "display_name": "Nom d'affichage", + "location_details": "Détails de l'emplacement", + "lodging": "Hébergement", + "lodging_delete_confirm": "Êtes-vous sûr de vouloir supprimer cet emplacement d'hébergement? \nCette action ne peut pas être annulée.", + "lodging_information": "Informations sur l'hébergement", + "price": "Prix", + "region": "Région", + "reservation_number": "Numéro de réservation", + "welcome_map_info": "Aventures publiques sur ce serveur", + "open_in_maps": "Ouvert dans les cartes" }, "home": { "desc_1": "Découvrez, planifiez et explorez en toute simplicité", @@ -262,7 +294,8 @@ "tag": "Étiqueter", "language_selection": "Langue", "support": "Soutien", - "calendar": "Calendrier" + "calendar": "Calendrier", + "admin_panel": "Panneau d'administration" }, "auth": { "confirm_password": "Confirmez le mot de passe", @@ -282,7 +315,12 @@ "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", + "no_public_adventures": "Aucune aventure publique trouvée", + "no_public_collections": "Aucune collection publique trouvée", + "user_adventures": "Aventures utilisateur", + "user_collections": "Collections d'utilisateurs" }, "users": { "no_users_found": "Aucun utilisateur trouvé avec des profils publics." @@ -296,7 +334,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", @@ -367,7 +418,25 @@ "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", + "disable_password": "Désactiver le mot de passe", + "password_disable": "Désactiver l'authentification du mot de passe", + "password_disable_desc": "La désactivation de l'authentification du mot de passe vous empêchera de vous connecter avec un mot de passe. \nVous devrez utiliser un fournisseur social ou OIDC pour vous connecter. Si votre fournisseur social est non lié, l'authentification du mot de passe sera automatiquement réactivé même si ce paramètre est désactivé.", + "password_disable_warning": "Actuellement, l'authentification du mot de passe est désactivée. \nLa connexion via un fournisseur social ou OIDC est requise.", + "password_disabled": "Authentification du mot de passe désactivé", + "password_disabled_error": "Erreur de désactivation de l'authentification du mot de passe. \nAssurez-vous qu'un fournisseur social ou OIDC est lié à votre compte.", + "password_enabled": "Authentification du mot de passe activé", + "password_enabled_error": "Erreur permettant l'authentification du mot de passe." }, "checklist": { "add_item": "Ajouter un article", @@ -438,7 +507,10 @@ "transportation_added": "Transport ajouté avec succès !", "transportation_delete_error": "Erreur lors de la suppression du transport", "transportation_deleted": "Transport supprimé avec succès !", - "transportation_edit_success": "Transport modifié avec succès !" + "transportation_edit_success": "Transport modifié avec succès !", + "ending_airport_desc": "Entrez la fin du code aéroportuaire (par exemple, laxiste)", + "fetch_location_information": "Récupérer les informations de localisation", + "starting_airport_desc": "Entrez le code aéroport de démarrage (par exemple, JFK)" }, "search": { "adventurelog_results": "Résultats du journal d'aventure", @@ -454,17 +526,7 @@ "show_visited_regions": "Afficher les régions visitées", "view_details": "Afficher les détails" }, - "languages": { - "de": "Allemand", - "en": "Anglais", - "es": "Espagnol", - "fr": "Français", - "it": "italien", - "nl": "Néerlandais", - "sv": "suédois", - "zh": "Chinois", - "pl": "Polonais" - }, + "languages": {}, "share": { "no_users_shared": "Aucun utilisateur partagé avec", "not_shared_with": "Non partagé avec", @@ -481,7 +543,8 @@ "member_since": "Membre depuis", "user_stats": "Statistiques des utilisateurs", "visited_countries": "Pays visités", - "visited_regions": "Régions visitées" + "visited_regions": "Régions visitées", + "visited_cities": "Villes visitées" }, "categories": { "category_name": "Nom de la catégorie", @@ -499,6 +562,69 @@ "recent_adventures": "Aventures récentes", "total_adventures": "Aventures totales", "total_visited_regions": "Total des régions visitées", - "welcome_back": "Content de te revoir" + "welcome_back": "Content de te revoir", + "total_visited_cities": "Total des villes visitées" + }, + "immich": { + "api_key": "Clé API Immich", + "api_note": "Remarque : il doit s'agir de l'URL du serveur API Immich, elle se termine donc probablement par /api, sauf si vous disposez d'une configuration personnalisée.", + "disable": "Désactiver", + "enable_immich": "Activer Immich", + "imageid_required": "L'identifiant de l'image est requis", + "immich": "Immich", + "immich_desc": "Intégrez votre compte Immich à AdventureLog pour vous permettre de rechercher dans votre bibliothèque de photos et d'importer des photos pour vos aventures.", + "immich_disabled": "Intégration Immich désactivée avec succès !", + "immich_enabled": "Intégration Immich activée avec succès !", + "immich_error": "Erreur lors de la mise à jour de l'intégration Immich", + "immich_integration": "Intégration Immich", + "immich_updated": "Paramètres Immich mis à jour avec succès !", + "integration_enabled": "Intégration activée", + "integration_fetch_error": "Erreur lors de la récupération des données de l'intégration Immich", + "integration_missing": "L'intégration Immich est absente du backend", + "load_more": "Charger plus", + "no_items_found": "Aucun article trouvé", + "query_required": "La requête est obligatoire", + "server_down": "Le serveur Immich est actuellement en panne ou inaccessible", + "server_url": "URL du serveur Immich", + "update_integration": "Intégration des mises à jour", + "documentation": "Documentation d'intégration Immich", + "localhost_note": "Remarque : localhost ne fonctionnera probablement pas à moins que vous n'ayez configuré les réseaux Docker en conséquence. \nIl est recommandé d'utiliser l'adresse IP du serveur ou le nom de domaine." + }, + "recomendations": { + "address": "Adresse", + "contact": "Contact", + "phone": "Téléphone", + "recommendation": "Recommandation", + "website": "Site web" + }, + "lodging": { + "apartment": "Appartement", + "bnb": "Bed and petit-déjeuner", + "cabin": "Cabine", + "campground": "Camping", + "check_in": "Enregistrement", + "check_out": "Vérifier", + "date_and_time": "Date", + "edit": "Modifier", + "edit_lodging": "Modifier l'hébergement", + "error_editing_lodging": "Édition d'erreurs Hébergement", + "hostel": "Auberge", + "hotel": "Hôtel", + "house": "Maison", + "lodging_added": "L'hébergement a ajouté avec succès!", + "lodging_delete_error": "Erreur de suppression de l'hébergement", + "lodging_deleted": "L'hébergement est supprimé avec succès!", + "lodging_edit_success": "L'hébergement édité avec succès!", + "lodging_type": "Type d'hébergement", + "motel": "Motel", + "new_lodging": "Nouveau logement", + "other": "Autre", + "provide_start_date": "Veuillez fournir une date de début", + "reservation_number": "Numéro de réservation", + "resort": "Station balnéaire", + "start": "Commencer", + "type": "Taper", + "villa": "Villa", + "current_timezone": "Fuseau horaire actuel" } } diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 21bee77..a68ce99 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -215,7 +215,39 @@ "start": "Inizio", "starting_airport": "Inizio aeroporto", "to": "A", - "transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata." + "transportation_delete_confirm": "Sei sicuro di voler eliminare questo trasporto? \nQuesta azione non può essere annullata.", + "show_map": "Mostra mappa", + "will_be_marked": "verrà contrassegnato come visitato una volta salvata l'avventura.", + "cities_updated": "città aggiornate", + "create_adventure": "Crea Avventura", + "no_adventures_to_recommendations": "Nessuna avventura trovata. \nAggiungi almeno un'avventura per ricevere consigli.", + "finding_recommendations": "Alla scoperta di gemme nascoste per la tua prossima avventura", + "attachment": "Allegato", + "attachment_delete_success": "Allegato eliminato con successo!", + "attachment_name": "Nome dell'allegato", + "attachment_update_error": "Errore durante l'aggiornamento dell'allegato", + "attachment_update_success": "Allegato aggiornato con successo!", + "attachment_upload_error": "Errore durante il caricamento dell'allegato", + "attachment_upload_success": "Allegato caricato con successo!", + "attachments": "Allegati", + "gpx_tip": "Carica i file GPX negli allegati per visualizzarli sulla mappa!", + "images": "Immagini", + "primary": "Primario", + "upload": "Caricamento", + "view_attachment": "Visualizza allegato", + "of": "Di", + "city": "Città", + "delete_lodging": "Elimina alloggio", + "display_name": "Nome da visualizzare", + "location_details": "Dettagli della posizione", + "lodging": "Alloggio", + "lodging_delete_confirm": "Sei sicuro di voler eliminare questa posizione di alloggio? \nQuesta azione non può essere annullata.", + "lodging_information": "Informazioni di alloggio", + "price": "Prezzo", + "region": "Regione", + "welcome_map_info": "Avventure pubbliche su questo server", + "reservation_number": "Numero di prenotazione", + "open_in_maps": "Aperto in mappe" }, "home": { "desc_1": "Scopri, pianifica ed esplora con facilità", @@ -262,7 +294,8 @@ "tag": "Etichetta", "language_selection": "Lingua", "support": "Supporto", - "calendar": "Calendario" + "calendar": "Calendario", + "admin_panel": "Pannello di amministrazione" }, "auth": { "confirm_password": "Conferma password", @@ -282,7 +315,12 @@ "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", + "no_public_adventures": "Nessuna avventura pubblica trovata", + "no_public_collections": "Nessuna collezione pubblica trovata", + "user_adventures": "Avventure utente", + "user_collections": "Collezioni utente" }, "users": { "no_users_found": "Nessun utente trovato con profili pubblici." @@ -296,7 +334,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", @@ -367,7 +418,25 @@ "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", + "disable_password": "Disabilita la password", + "password_disable": "Disabilita l'autenticazione della password", + "password_disable_desc": "La disabilitazione dell'autenticazione della password ti impedirà di accedere con una password. \nDovrai utilizzare un provider di social o OIDC per accedere. Se il tuo fornitore social non fosse collegato, l'autenticazione password verrà riabilitata automaticamente anche se questa impostazione è disabilitata.", + "password_disable_warning": "Attualmente, l'autenticazione della password è disabilitata. \nÈ richiesto l'accesso tramite un fornitore sociale o OIDC.", + "password_disabled": "Autenticazione password disabilitata", + "password_disabled_error": "Errore di disabilitazione dell'autenticazione della password. \nAssicurati che un fornitore sociale o OIDC sia collegato al tuo account.", + "password_enabled": "Autenticazione password abilitata", + "password_enabled_error": "Errore che abilita l'autenticazione della password." }, "checklist": { "add_item": "Aggiungi articolo", @@ -438,7 +507,10 @@ "transportation_delete_error": "Errore durante l'eliminazione del trasporto", "transportation_deleted": "Trasporto eliminato con successo!", "transportation_edit_success": "Trasporti modificati con successo!", - "type": "Tipo" + "type": "Tipo", + "ending_airport_desc": "Immettere il codice aeroportuale finale (ad es. LAX)", + "fetch_location_information": "Informazioni sulla posizione di recupero", + "starting_airport_desc": "Immettere il codice dell'aeroporto di partenza (ad es. JFK)" }, "search": { "adventurelog_results": "Risultati di AdventureLog", @@ -454,17 +526,7 @@ "show_visited_regions": "Mostra regioni visitate", "view_details": "Visualizza dettagli" }, - "languages": { - "de": "tedesco", - "en": "Inglese", - "es": "spagnolo", - "fr": "francese", - "it": "Italiano", - "nl": "Olandese", - "sv": "svedese", - "zh": "cinese", - "pl": "Polacco" - }, + "languages": {}, "share": { "no_users_shared": "Nessun utente condiviso con", "not_shared_with": "Non condiviso con", @@ -481,7 +543,8 @@ "member_since": "Membro da allora", "user_stats": "Statistiche utente", "visited_countries": "Paesi visitati", - "visited_regions": "Regioni visitate" + "visited_regions": "Regioni visitate", + "visited_cities": "Città visitate" }, "categories": { "category_name": "Nome della categoria", @@ -499,6 +562,69 @@ "recent_adventures": "Avventure recenti", "total_adventures": "Avventure totali", "total_visited_regions": "Totale regioni visitate", - "welcome_back": "Bentornato" + "welcome_back": "Bentornato", + "total_visited_cities": "Totale città visitate" + }, + "immich": { + "api_key": "Chiave API Immich", + "api_note": "Nota: questo deve essere l'URL del server API Immich, quindi probabilmente termina con /api a meno che tu non abbia una configurazione personalizzata.", + "disable": "Disabilita", + "enable_immich": "Abilita Immich", + "imageid_required": "L'ID immagine è obbligatorio", + "immich": "Immich", + "immich_desc": "Integra il tuo account Immich con AdventureLog per consentirti di cercare nella tua libreria di foto e importare foto per le tue avventure.", + "immich_disabled": "Integrazione Immich disabilitata con successo!", + "immich_enabled": "Integrazione Immich abilitata con successo!", + "immich_error": "Errore durante l'aggiornamento dell'integrazione Immich", + "immich_integration": "Integrazione di Immich", + "immich_updated": "Impostazioni Immich aggiornate con successo!", + "integration_enabled": "Integrazione abilitata", + "integration_fetch_error": "Errore durante il recupero dei dati dall'integrazione Immich", + "integration_missing": "L'integrazione Immich manca dal backend", + "load_more": "Carica altro", + "no_items_found": "Nessun articolo trovato", + "query_required": "La domanda è obbligatoria", + "server_down": "Il server Immich è attualmente inattivo o irraggiungibile", + "server_url": "URL del server Immich", + "update_integration": "Aggiorna integrazione", + "documentation": "Documentazione sull'integrazione di Immich", + "localhost_note": "Nota: molto probabilmente localhost non funzionerà a meno che tu non abbia configurato le reti docker di conseguenza. \nSi consiglia di utilizzare l'indirizzo IP del server o il nome del dominio." + }, + "recomendations": { + "address": "Indirizzo", + "contact": "Contatto", + "phone": "Telefono", + "recommendation": "Raccomandazione", + "website": "Sito web" + }, + "lodging": { + "apartment": "Appartamento", + "bnb": "Bed and Breakfast", + "cabin": "Cabina", + "campground": "Campeggio", + "check_in": "Check -in", + "check_out": "Guardare", + "date_and_time": "Data", + "edit": "Modificare", + "edit_lodging": "Modifica alloggio", + "error_editing_lodging": "Alloggio di modifica degli errori", + "hostel": "Ostello", + "hotel": "Hotel", + "house": "Casa", + "lodging_added": "Alloggio aggiunto con successo!", + "new_lodging": "Nuovo alloggio", + "other": "Altro", + "provide_start_date": "Si prega di fornire una data di inizio", + "reservation_number": "Numero di prenotazione", + "resort": "Ricorrere", + "start": "Inizio", + "type": "Tipo", + "villa": "Villa", + "lodging_delete_error": "Errore di eliminazione dell'alloggio", + "lodging_deleted": "Alloggio eliminato con successo!", + "lodging_edit_success": "Alloggio modificato con successo!", + "lodging_type": "Tipo di alloggio", + "motel": "Motel", + "current_timezone": "Fuso orario attuale" } } diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json new file mode 100644 index 0000000..bfe761b --- /dev/null +++ b/frontend/src/locales/ko.json @@ -0,0 +1,629 @@ +{ + "about": { + "about": "소개", + "close": "닫기", + "license": "GPL-3.0 라이선스 적용.", + "message": "미국에서 ❤️로 제작되었습니다.", + "nominatim_1": "위치 검색 및 지오코딩 제공", + "nominatim_2": "데이터는 ODbL 라이선스가 적용됩니다.", + "oss_attributions": "오픈 소스 속성", + "other_attributions": "추가 속성은 README 파일에서 찾을 수 있습니다.", + "source_code": "소스 코드" + }, + "adventures": { + "actions": "행동", + "activities": { + "activity": "활동 🏄", + "art_museums": "예술 & 박물관 🎨", + "attraction": "놀이동산 🎢", + "culture": "문화 🎭", + "dining": "식사 🍽️", + "event": "이벤트 🎉", + "festivals": "축제 🎪", + "fitness": "피트니스 🏋️", + "general": "일반 🌍", + "hiking": "하이킹 🥾", + "historical_sites": "사적지 🏛️", + "lodging": "숙박 🛌", + "music_concerts": "음악 🎶", + "nightlife": "야간문화 🌃", + "other": "기타", + "outdoor": "야외활동 🏞️", + "shopping": "쇼핑 🛍️", + "spiritual_journeys": "영적 여행 🧘‍♀️", + "transportation": "교통수단 🚗", + "volunteer_work": "자원 봉사 활동 🤝", + "water_sports": "수상 스포츠 🚤", + "wildlife": "야생 동물 🦒" + }, + "activity": "활동", + "activity_types": "활동 유형", + "add": "추가", + "add_a_tag": "태그를 추가하세요", + "add_an_activity": "활동을 추가하세요", + "add_new": "새로 추가...", + "add_notes": "메모 추가", + "add_to_collection": "컬렉션에 추가하세요", + "adventure": "모험", + "adventure_calendar": "모험 달력", + "adventure_create_error": "모험을 만들지 못했습니다", + "adventure_created": "모험 생성됨", + "adventure_delete_confirm": "이 모험을 삭제 하시겠습니까? 이 행동은 취소 할 수 없습니다.", + "adventure_delete_success": "모험이 성공적으로 삭제되었습니다!", + "adventure_details": "모험 정보", + "adventure_not_found": "표시할 모험이 없습니다. 오른쪽 하단의 플러스 버튼을 사용하여 추가하거나 필터를 변경하세요!", + "adventure_type": "모험 유형", + "adventure_update_error": "모험을 업데이트하지 못했습니다", + "adventure_updated": "모험 업데이트됨", + "all": "모두", + "archive": "보관", + "archived": "보관됨", + "archived_collection_message": "컬렉션이 성공적으로 보관되었습니다!", + "archived_collections": "보관된 컬렉션", + "ascending": "오름차순", + "attachment": "첨부 파일", + "attachment_delete_success": "첨부 파일이 성공적으로 삭제되었습니다!", + "attachment_name": "첨부 파일 이름", + "attachment_update_error": "첨부 파일 업데이트 실패", + "attachment_update_success": "첨부 파일이 성공적으로 업데이트되었습니다!", + "attachment_upload_error": "첨부 파일 업로드 실패", + "attachment_upload_success": "첨부 파일이 성공적으로 업로드되었습니다!", + "attachments": "첨부 파일", + "basic_information": "기본 정보", + "cancel": "취소", + "category": "카테고리", + "category_fetch_error": "카테고리 불러오기 실패", + "category_filter": "카테고리 필터", + "checklist": "체크리스트", + "checklist_delete_confirm": "이 체크리스트를 삭제 하시겠습니까? 이 행동은 취소할 수 없습니다.", + "clear_location": "장소 초기화", + "clear_map": "지도 초기화", + "close_filters": "필터 제거", + "collection": "컬렉션", + "collection_adventures": "컬렉션 모험을 추가하세요", + "collection_archived": "이 컬렉션은 보관되었습니다.", + "collection_completed": "이 컬렉션을 완성했습니다!", + "collection_link_error": "컬렉션에 모험 연결 중 오류", + "collection_link_success": "컬렉션에 모험이 성공적으로 연결되었습니다!", + "collection_remove_error": "컬렉션에서 모험을 제거 중 오류", + "collection_remove_success": "컬렉션에서 모험이 제거되었습니다!", + "collection_stats": "컬렉션 통계", + "copied_to_clipboard": "클립 보드에 복사됨!", + "copy_failed": "복사 실패", + "copy_link": "링크 복사", + "count_txt": "검색과 일치하는 결과", + "create_adventure": "모험 생성", + "create_new": "새로 만들기...", + "date": "일자", + "date_constrain": "컬렉션 일자로 제한", + "date_information": "일자 정보", + "dates": "일자", + "day": "일", + "days": "일", + "delete": "삭제", + "delete_adventure": "모험 삭제", + "delete_checklist": "체크리스트 삭제", + "delete_collection": "컬렉션 삭제", + "delete_collection_success": "컬렉션이 성공적으로 삭제되었습니다!", + "delete_collection_warning": "이 컬렉션을 삭제 하시겠습니까? 링크된 모든 모험을 삭제합니다. 이 행동은 취소할 수 없습니다.", + "delete_note": "노트 삭제", + "delete_transportation": "교통수단 삭제", + "descending": "내림차순", + "description": "설명", + "download_calendar": "캘린더 다운로드", + "duration": "기간", + "edit_adventure": "모험 편집", + "edit_collection": "컬렉션 편집", + "emoji_picker": "이모티콘 선택", + "end": "끝", + "end_date": "종료일", + "ending_airport": "도착 공항", + "error_updating_regions": "지역 업데이트 오류", + "fetch_image": "이미지 가져오기", + "filter": "필터", + "finding_recommendations": "다음 모험을 위해 숨겨진 보물을 찾아보세요", + "flight_information": "항공편 정보", + "from": "출발", + "generate_desc": "설명을 적으세요", + "gpx_tip": "GPX 파일을 첨부 파일에 업로드하면 지도에서 볼 수 있습니다!", + "hide": "숨기기", + "homepage": "홈페이지", + "image": "이미지", + "image_fetch_failed": "이미지를 가져오지 못했습니다", + "image_removed_error": "이미지 삭제 오류", + "image_removed_success": "이미지가 성공적으로 삭제되었습니다!", + "image_upload_error": "이미지 업로드 오류", + "image_upload_success": "이미지가 성공적으로 업로드되었습니다!", + "images": "이미지", + "itineary_by_date": "날짜 순 일정표", + "keep_exploring": "계속 탐험하세요!", + "latitude": "위도", + "link": "링크", + "link_new": "새로운 링크...", + "linked_adventures": "링크된 모험", + "links": "링크", + "location": "위치", + "location_information": "위치 정보", + "longitude": "경도", + "mark_region_as_visited": "{country} {Region} 지역을 방문함으로 표시할까요?", + "mark_visited": "방문함으로 표시", + "md_instructions": "여기에 마크다운으로 작성할 수 있습니다...", + "my_adventures": "내 모험", + "my_collections": "내 컬렉션", + "my_images": "내 이미지", + "my_visits": "내 방문", + "name": "이름", + "new_adventure": "새로운 모험", + "no_adventures_found": "모험이 없습니다", + "no_adventures_to_recommendations": "모험이 없습니다. 장소를 추천받으려면 최소 하나 이상의 모험을 등록해야 합니다.", + "no_collections_found": "이 모험을 추가할 수 있는 컬렉션이 없습니다.", + "no_description_found": "설명이 없습니다", + "no_end_date": "종료 날짜를 입력하세요", + "no_image_found": "이미지가 없습니다", + "no_image_url": "해당 URL에 이미지가 없습니다.", + "no_images": "이미지가 없습니다", + "no_linkable_adventures": "이 컬렉션에 연결할 수 있는 모험이 없습니다.", + "no_location": "위치를 입력하세요", + "no_location_found": "위치가 없습니다", + "no_results": "결과가 없습니다", + "no_start_date": "시작 날짜를 입력하십시오", + "not_found": "모험이 없습니다", + "not_found_desc": "당신이 찾고 있던 모험을 찾을 수 없었습니다. 다른 모험을 찾아보거나 나중에 다시 해 보세요.", + "not_visited": "방문하지 않음", + "note": "노트", + "note_delete_confirm": "이 노트를 삭제 하시겠습니까? 이 행동은 취소 할 수 없습니다.", + "notes": "노트", + "nothing_planned": "이 날에 계획된 것이 없습니다. 즐거운 여행 되세요!", + "open_details": "상세 내용 보기", + "open_filters": "필터 열기", + "order_by": "정렬 기준", + "order_direction": "정렬 순서", + "out_of_range": "여정 날짜 범위 내에 없습니다", + "planned": "계획", + "preview": "미리보기", + "primary": "기본", + "private": "비공개", + "public": "공개", + "public_adventure": "공개 모험", + "rating": "평가", + "regions_updated": "지역이 업데이트되었습니다", + "remove": "제거", + "remove_from_collection": "컬렉션에서 제거", + "save_next": "저장 & 다음", + "search_for_location": "위치 검색", + "search_results": "검색 결과", + "see_adventures": "모험 보기", + "select_adventure_category": "모험 카테고리 선택", + "set_to_pin": "고정하기", + "share": "공유", + "share_adventure": "이 모험을 공유하세요!", + "show": "보기", + "show_map": "지도 보기", + "show_region_labels": "지역 레이블 표시", + "sort": "정렬", + "sources": "출처", + "start": "시작", + "start_before_end_error": "시작일은 종료일 이전이어야 합니다", + "start_date": "시작일", + "starting_airport": "출발 공항", + "tags": "태그", + "to": "도착", + "transportation": "교통수단", + "transportation_delete_confirm": "이 교통수단을 삭제 하시겠습니까? 이 행동은 취소 할 수 없습니다.", + "transportations": "교통수단", + "unarchive": "보관 취소", + "unarchived_collection_message": "컬렉션이 성공적으로 보관 취소되었습니다!", + "update_visited_regions": "방문 지역 업데이트", + "update_visited_regions_disclaimer": "방문한 모험의 수에 따라 시간이 걸릴 수 있습니다.", + "updated": "업데이트됨", + "upload": "업로드", + "upload_image": "이미지 업로드", + "upload_images_here": "여기에 이미지를 업로드하세요", + "url": "URL", + "view_attachment": "첨부 파일 보기", + "visit": "방문", + "visit_link": "링크 방문", + "visited": "방문함", + "visited_region_check": "방문 지역 확인", + "visited_region_check_desc": "이것을 선택하면 서버는 방문한 모든 모험을 확인하고 그 모험의 지역을 표시하여 세계 여행에서 방문 여부를 표시합니다.", + "visits": "방문", + "warning": "경고", + "wiki_desc": "모험의 이름과 일치하는 글을 위키백과에서 가져옵니다.", + "wiki_image_error": "위키백과 이미지 가져오기 오류", + "wikipedia": "위키백과", + "will_be_marked": "모험이 저장되면 방문했다고 표시합니다.", + "checklists": "체크리스트", + "cities_updated": "도시 업데이트됨", + "clear": "초기화", + "city": "도시", + "delete_lodging": "숙박을 삭제하십시오", + "display_name": "표시 이름", + "location_details": "위치 세부 사항", + "lodging": "하숙", + "lodging_delete_confirm": "이 숙소 위치를 삭제 하시겠습니까? \n이 조치는 취소 할 수 없습니다.", + "lodging_information": "숙박 정보", + "of": "~의", + "price": "가격", + "region": "지역", + "reservation_number": "예약 번호", + "welcome_map_info": "이 서버의 공개 모험", + "open_in_maps": "지도에서 열립니다" + }, + "auth": { + "both_passwords_required": "두 암호 모두 필요합니다", + "confirm_password": "비밀번호 확인", + "email": "이메일", + "email_required": "이메일이 필요합니다", + "first_name": "이름", + "forgot_password": "비밀번호를 잊으셨나요?", + "last_name": "성", + "login": "로그인", + "login_error": "제공한 자격 증명으로 로그인 할 수 없습니다.", + "new_password": "새 비밀번호 (6자 이상)", + "or_3rd_party": "또는 서드 파티 서비스로 로그인하세요", + "password": "비밀번호", + "profile_picture": "프로필 사진", + "public_profile": "공개 프로필", + "public_tooltip": "공개 프로필을 사용하면 당신에게 다른 사용자가 컬렉션을 공유할 수 있으며, 사용자 페이지에서 프로필이 노출됩니다.", + "registration_disabled": "현재 등록할 수 없습니다.", + "reset_failed": "비밀번호 재설정 실패", + "signup": "가입", + "username": "사용자 이름", + "no_public_adventures": "공개 모험이 발견되지 않았습니다", + "no_public_collections": "공개 컬렉션이 발견되지 않았습니다", + "user_adventures": "사용자 모험", + "user_collections": "사용자 수집" + }, + "categories": { + "category_name": "카테고리 이름", + "edit_category": "카테고리 편집", + "icon": "아이콘", + "manage_categories": "카테고리 관리", + "no_categories_found": "카테고리가 없습니다.", + "select_category": "카테고리 선택", + "update_after_refresh": "페이지를 새로고침해야 모험 카드가 업데이트됩니다." + }, + "checklist": { + "add_item": "항목 추가", + "checklist_delete_error": "체크리스트 삭제 오류", + "checklist_deleted": "체크리스트가 성공적으로 삭제되었습니다!", + "checklist_editor": "체크리스트 편집기", + "checklist_public": "이 체크리스트는 공개 컬렉션에 있기 때문에 공개되었습니다.", + "checklist_viewer": "체크리스트 뷰어", + "editing_checklist": "체크리스트 편집", + "failed_to_save": "체크리스트 저장 실패", + "item": "항목", + "item_already_exists": "항목이 이미 존재합니다", + "item_cannot_be_empty": "항목은 비어있을 수 없습니다", + "items": "항목", + "new_checklist": "새 체크리스트", + "new_item": "새 항목", + "save": "저장" + }, + "collection": { + "collection_created": "컬렉션이 성공적으로 생성되었습니다!", + "collection_edit_success": "컬렉션이 성공적으로 편집되었습니다!", + "create": "생성", + "edit_collection": "컬렉션 편집", + "error_creating_collection": "컬렉션 생성 오류", + "error_editing_collection": "컬렉션 편집 오류", + "new_collection": "새로운 컬렉션", + "public_collection": "공개 컬렉션" + }, + "dashboard": { + "add_some": "다음 모험을 계획해 보는게 어떨까요? 아래 버튼을 클릭하여 새로운 모험을 추가할 수 있습니다.", + "countries_visited": "방문한 국가", + "no_recent_adventures": "최근 모험이 없나요?", + "recent_adventures": "최근 모험", + "total_adventures": "모든 모험", + "total_visited_cities": "방문한 모든 도시", + "total_visited_regions": "방문한 모든 지역", + "welcome_back": "다시 오신 것을 환영합니다" + }, + "home": { + "desc_1": "쉽고 편리하게 발견하고, 계획하고, 탐험하세요", + "desc_2": "AdventureLog는 여러분의 여정을 더 편리하게 만들어 드립니다. 추억에 남을 다음 모험을 계획하고, 준비하고, 안내하는 데 필요한 모든 도구와 리소스를 제공할 수 있도록 설계되었습니다.", + "feature_1": "여행 로그", + "feature_1_desc": "나만의 여행 기록을 만들어 기록하고 친구나 가족들에게 경험을 공유하세요.", + "feature_2": "일정 계획", + "feature_2_desc": "손쉽게 맞춤형 일정을 만들고 하루 단위 일정표를 받아 보세요.", + "feature_3": "여행 지도", + "feature_3_desc": "세계 각지의 방문지를 인터랙티브 지도로 확인하고 새로운 여행지를 찾아 보세요.", + "go_to": "Adventurelog로 이동", + "hero_1": "세상에서 가장 짜릿한 모험을 발견하세요", + "hero_2": "Adventurelog로 다음 모험을 발견하고 계획해 보세요. 환상적인 여행지를 탐색하고, 나만의 일정을 만드세요. 어디서든 접속할 수 있습니다.", + "key_features": "주요 기능" + }, + "immich": { + "api_key": "Immich API 키", + "api_note": "참고 : 이것은 Immich API 서버의 URL이어야 합니다. 사용자 정의 구성을 하지 않았다면 끝이 /api 입니다.", + "disable": "사용 불가", + "documentation": "Immich 통합 문서", + "enable_immich": "Immich 활성화", + "imageid_required": "이미지 ID가 필요합니다", + "immich": "Immich", + "immich_desc": "Immich 계정을 Adventurelog와 통합하면 사진 라이브러리를 검색하고 모험 사진을 가져올 수 있습니다.", + "immich_disabled": "Immich 통합이 성공적으로 비활성화되었습니다!", + "immich_enabled": "Immich 통합이 성공적으로 활성화되었습니다!", + "immich_error": "Immich 통합 업데이트 오류", + "immich_integration": "Immich 통합", + "immich_updated": "Immich 설정이 성공적으로 업데이트되었습니다!", + "integration_enabled": "통합 활성화", + "integration_fetch_error": "Immich 통합에서 데이터 가져오기 오류", + "integration_missing": "백엔드에서 Immich 통합을 찾을 수 없습니다", + "load_more": "더 가져오기", + "localhost_note": "참고 : docker 네트워크를 적절하게 설정하지 않으면 localhost는 올바르게 작동하지 않을 수 있습니다. IP 주소나 서버의 도메인 주소를 사용하는 것이 좋습니다.", + "no_items_found": "항목 없음", + "query_required": "쿼리가 필요합니다", + "server_down": "현재 Immich 서버가 다운되었거나 도달할 수 없습니다", + "server_url": "Immich 서버 URL", + "update_integration": "통합 업데이트" + }, + "map": { + "add_adventure": "새로운 모험 추가", + "add_adventure_at_marker": "마커에 새로운 모험 추가", + "adventure_map": "모험 지도", + "clear_marker": "마커 초기화", + "map_options": "지도 옵션", + "show_visited_regions": "방문한 지역 보기", + "view_details": "상세 보기" + }, + "navbar": { + "about": "Adventurelog 소개", + "adventures": "모험", + "calendar": "달력", + "collections": "컬렉션", + "discord": "디스코드", + "documentation": "문서", + "greeting": "안녕하세요", + "language_selection": "언어", + "logout": "로그아웃", + "map": "지도", + "my_adventures": "내 모험", + "my_tags": "내 태그", + "profile": "프로필", + "search": "검색", + "settings": "설정", + "shared_with_me": "나와 공유함", + "support": "지원", + "tag": "태그", + "theme_selection": "테마 선택", + "themes": { + "aestheticDark": "Aesthetic Dark", + "aestheticLight": "Aesthetic Light", + "aqua": "Aqua", + "dark": "Dark", + "forest": "Forest", + "light": "Light", + "night": "Night", + "northernLights": "Northern Lights" + }, + "users": "사용자", + "worldtravel": "세계 여행", + "admin_panel": "관리자 패널" + }, + "notes": { + "add_a_link": "링크 추가", + "content": "콘텐츠", + "editing_note": "노트 편집", + "failed_to_save": "메모 저장 실패", + "invalid_url": "잘못된 URL", + "note_delete_error": "노트 삭제 오류", + "note_deleted": "노트가 성공적으로 삭제되었습니다!", + "note_editor": "노트 편집기", + "note_public": "이 노트는 공개 컬렉션에 있기 때문에 공개되었습니다.", + "note_viewer": "노트 뷰어", + "open": "열기", + "save": "저장" + }, + "profile": { + "member_since": "가입 시점", + "user_stats": "사용자 통계", + "visited_cities": "방문한 도시", + "visited_countries": "방문한 국가", + "visited_regions": "방문한 지역" + }, + "recomendations": { + "address": "주소", + "contact": "연락처", + "phone": "핸드폰", + "recommendation": "추천", + "website": "웹사이트" + }, + "search": { + "adventurelog_results": "Adventurelog 결과", + "online_results": "온라인 결과", + "public_adventures": "공개 모험" + }, + "settings": { + "about_this_background": "이 배경에 대해", + "account_settings": "사용자 계정 설정", + "add_email": "이메일 추가", + "add_email_blocked": "2단계 인증으로 보호된 계정에 이메일 주소를 추가할 수 없습니다.", + "administration_settings": "관리자 설정", + "authenticator_code": "인증 코드", + "change_password": "비밀번호 변경", + "change_password_error": "비밀번호를 변경할 수 없습니다. 현재 비밀번호 또는 새 비밀번호가 유효하지 않습니다.", + "confirm_new_password": "새 비밀번호 확인", + "copy": "복사", + "csrf_failed": "CSRF 토큰 가져오기 실패", + "current_email": "현재 이메일", + "current_password": "현재 비밀번호", + "disable_mfa": "MFA 비활성화", + "duplicate_email": "이 이메일 주소는 이미 사용 중입니다.", + "email_added": "이메일이 성공적으로 추가되었습니다!", + "email_added_error": "이메일 추가 오류", + "email_change": "이메일 변경", + "email_removed": "이메일이 성공적으로 제거되었습니다!", + "email_removed_error": "이메일 제거 오류", + "email_verified": "이메일을 성공적으로 인증했습니다!", + "email_verified_erorr_desc": "이메일이 인증되지 않았습니다. 다시 시도하세요.", + "email_verified_error": "이메일 인증 오류", + "email_verified_success": "이메일이 인증되었습니다. 이제 로그인 할 수 있습니다.", + "enable_mfa": "MFA 활성화", + "error_change_password": "비밀번호 변경 오류. 현재 비밀번호를 확인하고 다시 시도하세요.", + "generic_error": "요청 처리 중 오류가 발생했습니다.", + "invalid_code": "유효하지 않은 MFA 코드", + "invalid_credentials": "유효하지 않은 사용자 이름 또는 비밀번호", + "invalid_token": "유효하지 않거나 만료된 토큰입니다.", + "join_discord": "디스코드 참여", + "join_discord_desc": "#travel-share 채널에 자신의 사진을 공유하세요.", + "launch_account_connections": "계정 연결 시작", + "launch_administration_panel": "관리자 패널 열기", + "login_redir": "로그인 페이지로 리디렉션됩니다.", + "make_primary": "기본 이메일로 설정", + "mfa_disabled": "다중 인증이 성공적으로 비활성화되었습니다!", + "mfa_enabled": "다중 인증이 성공적으로 활성화되었습니다!", + "mfa_not_enabled": "MFA가 활성화되지 않았습니다", + "mfa_page_title": "다중 인증", + "mfa_required": "다중 인증이 필요합니다", + "missing_email": "이메일 주소를 입력하세요", + "new_email": "새 이메일", + "new_password": "새 비밀번호", + "no_emai_set": "이메일 세트가 없습니다", + "no_email_set": "이메일 세트가 없습니다", + "no_verified_email_warning": "2단계 인증을 활성화하려면 인증된 이메일 주소가 필요합니다.", + "not_verified": "인증되지 않음", + "password_change": "비밀번호 변경", + "password_change_lopout_warning": "비밀번호를 변경한 후 로그아웃됩니다.", + "password_does_not_match": "암호가 일치하지 않습니다", + "password_is_required": "비밀번호가 필요합니다", + "password_too_short": "비밀번호는 6 자 이상이어야 합니다", + "photo_by": "사진 제공", + "possible_reset": "입력한 이메일 주소가 계정과 일치하면, 비밀번호를 재설정하기 위한 이메일을 받게 됩니다!", + "primary": "기본", + "recovery_codes": "복구 코드", + "recovery_codes_desc": "이것은 복구 코드입니다. 안전하게 보관하세요. 재확인이 불가능합니다.", + "required": "필수 필드입니다", + "reset_password": "비밀번호 재설정", + "reset_session_error": "로그아웃 후 재접속하여 세션을 초기화하고 다시 시도하세요.", + "settings_page": "설정 페이지", + "social_auth_desc": "소셜 및 OIDC 인증 제공자를 활성화 또는 비활성화합니다. 활성화하면 Authentik 같은 자체 호스팅 인증 제공자나 Github 같은 서드 파티 인증 제공자로 로그인할 수 있습니다.", + "social_auth_desc_2": "관련 설정은 Adventurelog 서버에서 관리되며 관리자가 직접 활성화해야 합니다.", + "social_oidc_auth": "소셜 및 OIDC 인증", + "submit": "제출", + "token_required": "비밀번호를 재설정하려면 토큰 및 UID가 필요합니다.", + "update": "업데이트", + "update_error": "설정 업데이트 오류", + "update_success": "설정이 성공적으로 업데이트되었습니다!", + "username_taken": "이 사용자 이름은 이미 사용 중입니다.", + "verified": "인증됨", + "verify": "인증", + "verify_email_error": "이메일 인증 오류. 몇 분 후 다시 시도하세요.", + "verify_email_success": "인증 이메일이 성공적으로 발송되었습니다!", + "documentation_link": "문서화 링크", + "email_set_primary": "기본 이메일 세트로 설정했습니다!", + "email_set_primary_error": "기본 이메일 설정 오류", + "email_taken": "이 이메일 주소는 이미 사용 중입니다.", + "disable_password": "비밀번호를 비활성화합니다", + "password_disable": "비밀번호 인증을 비활성화합니다", + "password_disable_desc": "비밀번호 인증을 비활성화하면 비밀번호로 로그인하지 못하게됩니다. \n소셜 또는 OIDC 제공 업체를 사용하여 로그인해야합니다. 소셜 제공 업체가 끊어지지 않으면이 설정이 비활성화 된 경우에도 비밀번호 인증이 자동으로 다시 활성화됩니다.", + "password_disable_warning": "현재 비밀번호 인증이 비활성화되어 있습니다. \n소셜 또는 OIDC 제공 업체를 통해 로그인해야합니다.", + "password_disabled": "비밀번호 인증 비활성화", + "password_disabled_error": "비밀번호 인증 오류. \n소셜 또는 OIDC 제공 업체가 귀하의 계정에 연결되어 있는지 확인하십시오.", + "password_enabled": "비밀번호 인증 활성화", + "password_enabled_error": "비밀번호 인증을 활성화하는 오류." + }, + "share": { + "go_to_settings": "설정으로 이동", + "no_shared_found": "공유받은 컬렉션이 없습니다.", + "no_users_shared": "공유한 사용자가 없습니다", + "not_shared_with": "공유하지 않음", + "set_public": "다른 사람에게 공유하려면, 프로필을 공개해야 합니다.", + "share_desc": "이 컬렉션을 다른 사용자와 공유하세요.", + "shared": "공유됨", + "shared_with": "공유한 사용자", + "unshared": "공유되지 않음", + "with": "함께 하는 사용자" + }, + "transportation": { + "date_and_time": "일시", + "date_time": "시작 일시", + "edit": "편집", + "edit_transportation": "교통수단 편집", + "end_date_time": "종료 일시", + "error_editing_transportation": "교통수단 편집 오류", + "flight_number": "항공편 번호", + "from_location": "출발지", + "modes": { + "bike": "자전거", + "boat": "보트", + "bus": "버스", + "car": "자동차", + "other": "기타", + "plane": "비행기", + "train": "기차", + "walking": "도보" + }, + "new_transportation": "새 교통수단", + "provide_start_date": "출발 일자를 입력하세요", + "start": "시작", + "to_location": "도착지", + "transport_type": "교통수단 유형", + "transportation_added": "교통수단이 성공적으로 추가되었습니다!", + "transportation_delete_error": "교통수단 삭제 오류", + "transportation_deleted": "교통수단이 성공적으로 삭제되었습니다!", + "transportation_edit_success": "교통수단이 성공적으로 편집되었습니다!", + "type": "유형", + "ending_airport_desc": "엔드 공항 코드 입력 (예 : LAX)", + "fetch_location_information": "위치 정보를 가져 오십시오", + "starting_airport_desc": "공항 시작 코드 입력 (예 : JFK)" + }, + "users": { + "no_users_found": "공개 프로필인 사용자가 없습니다." + }, + "worldtravel": { + "all": "모두", + "all_subregions": "모든 하위 지역", + "all_visited": "당신은 모든 지역을 방문했습니다", + "cities": "도시", + "clear_search": "검색 초기화", + "completely_visited": "모두 방문함", + "country_list": "국가 목록", + "failed_to_mark_visit": "방문함으로 표시 중 오류", + "failed_to_remove_visit": "방문함으로 표시 제거 중 오류", + "marked_visited": "방문으로 표시", + "no_cities_found": "도시가 없습니다", + "no_countries_found": "국가가 없습니다", + "not_visited": "방문하지 않음", + "num_countries": "개 국가 검색", + "partially_visited": "일부 방문함", + "region_failed_visited": "지역을 방문함으로 표시 중 실패", + "region_stats": "지역 통계", + "regions_in": "소속 지역", + "removed": "제거됨", + "view_cities": "도시 보기", + "visit_remove_failed": "방문 표시 제거 실패", + "visit_to": "방문함" + }, + "lodging": { + "apartment": "아파트", + "bnb": "숙박 및 아침 식사", + "cabin": "선실", + "campground": "캠프장", + "check_in": "체크인", + "current_timezone": "현재 시간대", + "date_and_time": "날짜", + "edit": "편집하다", + "edit_lodging": "숙박 편집", + "error_editing_lodging": "오류 편집 숙소", + "hostel": "숙박소", + "hotel": "호텔", + "house": "집", + "lodging_added": "숙박이 성공적으로 추가되었습니다!", + "lodging_delete_error": "오류 삭제 숙박", + "lodging_deleted": "숙박 시설이 성공적으로 삭제되었습니다!", + "lodging_edit_success": "숙박이 성공적으로 편집되었습니다!", + "lodging_type": "숙박 유형", + "motel": "모텔", + "new_lodging": "새로운 숙박", + "other": "다른", + "provide_start_date": "시작 날짜를 제공하십시오", + "reservation_number": "예약 번호", + "resort": "의지", + "start": "시작", + "type": "유형", + "villa": "별장", + "check_out": "체크 아웃" + } +} diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index c2a59a5..4a783ec 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -51,9 +51,9 @@ "close_filters": "Sluit filters", "collection": "Collectie", "collection_adventures": "Inclusief collectie-avonturen", - "collection_link_error": "Fout bij het koppelen van avontuur aan collectie", + "collection_link_error": "Fout bij het koppelen van dit avontuur aan de collectie", "collection_link_success": "Avontuur succesvol gekoppeld aan collectie!", - "collection_remove_error": "Fout bij verwijderen van avontuur uit collectie", + "collection_remove_error": "Fout bij verwijderen van dit avontuur uit de collectie", "collection_remove_success": "Avontuur is succesvol uit de collectie verwijderd!", "count_txt": "resultaten die overeenkomen met uw zoekopdracht", "create_new": "Maak nieuwe...", @@ -94,9 +94,9 @@ "dates": "Datums", "delete_adventure": "Avontuur verwijderen", "duration": "Duur", - "image_removed_error": "Fout bij verwijderen van afbeelding", + "image_removed_error": "Fout bij het verwijderen van de afbeelding", "image_removed_success": "Afbeelding succesvol verwijderd!", - "image_upload_error": "Fout bij het uploaden van afbeelding", + "image_upload_error": "Fout bij het uploaden van de afbeelding", "image_upload_success": "Afbeelding succesvol geüpload!", "no_image_url": "Er is geen afbeelding gevonden op die URL.", "planned": "Gepland", @@ -117,7 +117,7 @@ "category": "Categorie", "clear_map": "Kaart leegmaken", "copy_link": "Kopieer link", - "date_constrain": "Beperk u tot ophaaldata", + "date_constrain": "Beperk tot de datums van de collectie", "description": "Beschrijving", "end_date": "Einddatum", "fetch_image": "Afbeelding ophalen", @@ -126,7 +126,7 @@ "image_fetch_failed": "Kan afbeelding niet ophalen", "link": "Link", "location": "Locatie", - "location_information": "Locatie-informatie", + "location_information": "Informatie over de locatie", "my_images": "Mijn afbeeldingen", "my_visits": "Mijn bezoeken", "new_adventure": "Nieuw avontuur", @@ -152,8 +152,8 @@ "wikipedia": "Wikipedia", "adventure_not_found": "Er zijn geen avonturen om weer te geven. \nVoeg er een paar toe via de plusknop rechtsonder of probeer de filters te wijzigen!", "all": "Alle", - "error_updating_regions": "Fout bij wijzigen van regio's", - "mark_visited": "Markeer bezocht", + "error_updating_regions": "Fout bij het wijzigen van regio's", + "mark_visited": "Markeer als bezocht", "my_adventures": "Mijn avonturen", "no_adventures_found": "Geen avonturen gevonden", "no_collections_found": "Er zijn geen collecties gevonden waar dit avontuur aan kan worden toegevoegd.", @@ -162,7 +162,7 @@ "regions_updated": "regio's bijgewerkt", "update_visited_regions": "Werk bezochte regio's bij", "update_visited_regions_disclaimer": "Dit kan even duren, afhankelijk van het aantal avondturen dat je hebt beleefd.", - "visited_region_check": "Check bezochte regio's", + "visited_region_check": "Controleer bezochte regio's", "visited_region_check_desc": "Door dit te selecteren, controleert de server alle avonturen die je beleefde en markeert hun regio's als bezocht in de wereldreizen.", "add_new": "Nieuw toevoegen...", "checklist": "Controlelijst", @@ -194,9 +194,9 @@ "adventure_calendar": "Avonturenkalender", "emoji_picker": "Emoji-kiezer", "hide": "Verbergen", - "show": "Show", + "show": "Toon", "download_calendar": "Agenda downloaden", - "md_instructions": "Schrijf hier uw korting...", + "md_instructions": "Schrijf hier in markdown...", "preview": "Voorbeeld", "checklist_delete_confirm": "Weet u zeker dat u deze checklist wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.", "clear_location": "Locatie wissen", @@ -212,10 +212,42 @@ "out_of_range": "Niet binnen het datumbereik van het reisplan", "show_region_labels": "Toon regiolabels", "start": "Begin", - "starting_airport": "Startende luchthaven", + "starting_airport": "Luchthaven van vertrek", "to": "Naar", "transportation_delete_confirm": "Weet u zeker dat u dit transport wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.", - "ending_airport": "Einde luchthaven" + "ending_airport": "Luchthaven van aankomst", + "show_map": "Toon kaart", + "will_be_marked": "wordt gemarkeerd als bezocht zodra het avontuur is opgeslagen.", + "cities_updated": "steden bijgewerkt", + "create_adventure": "Creëer avontuur", + "no_adventures_to_recommendations": "Geen avonturen gevonden. \nVoeg ten minste één avontuur toe om aanbevelingen te krijgen.", + "finding_recommendations": "Ontdek verborgen juweeltjes voor je volgende avontuur", + "attachment": "Bijlage", + "attachment_delete_success": "Bijlage succesvol verwijderd!", + "attachment_name": "Naam van bijlage", + "attachment_update_error": "Fout bij het bijwerken van de bijlage", + "attachment_update_success": "Bijlage succesvol bijgewerkt!", + "attachment_upload_error": "Fout bij het uploaden van de bijlage", + "attachment_upload_success": "Bijlage succesvol geüpload!", + "attachments": "Bijlagen", + "gpx_tip": "Upload GPX-bestanden naar bijlagen om ze op de kaart te bekijken!", + "images": "Afbeeldingen", + "primary": "Primair", + "upload": "Uploaden", + "view_attachment": "Bijlage bekijken", + "of": "van", + "city": "Stad", + "delete_lodging": "Verwijder accommodatie", + "display_name": "Weergavenaam", + "location_details": "Locatiegegevens", + "lodging": "Onderdak", + "reservation_number": "Reserveringsnummer", + "welcome_map_info": "Publieke avonturen op deze server", + "lodging_delete_confirm": "Weet u zeker dat u deze accommodatielocatie wilt verwijderen? \nDeze actie kan niet ongedaan worden gemaakt.", + "lodging_information": "Informatie overliggen", + "price": "Prijs", + "region": "Regio", + "open_in_maps": "Open in kaarten" }, "home": { "desc_1": "Ontdek, plan en verken met gemak", @@ -235,7 +267,7 @@ "about": "Over AdventureLog", "adventures": "Avonturen", "collections": "Collecties", - "discord": "discord", + "discord": "Discord", "documentation": "Documentatie", "greeting": "Hoi", "logout": "Uitloggen", @@ -262,7 +294,8 @@ "tag": "Label", "language_selection": "Taal", "support": "Steun", - "calendar": "Kalender" + "calendar": "Kalender", + "admin_panel": "Admin -paneel" }, "auth": { "confirm_password": "Bevestig wachtwoord", @@ -282,7 +315,12 @@ "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", + "no_public_adventures": "Geen openbare avonturen gevonden", + "no_public_collections": "Geen openbare collecties gevonden", + "user_adventures": "Gebruikersavonturen", + "user_collections": "Gebruikerscollecties" }, "users": { "no_users_found": "Er zijn geen gebruikers gevonden met openbare profielen." @@ -296,7 +334,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 in", + "removed": "verwijderd", + "view_cities": "Steden bekijken", + "visit_remove_failed": "Kan bezoek niet verwijderen", + "visit_to": "Bezoek aan" }, "settings": { "account_settings": "Gebruikersaccount instellingen", @@ -325,8 +376,8 @@ "join_discord": "Sluit je aan bij Discord", "join_discord_desc": "om uw eigen foto's te delen. \nPlaats ze in de", "photo_by": "Foto door", - "change_password_error": "Kan wachtwoord niet wijzigen. \nOngeldig huidig ​​wachtwoord of ongeldig nieuw wachtwoord.", - "current_password": "Huidig ​​wachtwoord", + "change_password_error": "Kan wachtwoord niet wijzigen. \nOngeldig huidig wachtwoord of ongeldig nieuw wachtwoord.", + "current_password": "Huidig wachtwoord", "password_change_lopout_warning": "Na het wijzigen van uw wachtwoord wordt u uitgelogd.", "authenticator_code": "Authenticatiecode", "copy": "Kopiëren", @@ -336,7 +387,7 @@ "email_removed": "E-mail succesvol verwijderd!", "email_removed_error": "Fout bij verwijderen van e-mail", "email_set_primary": "E-mailadres is succesvol ingesteld als primair!", - "email_set_primary_error": "Fout bij het instellen van e-mail als primair", + "email_set_primary_error": "Fout bij het instellen van dit e-mail als primair", "email_verified": "E-mail succesvol geverifieerd!", "email_verified_erorr_desc": "Uw e-mailadres kan niet worden geverifieerd. \nProbeer het opnieuw.", "email_verified_error": "Fout bij het verifiëren van e-mailadres", @@ -367,13 +418,31 @@ "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", + "disable_password": "Schakel het wachtwoord uit", + "password_disable": "Schakel wachtwoordverificatie uit", + "password_disable_desc": "Het uitschakelen van wachtwoordverificatie zal voorkomen dat u zich aanmeldt met een wachtwoord. \nU moet een sociale of OIDC-provider gebruiken om in te loggen. Als uw sociale provider niet wordt gekoppeld, wordt wachtwoordverificatie automatisch opnieuw ingeschakeld, zelfs als deze instelling is uitgeschakeld.", + "password_disable_warning": "Momenteel is wachtwoordverificatie uitgeschakeld. \nLogin via een sociale of OIDC -provider is vereist.", + "password_disabled": "Wachtwoordverificatie uitgeschakeld", + "password_disabled_error": "Fout het uitschakelen van wachtwoordverificatie. \nZorg ervoor dat een sociale of OIDC -provider is gekoppeld aan uw account.", + "password_enabled": "Wachtwoordverificatie ingeschakeld", + "password_enabled_error": "Fout bij het inschakelen van wachtwoordverificatie." }, "checklist": { "add_item": "Artikel toevoegen", - "checklist_delete_error": "Fout bij verwijderen van controlelijst", + "checklist_delete_error": "Fout bij het verwijderen van de controlelijst", "checklist_deleted": "Controlelijst succesvol verwijderd!", - "checklist_editor": "Controlelijst-editor", + "checklist_editor": "Controlelijsten bewerken", "checklist_public": "Deze controlelijst is openbaar omdat deze zich in een openbare collectie bevindt.", "editing_checklist": "Controlelijst bewerken", "failed_to_save": "Kan controlelijst niet opslaan", @@ -384,7 +453,7 @@ "new_item": "Nieuw artikel", "save": "Opslaan", "checklist_viewer": "Controlelijstviewer", - "new_checklist": "Nieuwe checklist" + "new_checklist": "Nieuwe controlelijst" }, "collection": { "collection_created": "Collectie succesvol aangemaakt!", @@ -392,7 +461,7 @@ "create": "Aanmaken", "edit_collection": "Collectie bewerken", "error_creating_collection": "Fout bij aanmaken collectie", - "error_editing_collection": "Fout bij bewerken collectie", + "error_editing_collection": "Fout bij het bewerken van de collectie", "new_collection": "Nieuwe collectie", "public_collection": "Openbare collectie" }, @@ -403,22 +472,22 @@ "failed_to_save": "Kan opmerking niet opslaan", "note_delete_error": "Fout bij verwijderen van opmerking", "note_deleted": "Opmerking succesvol verwijderd!", - "note_editor": "Opmerking-editor", + "note_editor": "Opmerkingen bewerken", "note_public": "Deze opmerking is openbaar omdat deze zich in een openbare collectie bevindt.", "open": "Open", "save": "Opslaan", "invalid_url": "Ongeldige URL", - "note_viewer": "Notitieviewer" + "note_viewer": "Bekijk notities" }, "transportation": { "date_and_time": "Datum", "date_time": "Startdatum", - "edit": "Bewerking", + "edit": "Bewerk", "edit_transportation": "Vervoer bewerken", "end_date_time": "Einddatum", - "error_editing_transportation": "Fout bij bewerken van vervoer", + "error_editing_transportation": "Fout bij het bewerken van het vervoer", "flight_number": "Vluchtnummer", - "from_location": "Van locatie", + "from_location": "Vertreklocatie", "modes": { "bike": "Fiets", "boat": "Boot", @@ -427,9 +496,9 @@ "car": "Auto", "other": "Ander", "plane": "Vliegtuig", - "walking": "Lopen" + "walking": "Wandelen" }, - "to_location": "Naar locatie", + "to_location": "Aankomstlocatie", "transportation_edit_success": "Vervoer succesvol bewerkt!", "type": "Type", "new_transportation": "Nieuw vervoer", @@ -438,7 +507,10 @@ "transport_type": "Vervoerstype", "transportation_added": "Vervoer succesvol toegevoegd!", "transportation_delete_error": "Fout bij verwijderen vervoer", - "transportation_deleted": "Vervoer succesvol verwijderd!" + "transportation_deleted": "Vervoer succesvol verwijderd!", + "ending_airport_desc": "Voer eindigende luchthavencode in (bijv. LAX)", + "fetch_location_information": "Locatie -informatie ophalen", + "starting_airport_desc": "Voer de startende luchthavencode in (bijv. JFK)" }, "search": { "adventurelog_results": "AdventureLog-resultaten", @@ -447,24 +519,14 @@ }, "map": { "add_adventure": "Voeg nieuw avontuur toe", - "add_adventure_at_marker": "Voeg een nieuw avontuur toe bij markeerpunt", + "add_adventure_at_marker": "Voeg een nieuw avontuur toe bij het markeerpunt", "adventure_map": "Avonturenkaart", "clear_marker": "Verwijder markeerpunt", "map_options": "Kaartopties", "show_visited_regions": "Toon bezochte regio's", "view_details": "Details bekijken" }, - "languages": { - "de": "Duits", - "en": "Engels", - "es": "Spaans", - "fr": "Frans", - "it": "Italiaans", - "nl": "Nederlands", - "sv": "Zweeds", - "zh": "Chinese", - "pl": "Pools" - }, + "languages": {}, "share": { "no_users_shared": "Er zijn geen gebruikers gedeeld", "not_shared_with": "Niet gedeeld met", @@ -481,15 +543,16 @@ "member_since": "Lid sinds", "user_stats": "Gebruikersstatistieken", "visited_countries": "Bezochte landen", - "visited_regions": "Bezochte regio's" + "visited_regions": "Bezochte regio's", + "visited_cities": "Steden bezocht" }, "categories": { "category_name": "Categorienaam", "edit_category": "Categorie bewerken", - "icon": "Ikoon", + "icon": "Icoon", "manage_categories": "Beheer categorieën", "no_categories_found": "Geen categorieën gevonden.", - "select_category": "Selecteer categorie", + "select_category": "Selecteer een categorie", "update_after_refresh": "De avonturenkaarten worden bijgewerkt zodra u de pagina vernieuwt." }, "dashboard": { @@ -499,6 +562,69 @@ "recent_adventures": "Recente avonturen", "total_adventures": "Totale avonturen", "total_visited_regions": "Totaal bezochte regio's", - "welcome_back": "Welkom terug" + "welcome_back": "Welkom terug", + "total_visited_cities": "Totaal bezochte steden" + }, + "immich": { + "api_key": "Immich API-sleutel", + "api_note": "Let op: dit moet de URL naar de Immich API-server zijn, deze eindigt dus waarschijnlijk op /api, tenzij je een aangepaste configuratie hebt.", + "disable": "Uitzetten", + "enable_immich": "Schakel Immich in", + "imageid_required": "Afbeeldings-ID is vereist", + "immich": "Immich", + "immich_desc": "Integreer uw Immich-account met AdventureLog zodat u in uw fotobibliotheek kunt zoeken en foto's voor uw avonturen kunt importeren.", + "immich_disabled": "Immich-integratie succesvol uitgeschakeld!", + "immich_enabled": "Immich-integratie succesvol ingeschakeld!", + "immich_error": "Fout bij updaten van Immich-integratie", + "immich_integration": "Immich-integratie", + "immich_updated": "Immich-instellingen zijn succesvol bijgewerkt!", + "integration_enabled": "Integratie ingeschakeld", + "integration_fetch_error": "Fout bij het ophalen van gegevens uit de Immich-integratie", + "integration_missing": "De Immich-integratie ontbreekt in de backend", + "load_more": "Laad meer", + "no_items_found": "Geen artikelen gevonden", + "query_required": "Er is een zoekopdracht vereist", + "server_down": "De Immich-server is momenteel offline of onbereikbaar", + "server_url": "Immich-server-URL", + "update_integration": "Integratie bijwerken", + "documentation": "Immich-integratiedocumentatie", + "localhost_note": "Opmerking: localhost zal hoogstwaarschijnlijk niet werken tenzij dit bewust zo geconfigureerd is in het docker-netwerk. \nHet is aanbevolen om het IP-adres van de server of de domeinnaam te gebruiken." + }, + "recomendations": { + "address": "Adres", + "contact": "Contact", + "phone": "Telefoon", + "recommendation": "Aanbeveling", + "website": "Website" + }, + "lodging": { + "apartment": "Appartement", + "bnb": "Bed and breakfast", + "cabin": "Cabine", + "campground": "Camping", + "check_in": "Inchecken", + "check_out": "Uitchecken", + "date_and_time": "Datum", + "edit": "Bewerking", + "edit_lodging": "Bewerkingen bewerken", + "error_editing_lodging": "Foutbewerkingsbewerkingen", + "hostel": "Hostel", + "hotel": "Hotel", + "house": "Huis", + "lodging_added": "Lodging met succes toegevoegd!", + "lodging_delete_error": "Fout bij het verwijderen van accommodatie", + "lodging_deleted": "Met succes verwijderd!", + "lodging_edit_success": "Lodging met succes bewerkt!", + "lodging_type": "Lodging type", + "motel": "Motel", + "new_lodging": "Nieuwe accommodatie", + "other": "Ander", + "provide_start_date": "Geef een startdatum op", + "reservation_number": "Reserveringsnummer", + "resort": "Toevlucht", + "start": "Begin", + "type": "Type", + "villa": "Villa", + "current_timezone": "Huidige tijdzone" } } diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index eac9634..3925de1 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -30,7 +30,8 @@ "aqua": "Aqua", "northernLights": "Zorza Polarna" }, - "calendar": "Kalendarz" + "calendar": "Kalendarz", + "admin_panel": "Panel administracyjny" }, "about": { "about": "O aplikacji", @@ -262,7 +263,39 @@ "start": "Start", "starting_airport": "Początkowe lotnisko", "to": "Do", - "transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć." + "transportation_delete_confirm": "Czy na pewno chcesz usunąć ten transport? \nTej akcji nie można cofnąć.", + "show_map": "Pokaż mapę", + "will_be_marked": "zostanie oznaczona jako odwiedzona po zapisaniu przygody.", + "cities_updated": "miasta zaktualizowane", + "create_adventure": "Stwórz przygodę", + "no_adventures_to_recommendations": "Nie znaleziono żadnych przygód. \nDodaj co najmniej jedną przygodę, aby uzyskać rekomendacje.", + "finding_recommendations": "Odkrywanie ukrytych klejnotów na następną przygodę", + "attachment": "Załącznik", + "attachment_delete_success": "Załącznik został pomyślnie usunięty!", + "attachment_name": "Nazwa załącznika", + "attachment_update_error": "Błąd podczas aktualizacji załącznika", + "attachment_update_success": "Załącznik został pomyślnie zaktualizowany!", + "attachment_upload_error": "Błąd podczas przesyłania załącznika", + "attachment_upload_success": "Załącznik przesłany pomyślnie!", + "attachments": "Załączniki", + "gpx_tip": "Przesyłaj pliki GPX do załączników, aby wyświetlić je na mapie!", + "images": "Obrazy", + "primary": "Podstawowy", + "upload": "Wgrywać", + "view_attachment": "Zobacz załącznik", + "of": "z", + "city": "Miasto", + "delete_lodging": "Usunąć zakwaterowanie", + "display_name": "Nazwa wyświetlania", + "location_details": "Szczegóły lokalizacji", + "lodging": "Kwatera", + "lodging_delete_confirm": "Czy na pewno chcesz usunąć tę lokalizację zakwaterowania? \nTego działania nie można cofnąć.", + "lodging_information": "Informacje o zakwaterowaniu", + "price": "Cena", + "region": "Region", + "reservation_number": "Numer rezerwacji", + "welcome_map_info": "Publiczne przygody na tym serwerze", + "open_in_maps": "Otwarte w mapach" }, "worldtravel": { "country_list": "Lista krajów", @@ -273,7 +306,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", @@ -293,7 +339,12 @@ "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", + "no_public_adventures": "Nie znaleziono publicznych przygód", + "no_public_collections": "Nie znaleziono publicznych kolekcji", + "user_adventures": "Przygody użytkowników", + "user_collections": "Kolekcje użytkowników" }, "users": { "no_users_found": "Nie znaleziono użytkowników z publicznymi profilami." @@ -367,7 +418,25 @@ "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", + "disable_password": "Wyłącz hasło", + "password_disable": "Wyłącz uwierzytelnianie hasła", + "password_disable_desc": "Wyłączenie uwierzytelniania hasła uniemożliwia logowanie hasłem. \nAby się zalogować, będziesz musiał użyć dostawcy społeczności lub OIDC. Jeśli dostawca społecznościowy zostanie niezbędny, uwierzytelnianie hasła zostanie automatycznie ponowne odnowione, nawet jeśli to ustawienie zostanie wyłączone.", + "password_disable_warning": "Obecnie uwierzytelnianie hasła jest wyłączone. \nWymagane jest zalogowanie się za pośrednictwem dostawcy społeczności lub OIDC.", + "password_disabled": "Uwierzytelnianie hasła wyłączone", + "password_disabled_error": "Błąd wyłączający uwierzytelnianie hasła. \nUpewnij się, że dostawca społecznościowy lub OIDC jest powiązany z Twoim kontem.", + "password_enabled": "Włączone uwierzytelnianie hasła", + "password_enabled_error": "Błąd umożliwiający uwierzytelnianie hasła." }, "collection": { "collection_created": "Kolekcja została pomyślnie utworzona!", @@ -438,7 +507,10 @@ "transportation_edit_success": "Transport edytowany pomyślnie!", "edit_transportation": "Edytuj transport", "start": "Początek", - "date_and_time": "Data i godzina" + "date_and_time": "Data i godzina", + "ending_airport_desc": "Wprowadź końcowe kod lotniska (np. LAX)", + "fetch_location_information": "Pobierać informacje o lokalizacji", + "starting_airport_desc": "Wprowadź początkowy kod lotniska (np. JFK)" }, "search": { "adventurelog_results": "Wyniki AdventureLog", @@ -466,22 +538,13 @@ "set_public": "Aby umożliwić użytkownikom udostępnianie Tobie, musisz ustawić swój profil jako publiczny.", "go_to_settings": "Przejdź do ustawień" }, - "languages": { - "en": "Angielski", - "de": "Niemiecki", - "es": "Hiszpański", - "fr": "Francuski", - "it": "Włoski", - "nl": "Holenderski", - "sv": "Szwedzki", - "zh": "Chiński", - "pl": "Polski" - }, + "languages": {}, "profile": { "member_since": "Użytkownik od", "user_stats": "Statystyki użytkownika", "visited_countries": "Odwiedzone kraje", - "visited_regions": "Odwiedzone regiony" + "visited_regions": "Odwiedzone regiony", + "visited_cities": "Odwiedzone miasta" }, "categories": { "manage_categories": "Zarządzaj kategoriami", @@ -499,6 +562,69 @@ "recent_adventures": "Ostatnie przygody", "total_adventures": "Totalne przygody", "total_visited_regions": "Łączna liczba odwiedzonych regionów", - "welcome_back": "Witamy z powrotem" + "welcome_back": "Witamy z powrotem", + "total_visited_cities": "Łączna liczba odwiedzonych miast" + }, + "immich": { + "api_key": "Klucz API Immicha", + "api_note": "Uwaga: musi to być adres URL serwera API Immich, więc prawdopodobnie kończy się na /api, chyba że masz niestandardową konfigurację.", + "disable": "Wyłączyć", + "enable_immich": "Włącz Immicha", + "immich": "Immich", + "immich_enabled": "Integracja z Immich została pomyślnie włączona!", + "immich_error": "Błąd podczas aktualizacji integracji Immich", + "immich_integration": "Integracja Immicha", + "immich_updated": "Ustawienia Immich zostały pomyślnie zaktualizowane!", + "integration_enabled": "Integracja włączona", + "integration_fetch_error": "Błąd podczas pobierania danych z integracji Immich", + "integration_missing": "W backendie brakuje integracji z Immich", + "load_more": "Załaduj więcej", + "no_items_found": "Nie znaleziono żadnych elementów", + "query_required": "Zapytanie jest wymagane", + "server_down": "Serwer Immich jest obecnie wyłączony lub nieosiągalny", + "server_url": "Adres URL serwera Immich", + "update_integration": "Zaktualizuj integrację", + "imageid_required": "Wymagany jest identyfikator obrazu", + "immich_desc": "Zintegruj swoje konto Immich z AdventureLog, aby móc przeszukiwać bibliotekę zdjęć i importować zdjęcia do swoich przygód.", + "immich_disabled": "Integracja z Immich została pomyślnie wyłączona!", + "documentation": "Dokumentacja integracji Immicha", + "localhost_note": "Uwaga: localhost najprawdopodobniej nie będzie działać, jeśli nie skonfigurujesz odpowiednio sieci dokerów. \nZalecane jest użycie adresu IP serwera lub nazwy domeny." + }, + "recomendations": { + "address": "Adres", + "contact": "Kontakt", + "phone": "Telefon", + "recommendation": "Zalecenie", + "website": "Strona internetowa" + }, + "lodging": { + "apartment": "Apartament", + "bnb": "Nocleg i śniadanie", + "cabin": "Kabina", + "campground": "Obozowisko", + "lodging_added": "Lodowanie dodane pomyślnie!", + "lodging_delete_error": "Usuwanie błędów", + "lodging_deleted": "Z powodzeniem usunięto!", + "lodging_edit_success": "Złożone zredagowane zredagowane!", + "lodging_type": "Typ składowania", + "motel": "Motel", + "start": "Start", + "type": "Typ", + "villa": "Willa", + "check_in": "Zameldować się", + "check_out": "Wymeldować się", + "date_and_time": "Data", + "edit": "Redagować", + "edit_lodging": "Edytuj zakwaterowanie", + "error_editing_lodging": "Edycja błędów", + "hostel": "Schronisko", + "hotel": "Hotel", + "house": "Dom", + "new_lodging": "Nowe zakwaterowanie", + "other": "Inny", + "provide_start_date": "Proszę podać datę rozpoczęcia", + "reservation_number": "Numer rezerwacji", + "resort": "Uciec", + "current_timezone": "Obecna strefa czasowa" } } diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 495b0ba..c6fd200 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -1,9 +1,9 @@ { "about": { "about": "Om", - "close": "Nära", + "close": "Stäng", "license": "Licensierad under GPL-3.0-licensen.", - "message": "Tillverkad med ❤️ i USA.", + "message": "Skapat med ❤️ i USA.", "nominatim_1": "Platssökning och geokodning tillhandahålls av", "nominatim_2": "Deras data är licensierad under ODbL-licensen.", "oss_attributions": "Tillskrivningar med öppen källkod", @@ -80,12 +80,12 @@ "not_found_desc": "Äventyret du letade efter kunde inte hittas. \nProva ett annat äventyr eller kom tillbaka senare.", "open_details": "Öppna Detaljer", "open_filters": "Öppna filter", - "order_by": "Beställ efter", - "order_direction": "Beställ riktning", - "planned": "Planerad", + "order_by": "Sortera efter", + "order_direction": "Sorteringsriktning", + "planned": "Planerade", "private": "Privat", "public": "Offentlig", - "rating": "Gradering", + "rating": "Betyg", "remove_from_collection": "Ta bort från samlingen", "share": "Dela", "sort": "Sortera", @@ -93,7 +93,7 @@ "unarchive": "Avarkivera", "unarchived_collection_message": "Samlingen har tagits bort från arkivet!", "visit": "Besök", - "visited": "Besökte", + "visited": "Besökta", "visits": "Besök", "image_removed_error": "Det gick inte att ta bort bilden", "image_removed_success": "Bilden har tagits bort!", @@ -153,12 +153,12 @@ "all": "Alla", "error_updating_regions": "Fel vid uppdatering av regioner", "mark_region_as_visited": "Markera region {region}, {country} som besökt?", - "mark_visited": "Mark besökte", + "mark_visited": "Markera som besökt", "my_adventures": "Mina äventyr", "no_adventures_found": "Inga äventyr hittades", "no_collections_found": "Inga samlingar hittades att lägga till detta äventyr till.", "no_linkable_adventures": "Inga äventyr hittades som kan kopplas till denna samling.", - "not_visited": "Ej besökt", + "not_visited": "Ej besökta", "regions_updated": "regioner uppdaterade", "update_visited_regions": "Uppdatera besökta regioner", "update_visited_regions_disclaimer": "Detta kan ta ett tag beroende på antalet äventyr du har besökt.", @@ -215,7 +215,39 @@ "start": "Start", "starting_airport": "Startar flygplats", "to": "Till", - "transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras." + "transportation_delete_confirm": "Är du säker på att du vill ta bort denna transport? \nDenna åtgärd kan inte ångras.", + "show_map": "Visa karta", + "will_be_marked": "kommer att markeras som besökt när äventyret har sparats.", + "cities_updated": "städer uppdaterade", + "create_adventure": "Skapa äventyr", + "no_adventures_to_recommendations": "Inga äventyr hittades. \nLägg till minst ett äventyr för att få rekommendationer.", + "finding_recommendations": "Upptäck dolda pärlor för ditt nästa äventyr", + "attachment": "Fastsättning", + "attachment_delete_success": "Bilagan har raderats!", + "attachment_name": "Bilagans namn", + "attachment_update_error": "Fel vid uppdatering av bilaga", + "attachment_update_success": "Bilagan har uppdaterats!", + "attachment_upload_error": "Det gick inte att ladda upp bilagan", + "attachment_upload_success": "Bilagan har laddats upp!", + "attachments": "Bilagor", + "gpx_tip": "Ladda upp GPX-filer till bilagor för att se dem på kartan!", + "images": "Bilder", + "primary": "Primär", + "upload": "Ladda upp", + "view_attachment": "Visa bilaga", + "of": "av", + "city": "Stad", + "delete_lodging": "Ta bort logi", + "display_name": "Visningsnamn", + "location_details": "Platsinformation", + "lodging": "Logi", + "welcome_map_info": "Offentliga äventyr på denna server", + "lodging_delete_confirm": "Är du säker på att du vill ta bort den här logiplatsen? \nDenna åtgärd kan inte ångras.", + "lodging_information": "Logi information", + "price": "Pris", + "region": "Område", + "reservation_number": "Bokningsnummer", + "open_in_maps": "Kappas in" }, "home": { "desc_1": "Upptäck, planera och utforska med lätthet", @@ -223,29 +255,29 @@ "feature_1": "Reselogg", "feature_1_desc": "Håll koll på dina äventyr med en personlig reselogg och dela dina upplevelser med vänner och familj.", "feature_2": "Reseplanering", - "feature_2_desc": "Skapa enkelt anpassade resplaner och få en uppdelning av din resa dag för dag.", + "feature_2_desc": "Skapa enkelt skräddarsydda resplaner och få en översikt över din resa, dag för dag.", "feature_3": "Resekarta", "feature_3_desc": "Se dina resor över hela världen med en interaktiv karta och utforska nya destinationer.", "go_to": "Gå till AdventureLog", "hero_1": "Upptäck världens mest spännande äventyr", "hero_2": "Upptäck och planera ditt nästa äventyr med AdventureLog. \nUtforska hisnande destinationer, skapa anpassade resplaner och håll kontakten när du är på språng.", - "key_features": "Nyckelfunktioner" + "key_features": "Viktiga funktioner" }, "navbar": { "about": "Om AdventureLog", "adventures": "Äventyr", "collections": "Samlingar", - "discord": "Disharmoni", + "discord": "Discord", "documentation": "Dokumentation", "greeting": "Hej", - "logout": "Utloggning", + "logout": "Logga ut", "map": "Karta", "my_adventures": "Mina äventyr", "profile": "Profil", - "search": "Söka", + "search": "Sök", "settings": "Inställningar", "shared_with_me": "Delade med mig", - "theme_selection": "Temaval", + "theme_selection": "Tema", "themes": { "aqua": "Aqua", "dark": "Mörk", @@ -259,21 +291,35 @@ "users": "Användare", "worldtravel": "Världsresor", "my_tags": "Mina taggar", - "tag": "Märka", + "tag": "Tagg", "language_selection": "Språk", - "support": "Stöd", - "calendar": "Kalender" + "support": "Support", + "calendar": "Kalender", + "admin_panel": "Administratör" }, "worldtravel": { "all": "Alla", "all_subregions": "Alla underregioner", "clear_search": "Rensa sökning", - "completely_visited": "Helt besökt", + "completely_visited": "Fullständigt besökta", "country_list": "Lista över länder", "no_countries_found": "Inga länder hittades", - "not_visited": "Ej besökt", - "num_countries": "hittade länder", - "partially_visited": "Delvis besökt" + "not_visited": "Ej besökta", + "num_countries": "länder hittades", + "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", @@ -281,7 +327,7 @@ "first_name": "Förnamn", "forgot_password": "Glömt lösenordet?", "last_name": "Efternamn", - "login": "Inloggning", + "login": "Logga in", "login_error": "Det går inte att logga in med de angivna uppgifterna.", "password": "Lösenord", "registration_disabled": "Registreringen är för närvarande inaktiverad.", @@ -293,7 +339,12 @@ "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", + "no_public_adventures": "Inga offentliga äventyr hittades", + "no_public_collections": "Inga offentliga samlingar hittades", + "user_adventures": "Användaräventyr", + "user_collections": "Användarsamlingar" }, "users": { "no_users_found": "Inga användare hittades med offentliga profiler." @@ -307,25 +358,25 @@ "new_password": "Nytt lösenord", "no_email_set": "Ingen e-post inställd", "password_change": "Ändra lösenord", - "settings_page": "Inställningssida", + "settings_page": "Inställningar", "update": "Uppdatera", - "update_error": "Fel vid uppdatering av inställningar", + "update_error": "Ett fel uppstod vid uppdatering av inställningar", "update_success": "Inställningarna har uppdaterats!", "change_password": "Ändra lösenord", "invalid_token": "Token är ogiltig eller har gått ut", "login_redir": "Du kommer då att omdirigeras till inloggningssidan.", "missing_email": "Vänligen ange en e-postadress", - "password_does_not_match": "Lösenord stämmer inte överens", + "password_does_not_match": "Lösenorden stämmer inte överens", "password_is_required": "Lösenord krävs", "possible_reset": "Om e-postadressen du angav är kopplad till ett konto kommer du att få ett e-postmeddelande med instruktioner för att återställa ditt lösenord!", "reset_password": "Återställ lösenord", - "submit": "Överlämna", + "submit": "Skicka", "token_required": "Token och UID krävs för lösenordsåterställning.", "about_this_background": "Om denna bakgrund", "join_discord": "Gå med i Discord", "join_discord_desc": "för att dela dina egna foton. \nLägg upp dem i", "photo_by": "Foto av", - "change_password_error": "Det går inte att ändra lösenord. \nOgiltigt nuvarande lösenord eller ogiltigt nytt lösenord.", + "change_password_error": "Det gick inte att ändra lösenord. \nDet nuvarande eller det nya lösenordet är ogiltigt.", "current_password": "Aktuellt lösenord", "password_change_lopout_warning": "Du kommer att loggas ut efter att du har ändrat ditt lösenord.", "authenticator_code": "Autentiseringskod", @@ -367,23 +418,41 @@ "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", + "disable_password": "Inaktivera lösenord", + "password_disable": "Inaktivera lösenordsautentisering", + "password_disable_desc": "Inaktivering av lösenordsautentisering förhindrar dig att logga in med ett lösenord. \nDu måste använda en social eller OIDC-leverantör för att logga in. Om din sociala leverantör lossnar kommer lösenordsautentisering automatiskt att aktiveras även om den här inställningen är inaktiverad.", + "password_disable_warning": "För närvarande är lösenordsautentisering inaktiverad. \nLogga in via en social eller OIDC -leverantör krävs.", + "password_disabled": "Lösenordsautentisering inaktiverad", + "password_disabled_error": "Fel Inaktivera lösenordsautentisering. \nSe till att en social eller OIDC -leverantör är länkad till ditt konto.", + "password_enabled": "Lösenordsautentisering aktiverad", + "password_enabled_error": "Fel som aktiverar lösenordsautentisering." }, "checklist": { "add_item": "Lägg till objekt", - "checklist_delete_error": "Fel vid borttagning av checklista", + "checklist_delete_error": "Ett fel uppstod vid borttagning av checklista", "checklist_deleted": "Checklistan har raderats!", - "checklist_editor": "Checklista Editor", + "checklist_editor": "Redigerare för checklistor", "checklist_public": "Den här checklistan är offentlig eftersom den finns i en offentlig samling.", - "editing_checklist": "Redigeringschecklista", + "editing_checklist": "Redigerar checklista", "failed_to_save": "Det gick inte att spara checklistan", "item": "Punkt", - "item_already_exists": "Objektet finns redan", - "item_cannot_be_empty": "Objektet får inte vara tomt", - "items": "Föremål", + "item_already_exists": "Listobjektet finns redan", + "item_cannot_be_empty": "Listobjektet får inte vara tomt", + "items": "Punkter", "new_item": "Nytt föremål", "save": "Spara", - "checklist_viewer": "Checklista Viewer", + "checklist_viewer": "Se Checklista", "new_checklist": "Ny checklista" }, "collection": { @@ -392,18 +461,18 @@ "create": "Skapa", "edit_collection": "Redigera samling", "error_creating_collection": "Det gick inte att skapa samlingen", - "error_editing_collection": "Fel vid redigering av samling", + "error_editing_collection": "Ett fel uppstod vid redigering av samling", "new_collection": "Ny samling", "public_collection": "Offentlig samling" }, "notes": { "add_a_link": "Lägg till en länk", "content": "Innehåll", - "editing_note": "Redigeringsanteckning", + "editing_note": "Redigerar anteckning", "failed_to_save": "Det gick inte att spara anteckningen", "note_delete_error": "Det gick inte att ta bort anteckningen", "note_deleted": "Anteckningen har raderats!", - "note_editor": "Note Editor", + "note_editor": "Redigerare för anteckningar", "note_public": "Den här anteckningen är offentlig eftersom den finns i en offentlig samling.", "open": "Öppna", "save": "Spara", @@ -425,7 +494,7 @@ "bus": "Buss", "car": "Bil", "other": "Andra", - "plane": "Plan", + "plane": "Flygplan", "train": "Tåg", "walking": "Gående" }, @@ -438,7 +507,10 @@ "transportation_delete_error": "Det gick inte att ta bort transport", "transportation_deleted": "Transporten har raderats!", "transportation_edit_success": "Transporten har redigerats!", - "type": "Typ" + "type": "Typ", + "ending_airport_desc": "Ange slut på flygplatskoden (t.ex. LAX)", + "fetch_location_information": "Hämta platsinformation", + "starting_airport_desc": "Ange start av flygplatskoden (t.ex. JFK)" }, "search": { "adventurelog_results": "AdventureLog-resultat", @@ -454,17 +526,7 @@ "show_visited_regions": "Visa besökta regioner", "view_details": "Visa detaljer" }, - "languages": { - "de": "tyska", - "en": "engelska", - "es": "spanska", - "fr": "franska", - "it": "italienska", - "nl": "holländska", - "sv": "svenska", - "zh": "kinesiska", - "pl": "polsk" - }, + "languages": {}, "share": { "no_users_shared": "Inga användare delas med", "not_shared_with": "Inte delad med", @@ -475,13 +537,14 @@ "with": "med", "go_to_settings": "Gå till inställningar", "no_shared_found": "Inga samlingar hittades som delas med dig.", - "set_public": "För att tillåta användare att dela med dig måste du ha din profil inställd på offentlig." + "set_public": "För att tillåta användare att dela med dig måste du ha en offentlig profil." }, "profile": { - "member_since": "Medlem sedan dess", + "member_since": "Medlem sedan", "user_stats": "Användarstatistik", "visited_countries": "Besökta länder", - "visited_regions": "Besökte regioner" + "visited_regions": "Besökta regioner", + "visited_cities": "Besökte städer" }, "categories": { "category_name": "Kategorinamn", @@ -499,6 +562,69 @@ "recent_adventures": "Senaste äventyr", "total_adventures": "Totala äventyr", "total_visited_regions": "Totalt antal besökta regioner", - "welcome_back": "Välkommen tillbaka" + "welcome_back": "Välkommen tillbaka", + "total_visited_cities": "Totalt antal besökta städer" + }, + "immich": { + "api_key": "Immich API-nyckel", + "api_note": "Obs: detta måste vara URL:en till Immich API-servern så den slutar troligen med /api om du inte har en anpassad konfiguration.", + "disable": "Inaktivera", + "enable_immich": "Aktivera Immich", + "imageid_required": "Bild-ID krävs", + "immich": "Immich", + "immich_desc": "Integrera ditt Immich-konto med AdventureLog så att du kan söka i ditt fotobibliotek och importera bilder för dina äventyr.", + "immich_disabled": "Immich-integrationen inaktiverades framgångsrikt!", + "immich_enabled": "Immich-integrationen har aktiverats framgångsrikt!", + "immich_error": "Fel vid uppdatering av Immich-integration", + "immich_integration": "Immich Integration", + "immich_updated": "Immich-inställningarna har uppdaterats framgångsrikt!", + "integration_enabled": "Integration aktiverad", + "integration_fetch_error": "Fel vid hämtning av data från Immich-integrationen", + "integration_missing": "Immich-integrationen saknas i backend", + "load_more": "Ladda mer", + "no_items_found": "Inga föremål hittades", + "query_required": "Fråga krävs", + "server_down": "Immich-servern är för närvarande nere eller kan inte nås", + "server_url": "Immich Server URL", + "update_integration": "Uppdatera integration", + "documentation": "Immich Integrationsdokumentation", + "localhost_note": "Obs: localhost kommer sannolikt inte att fungera om du inte har konfigurerat docker-nätverk i enlighet med detta. \nDet rekommenderas att använda serverns IP-adress eller domännamnet." + }, + "recomendations": { + "address": "Adress", + "contact": "Kontakta", + "phone": "Telefon", + "recommendation": "Rekommendation", + "website": "Webbplats" + }, + "lodging": { + "apartment": "Lägenhet", + "bnb": "Säng och frukost", + "cabin": "Stuga", + "campground": "Campingplats", + "error_editing_lodging": "Felredigeringsbefäl", + "hostel": "Vandrarhem", + "hotel": "Hotell", + "house": "Hus", + "lodging_added": "Logi tillagd framgångsrikt!", + "lodging_delete_error": "Felladering logi", + "lodging_deleted": "Logga raderas framgångsrikt!", + "lodging_edit_success": "LOGGE Redigerad framgångsrikt!", + "lodging_type": "Logi", + "motel": "Motell", + "new_lodging": "Inställning", + "other": "Andra", + "provide_start_date": "Vänligen ange ett startdatum", + "reservation_number": "Bokningsnummer", + "resort": "Tillflykt", + "start": "Start", + "type": "Typ", + "villa": "Villa", + "check_in": "Checka in", + "check_out": "Checka ut", + "date_and_time": "Datum", + "edit": "Redigera", + "edit_lodging": "Redigera logi", + "current_timezone": "Nuvarande tidszon" } } diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 0a686a2..864d699 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -1,236 +1,4 @@ { - "about": { - "about": "关于", - "close": "关闭", - "license": "根据 GPL-3.0 许可证获得许可。", - "message": "由 ❤️ 在美国制造。", - "nominatim_1": "位置搜索和地理编码由以下提供:", - "nominatim_2": "他们的数据已获得 ODbL 许可证的许可。", - "oss_attributions": "开源属性", - "other_attributions": "其他属性可以在自述文件中找到。", - "source_code": "源代码" - }, - "adventures": { - "activities": { - "activity": "活动🏄", - "art_museums": "艺术", - "attraction": "景点🎢", - "culture": "文化🎭", - "dining": "餐饮🍽️", - "event": "活动🎉", - "festivals": "节日🎪", - "fitness": "健身🏋️", - "general": "一般🌍", - "hiking": "徒步旅行🥾", - "historical_sites": "历史古迹🏛️", - "lodging": "住宿🛌", - "music_concerts": "音乐", - "nightlife": "夜生活🌃", - "other": "其他", - "outdoor": "户外🏞️", - "shopping": "购物🛍️", - "spiritual_journeys": "心灵之旅🧘‍♀️", - "transportation": "交通🚗", - "volunteer_work": "志愿工作🤝", - "water_sports": "水上运动🚤", - "wildlife": "野生动物🦒" - }, - "add_to_collection": "添加到收藏", - "adventure": "冒险", - "adventure_delete_confirm": "您确定要删除此冒险吗?\n此操作无法撤消。", - "adventure_details": "冒险详情", - "adventure_type": "冒险类型", - "archive": "档案", - "archived": "已存档", - "archived_collection_message": "收藏存档成功!", - "archived_collections": "存档收藏", - "ascending": "升序", - "cancel": "取消", - "category_filter": "类别过滤器", - "clear": "清除", - "close_filters": "关闭过滤器", - "collection": "收藏", - "collection_adventures": "包括收集冒险", - "collection_link_error": "将冒险与收藏链接时出错", - "collection_link_success": "冒险与收藏成功关联!", - "collection_remove_error": "从集合中删除冒险时出错", - "collection_remove_success": "冒险已成功从收藏中删除!", - "count_txt": "与您的搜索匹配的结果", - "create_new": "创建新...", - "date": "日期", - "delete": "删除", - "delete_collection": "删除集合", - "delete_collection_success": "收藏删除成功!", - "delete_collection_warning": "您确定要删除该收藏吗?\n这也将删除所有链接的冒险。\n此操作无法撤消。", - "descending": "降序", - "edit_adventure": "编辑冒险", - "edit_collection": "编辑收藏", - "filter": "筛选", - "homepage": "主页", - "latitude": "纬度", - "longitude": "经度", - "my_collections": "我的收藏", - "name": "姓名", - "no_image_found": "没有找到图片", - "not_found": "冒险未找到", - "not_found_desc": "无法找到您正在寻找的冒险。\n请尝试不同的冒险或稍后回来查看。", - "open_details": "打开详情", - "open_filters": "打开过滤器", - "order_by": "订购方式", - "order_direction": "订单方向", - "private": "私人的", - "public": "民众", - "rating": "等级", - "remove_from_collection": "从集合中删除", - "share": "分享", - "sort": "种类", - "sources": "来源", - "unarchive": "取消归档", - "unarchived_collection_message": "收藏解压成功!", - "updated": "已更新", - "visit": "访问", - "visits": "访问量", - "adventure_delete_success": "冒险删除成功!", - "dates": "枣子", - "delete_adventure": "删除冒险", - "duration": "期间", - "image_removed_error": "删除图像时出错", - "image_removed_success": "图片删除成功!", - "image_upload_error": "上传图片时出错", - "image_upload_success": "图片上传成功!", - "no_image_url": "在该 URL 中找不到图像。", - "planned": "计划", - "start_before_end_error": "开始日期必须早于结束日期", - "visited": "访问过", - "wiki_image_error": "从维基百科获取图像时出错", - "actions": "行动", - "activity": "活动", - "activity_types": "活动类型", - "add": "添加", - "add_an_activity": "添加活动", - "add_notes": "添加注释", - "adventure_create_error": "未能创造冒险", - "adventure_created": "冒险已创造", - "adventure_update_error": "冒险更新失败", - "adventure_updated": "冒险已更新", - "basic_information": "基本信息", - "category": "类别", - "clear_map": "清晰的地图", - "copy_link": "复制链接", - "date_constrain": "限制收集日期", - "description": "描述", - "end_date": "结束日期", - "fetch_image": "获取图像", - "generate_desc": "生成描述", - "image": "图像", - "image_fetch_failed": "获取图像失败", - "link": "关联", - "location": "地点", - "location_information": "位置信息", - "my_images": "我的图片", - "my_visits": "我的访问", - "new_adventure": "新冒险", - "no_description_found": "没有找到描述", - "no_images": "没有图片", - "no_location": "请输入地点", - "no_results": "没有找到结果", - "no_start_date": "请输入开始日期", - "public_adventure": "公共冒险", - "remove": "消除", - "save_next": "节省", - "search_for_location": "搜索地点", - "search_results": "搜索结果", - "see_adventures": "查看冒险", - "select_adventure_category": "选择冒险类别", - "share_adventure": "分享这个冒险!", - "start_date": "开始日期", - "upload_image": "上传图片", - "upload_images_here": "在这里上传图片", - "url": "网址", - "warning": "警告", - "wiki_desc": "从维基百科文章中提取与冒险名称匹配的摘录。", - "wikipedia": "维基百科", - "adventure_not_found": "没有任何冒险可以展示。\n使用右下角的加号按钮添加一些或尝试更改过滤器!", - "all": "全部", - "error_updating_regions": "更新区域时出错", - "mark_region_as_visited": "将地区 {region}、{country} 标记为已访问?", - "mark_visited": "马克访问过", - "my_adventures": "我的冒险", - "no_adventures_found": "没有发现冒险", - "no_collections_found": "未找到可添加此冒险的集合。", - "no_linkable_adventures": "没有发现任何冒险可以链接到这个集合。", - "not_visited": "未访问过", - "regions_updated": "地区已更新", - "update_visited_regions": "更新访问过的地区", - "update_visited_regions_disclaimer": "这可能需要一段时间,具体取决于您访问过的冒险活动的数量。", - "visited_region_check": "访问地区检查", - "visited_region_check_desc": "通过选择此选项,服务器将检查您访问过的所有冒险活动,并将它们所在的区域标记为在世界旅行中访问过的区域。", - "add_new": "添加新...", - "checklist": "清单", - "checklists": "清单", - "collection_archived": "该藏品已存档。", - "collection_completed": "您已经完成了这个合集!", - "collection_stats": "集合统计", - "days": "天", - "itineary_by_date": "行程(按日期)", - "keep_exploring": "继续探索!", - "link_new": "链接新...", - "linked_adventures": "关联的冒险", - "links": "链接", - "no_end_date": "请输入结束日期", - "note": "笔记", - "notes": "笔记", - "nothing_planned": "这一天没有什么计划。\n祝旅途愉快!", - "transportation": "运输", - "transportations": "交通", - "visit_link": "访问链接", - "day": "天", - "add_a_tag": "添加标签", - "tags": "标签", - "set_to_pin": "设置为固定", - "category_fetch_error": "获取类别时出错", - "copied_to_clipboard": "已复制到剪贴板!", - "copy_failed": "复制失败", - "adventure_calendar": "冒险日历", - "emoji_picker": "表情符号选择器", - "hide": "隐藏", - "show": "展示", - "download_calendar": "下载日历", - "md_instructions": "在这里写下你的标记...", - "preview": "预览", - "checklist_delete_confirm": "您确定要删除此清单吗?\n此操作无法撤消。", - "clear_location": "明确的位置", - "date_information": "日期信息", - "delete_checklist": "删除清单", - "delete_note": "删除注释", - "delete_transportation": "删除交通", - "end": "结尾", - "ending_airport": "结束机场", - "flight_information": "航班信息", - "from": "从", - "no_location_found": "没有找到位置", - "note_delete_confirm": "您确定要删除此注释吗?\n此操作无法撤消。", - "out_of_range": "不在行程日期范围内", - "show_region_labels": "显示区域标签", - "start": "开始", - "starting_airport": "出发机场", - "to": "到", - "transportation_delete_confirm": "您确定要删除此交通工具吗?\n此操作无法撤消。" - }, - "home": { - "desc_1": "轻松发现、规划和探索", - "desc_2": "AdventureLog 旨在简化您的旅程,为您提供工具和资源来计划、打包和导航您的下一次难忘的冒险。", - "feature_1": "旅行日志", - "feature_1_desc": "通过个性化的旅行日志记录您的冒险经历,并与朋友和家人分享您的经历。", - "feature_2": "旅行计划", - "feature_2_desc": "轻松创建自定义行程并获取行程的每日详细信息。", - "feature_3": "旅游地图", - "feature_3_desc": "使用交互式地图查看您在世界各地的旅行并探索新的目的地。", - "go_to": "前往冒险日志", - "hero_1": "探索世界上最惊险的冒险", - "hero_2": "使用 AdventureLog 发现并计划您的下一次冒险。\n探索令人惊叹的目的地、创建定制行程并随时随地保持联系。", - "key_features": "主要特点" - }, "navbar": { "about": "关于冒险日志", "adventures": "冒险", @@ -262,27 +30,297 @@ "tag": "标签", "language_selection": "语言", "support": "支持", - "calendar": "日历" + "calendar": "日历", + "admin_panel": "管理面板" + }, + "about": { + "about": "关于", + "license": "根据 GPL-3.0 许可证授权。", + "message": "在美国用 ❤️ 制作。", + "nominatim_1": "位置搜索和地理编码由", + "nominatim_2": "提供。他们的数据根据ODbL许可证授权。", + "oss_attributions": "开源声明", + "other_attributions": "其他声明可以在 README 文件中找到。", + "source_code": "源代码", + "close": "关闭" + }, + "home": { + "desc_1": "轻松发现、规划和探索", + "desc_2": "AdventureLog 旨在简化您的旅程,为您提供工具和资源来计划、打包和导航您的下一次难忘的冒险。", + "feature_1": "旅行日志", + "feature_1_desc": "通过个性化的旅行日志记录您的冒险经历,并与朋友和家人分享您的经历。", + "feature_2": "旅行计划", + "feature_2_desc": "轻松创建自定义行程并获取行程的每日详细信息。", + "feature_3": "旅游地图", + "feature_3_desc": "使用交互式地图查看您在世界各地的旅行并探索新的目的地。", + "go_to": "前往冒险日志", + "hero_1": "探索世界上最惊险的冒险", + "hero_2": "使用 AdventureLog 发现并计划您的下一次冒险。\n探索令人惊叹的目的地、创建定制行程并随时随地保持联系。", + "key_features": "主要特点" + }, + "adventures": { + "collection_remove_success": "成功从合集中移除冒险!", + "collection_remove_error": "从合集中移除冒险时出错", + "collection_link_success": "成功将冒险链接到合集!", + "no_image_found": "未找到图片", + "collection_link_error": "链接冒险到合集时出错", + "adventure_delete_confirm": "您确定要删除此冒险吗?此操作无法撤销。", + "checklist_delete_confirm": "您确定要删除此检查清单吗?此操作无法撤销。", + "note_delete_confirm": "您确定要删除此笔记吗?此操作无法撤销。", + "transportation_delete_confirm": "您确定要删除此交通工具吗?此操作无法撤销。", + "lodging_delete_confirm": "您确定要删除此住宿地点吗?此操作无法撤销。", + "delete_checklist": "删除检查清单", + "delete_note": "删除笔记", + "delete_transportation": "删除交通工具", + "delete_lodging": "删除住宿", + "open_details": "打开详情", + "edit_adventure": "编辑冒险", + "remove_from_collection": "从合集中移除", + "add_to_collection": "添加到合集", + "delete": "删除", + "not_found": "未找到冒险", + "not_found_desc": "未找到你要查找的冒险。请尝试其他冒险或稍后再试。", + "homepage": "主页", + "adventure_details": "冒险详情", + "collection": "合集", + "adventure_type": "冒险类型", + "longitude": "经度", + "latitude": "纬度", + "visit": "访问", + "visits": "访问次数", + "create_new": "创建新的...", + "adventure": "冒险", + "count_txt": "与您的搜索匹配的结果", + "sort": "排序", + "order_by": "排序依据", + "order_direction": "排序方向", + "ascending": "升序", + "descending": "降序", + "updated": "更新", + "name": "名称", + "date": "日期", + "activity_types": "活动类型", + "tags": "标签", + "add_a_tag": "添加标签", + "date_constrain": "限制在合集日期内", + "rating": "评分", + "my_images": "我的图片", + "add_an_activity": "添加活动", + "show_region_labels": "显示区域标签", + "no_images": "没有图片", + "upload_images_here": "在此上传图片", + "share_adventure": "分享此冒险!", + "copy_link": "复制链接", + "image": "图片", + "upload_image": "上传图片", + "url": "网址", + "fetch_image": "获取图片", + "wikipedia": "维基百科", + "add_notes": "添加笔记", + "warning": "警告", + "my_adventures": "我的冒险", + "no_linkable_adventures": "未找到可以链接到此合集的冒险。", + "add": "添加", + "save_next": "保存并继续", + "end_date": "结束日期", + "my_visits": "我的访问", + "start_date": "开始日期", + "remove": "移除", + "location": "位置", + "search_for_location": "搜索位置", + "clear_map": "清除地图", + "search_results": "搜索结果", + "no_results": "未找到结果", + "wiki_desc": "从与冒险名称匹配的维基百科文章中提取摘录。", + "attachments": "附件", + "attachment": "附件", + "images": "图片", + "primary": "主要", + "view_attachment": "查看附件", + "generate_desc": "生成描述", + "public_adventure": "公开冒险", + "location_information": "位置信息", + "link": "链接", + "links": "链接", + "description": "描述", + "sources": "来源", + "collection_adventures": "包含合集中的冒险", + "filter": "过滤", + "category_filter": "类别过滤器", + "category": "类别", + "select_adventure_category": "选择冒险类别", + "clear": "清除", + "my_collections": "我的合集", + "open_filters": "打开过滤器", + "close_filters": "关闭过滤器", + "archived_collections": "已归档合集", + "share": "分享", + "private": "私密", + "public": "公开", + "archived": "已归档", + "edit_collection": "编辑合集", + "unarchive": "取消归档", + "archive": "归档", + "no_collections_found": "未找到可添加此冒险的合集。", + "not_visited": "未访问", + "archived_collection_message": "成功归档合集!", + "unarchived_collection_message": "成功取消归档合集!", + "delete_collection_success": "成功删除合集!", + "delete_collection_warning": "你确定要删除此合集吗?这将同时删除所有链接的冒险。此操作无法撤销。", + "cancel": "取消", + "of": "/", + "delete_collection": "删除合集", + "delete_adventure": "删除冒险", + "adventure_delete_success": "成功删除冒险!", + "visited": "已访问", + "planned": "计划中", + "duration": "持续时间", + "all": "全部", + "image_removed_success": "成功移除图片!", + "image_removed_error": "移除图片时出错", + "no_image_url": "在该网址找不到图片。", + "image_upload_success": "图片上传成功!", + "image_upload_error": "上传图片时出错", + "dates": "日期", + "wiki_image_error": "从维基百科获取图片时出错", + "start_before_end_error": "开始日期必须在结束日期之前", + "activity": "活动", + "actions": "操作", + "no_end_date": "请输入结束日期", + "see_adventures": "查看冒险", + "image_fetch_failed": "获取图片失败", + "no_location": "请输入位置", + "no_start_date": "请输入开始日期", + "no_description_found": "未找到描述", + "adventure_created": "冒险已创建", + "adventure_create_error": "创建冒险失败", + "lodging": "住宿", + "create_adventure": "创建冒险", + "adventure_updated": "冒险已更新", + "adventure_update_error": "更新冒险失败", + "set_to_pin": "设置为图钉", + "category_fetch_error": "获取类别时出错", + "new_adventure": "新冒险", + "basic_information": "基本信息", + "no_adventures_to_recommendations": "未找到冒险。添加至少一个冒险以获得推荐。", + "display_name": "显示名称", + "adventure_not_found": "没找到任何冒险。使用右下角的加号按钮添加一些,或尝试更改筛选条件!", + "no_adventures_found": "未找到冒险", + "mark_region_as_visited": "将地区 {region}, {country} 标记为已访问?", + "mark_visited": "标记为已访问", + "error_updating_regions": "更新地区时出错", + "regions_updated": "地区已更新", + "cities_updated": "城市已更新", + "visited_region_check": "已访问地区检查", + "visited_region_check_desc": "通过选择此项,服务器将检查您所有已访问的冒险,并将它们所在的地区标记为已访问。", + "update_visited_regions": "更新已访问地区", + "update_visited_regions_disclaimer": "根据您访问的冒险数量,这可能需要一些时间。", + "link_new": "链接新...", + "add_new": "添加新...", + "transportation": "交通", + "note": "笔记", + "checklist": "检查清单", + "collection_archived": "此合集已归档。", + "visit_link": "访问链接", + "collection_completed": "您已完成此合集!", + "collection_stats": "合集统计", + "keep_exploring": "继续探索!", + "linked_adventures": "链接的冒险", + "notes": "笔记", + "checklists": "检查清单", + "transportations": "交通方式", + "adventure_calendar": "冒险日历", + "day": "天", + "itineary_by_date": "行程(按日期", + "nothing_planned": "这一天没有计划。享受旅程!", + "copied_to_clipboard": "已复制到剪贴板!", + "copy_failed": "复制失败", + "show": "显示", + "hide": "隐藏", + "clear_location": "清除位置", + "starting_airport": "起始机场", + "ending_airport": "目的地机场", + "no_location_found": "未找到位置", + "from": "从", + "to": "到", + "will_be_marked": "将在冒险保存后标记为已访问。", + "start": "开始", + "end": "结束", + "show_map": "显示地图", + "emoji_picker": "表情符号选择器", + "download_calendar": "下载日历", + "date_information": "日期信息", + "flight_information": "航班信息", + "out_of_range": "不在行程日期范围内", + "preview": "预览", + "finding_recommendations": "发现你下一次冒险的隐藏宝藏", + "location_details": "位置详情", + "city": "城市", + "region": "地区", + "md_instructions": "可以使用 Markdown 在此输入您的笔记。", + "days": "天", + "attachment_upload_success": "附件上传成功!", + "attachment_upload_error": "上传附件时出错", + "upload": "上传", + "attachment_delete_success": "附件删除成功!", + "attachment_update_success": "附件更新成功!", + "attachment_name": "附件名称", + "gpx_tip": "上传 GPX 文件到附件以便在地图上查看它们!", + "welcome_map_info": "该服务器上的公共冒险", + "attachment_update_error": "更新附件时出错", + "activities": { + "general": "通用 🌍", + "outdoor": "户外 🏞️", + "lodging": "住宿 🛌", + "dining": "餐饮 🍽️", + "activity": "活动 🏄", + "attraction": "景点 🎢", + "shopping": "购物 🛍️", + "nightlife": "夜生活 🌃", + "event": "活动 🎉", + "transportation": "交通 🚗", + "culture": "文化 🎭", + "water_sports": "水上运动 🚤", + "hiking": "徒步旅行 🥾", + "wildlife": "野生动物 🦒", + "historical_sites": "历史遗址 🏛️", + "music_concerts": "音乐与演唱会 🎶", + "fitness": "健身 🏋️", + "art_museums": "艺术与博物馆 🎨", + "festivals": "节日 🎪", + "spiritual_journeys": "精神之旅 🧘‍♀️", + "volunteer_work": "志愿者工作 🤝", + "other": "其他" + }, + "lodging_information": "住宿信息", + "price": "价格", + "reservation_number": "预订号", + "open_in_maps": "在地图上打开" }, "auth": { "forgot_password": "忘记密码?", "login": "登录", "login_error": "无法使用提供的凭据登录。", "password": "密码", - "signup": "报名", + "signup": "注册", "username": "用户名", "confirm_password": "确认密码", "email": "电子邮件", "first_name": "名", "last_name": "姓", - "registration_disabled": "目前已禁用注册。", - "profile_picture": "个人资料图片", + "registration_disabled": "注册当前已禁用。", + "profile_picture": "头像", "public_profile": "公开资料", - "public_tooltip": "通过公开个人资料,用户可以与您共享收藏并在用户页面上查看您的个人资料。", + "public_tooltip": "通过公开个人资料,用户可以与您共享合集,并在用户页面查看您的资料。", "email_required": "电子邮件为必填项", - "both_passwords_required": "两个密码都需要", - "new_password": "新密码", - "reset_failed": "重置密码失败" + "both_passwords_required": "两个密码都是必填项", + "new_password": "新密码(6个字符以上)", + "reset_failed": "重置密码失败", + "or_3rd_party": "或使用第三方服务登录", + "no_public_adventures": "未找到公开冒险", + "no_public_collections": "未找到公开合集", + "user_adventures": "用户冒险", + "user_collections": "用户合集" }, "worldtravel": { "all": "全部", @@ -293,19 +331,32 @@ "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": "未找到具有公开个人资料的用户。" + "no_users_found": "未找到已公开个人资料的用户。" }, "settings": { - "account_settings": "用户帐户设置", + "account_settings": "用户账户设置", "confirm_new_password": "确认新密码", - "current_email": "当前电子邮件", - "email_change": "更改电子邮件", - "new_email": "新电子邮件", + "current_email": "当前邮箱", + "email_change": "更改邮箱", + "new_email": "新邮箱", "new_password": "新密码", - "no_email_set": "没有设置电子邮件", + "no_email_set": "没有设置邮箱", "password_change": "更改密码", "settings_page": "设置页面", "update": "更新", @@ -313,88 +364,106 @@ "update_success": "设置更新成功!", "change_password": "更改密码", "invalid_token": "令牌无效或已过期", - "login_redir": "然后您将被重定向到登录页面。", - "missing_email": "请输入电子邮件地址", + "login_redir": "修改后将跳转至登录页面。", + "missing_email": "请输入邮箱地址", "password_does_not_match": "密码不匹配", "password_is_required": "需要密码", - "possible_reset": "如果您提供的电子邮件地址与帐户关联,您将收到一封电子邮件,其中包含重置密码的说明!", + "possible_reset": "若提供的邮箱与账户关联,您将收到包含重置说明的邮件!", "reset_password": "重置密码", "submit": "提交", - "token_required": "重置密码需要令牌和 UID。", - "about_this_background": "关于这个背景", - "join_discord": "加入不和谐", - "join_discord_desc": "分享您自己的照片。\n将它们张贴在", - "photo_by": "摄影:", + "token_required": "密码重置需提供令牌和用户ID。", + "about_this_background": "关于此背景", + "join_discord": "加入 Discord", + "join_discord_desc": "分享您的照片,请发布到 #travel-share 频道。", + "photo_by": "摄影師:", "change_password_error": "无法更改密码。\n当前密码无效或新密码无效。", "current_password": "当前密码", - "password_change_lopout_warning": "更改密码后您将退出。", + "password_change_lopout_warning": "修改密码后需重新登录。", "authenticator_code": "验证码", "copy": "复制", "disable_mfa": "禁用 MFA", "email_added": "邮箱添加成功!", - "email_added_error": "添加电子邮件时出错", - "email_removed": "电子邮件删除成功!", - "email_removed_error": "删除电子邮件时出错", - "email_set_primary": "成功将电子邮件设置为主!", - "email_set_primary_error": "将电子邮件设置为主要电子邮件时出错", + "email_added_error": "添加邮箱时出错", + "email_removed": "邮箱删除成功!", + "email_removed_error": "删除邮箱时出错", + "email_set_primary": "已设为主邮箱", + "email_set_primary_error": "设置主邮箱时出错", "email_verified": "邮箱验证成功!", - "email_verified_erorr_desc": "无法验证您的电子邮件。\n请再试一次。", - "email_verified_error": "验证电子邮件时出错", - "email_verified_success": "您的电子邮件已被验证。\n您现在可以登录了。", + "email_verified_erorr_desc": "无法验证您的邮箱。\n请再试一次。", + "email_verified_error": "验证邮箱时出错", + "email_verified_success": "您的邮箱已被验证。\n您现在可以登录了。", "enable_mfa": "启用 MFA", - "error_change_password": "更改密码时出错。\n请检查您当前的密码,然后重试。", - "generic_error": "处理您的请求时发生错误。", + "error_change_password": "更改密码时出错。\n请检查当前密码或新密码格式。", + "generic_error": "处理请求时发生错误。", "invalid_code": "MFA 代码无效", "invalid_credentials": "用户名或密码无效", - "make_primary": "设为主要", + "make_primary": "设为主邮箱", "mfa_disabled": "多重身份验证已成功禁用!", "mfa_enabled": "多重身份验证启用成功!", "mfa_not_enabled": "MFA 未启用", "mfa_page_title": "多重身份验证", "mfa_required": "需要多重身份验证", - "no_emai_set": "没有设置电子邮件", + "no_emai_set": "没有设置邮箱", "not_verified": "未验证", - "primary": "基本的", + "primary": "主邮箱", "recovery_codes": "恢复代码", - "recovery_codes_desc": "这些是您的恢复代码。\n确保他们的安全。\n你将无法再见到他们。", - "reset_session_error": "请注销并重新登录以刷新您的会话,然后重试。", + "recovery_codes_desc": "请妥善保存这些恢复码,后续将无法再次查看。", + "reset_session_error": "请重新登录以刷新会话后重试。", "verified": "已验证", - "verify": "核实", - "verify_email_error": "验证电子邮件时出错。\n几分钟后重试。", + "verify": "验证", + "verify_email_error": "验证邮箱时出错。\n几分钟后重试。", "verify_email_success": "邮箱验证发送成功!", - "add_email_blocked": "您无法将电子邮件地址添加到受双因素身份验证保护的帐户。", - "required": "此字段是必需的", + "add_email_blocked": "无法为启用多重身份验证的账户添加邮箱。", + "required": "此字段为必填项", "csrf_failed": "获取 CSRF 令牌失败", - "duplicate_email": "该电子邮件地址已被使用。", - "email_taken": "该电子邮件地址已被使用。", - "username_taken": "该用户名已被使用。" + "duplicate_email": "该邮箱已被使用。", + "email_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 个字符", + "disable_password": "禁用密码", + "password_disable": "禁用密码身份验证", + "password_disable_desc": "禁用密码身份验证将阻止您使用密码登录。\n您将需要使用社交或OIDC提供商登录。如果您的社交提供商未链接,即使禁用了此设置,密码身份验证也将自动重新启用。", + "password_disable_warning": "当前,密码身份验证已禁用。\n需要通过社交或OIDC提供商登录。", + "password_disabled": "密码身份验证禁用", + "password_disabled_error": "错误禁用密码身份验证。\n确保将社交或OIDC提供商链接到您的帐户。", + "password_enabled": "启用密码身份验证", + "password_enabled_error": "启用密码身份验证的错误。" }, "checklist": { "add_item": "添加项目", "checklist_delete_error": "删除清单时出错", "checklist_deleted": "清单删除成功!", "checklist_editor": "清单编辑器", - "checklist_public": "该清单是公开的,因为它属于公共收藏。", - "editing_checklist": "编辑清单", + "checklist_public": "此清单因位于公开合集中而自动公开。", + "editing_checklist": "正在编辑清单", "failed_to_save": "保存清单失败", - "item": "物品", + "item": "项目", "item_already_exists": "项目已存在", - "item_cannot_be_empty": "项目不能为空", + "item_cannot_be_empty": "项目内容不能为空", "items": "项目", - "new_item": "新商品", - "save": "节省", + "new_item": "新项目", + "save": "保存", "checklist_viewer": "清单查看器", - "new_checklist": "新清单" + "new_checklist": "新建清单" }, "collection": { - "collection_created": "收藏创建成功!", + "collection_created": "合集创建成功!", "collection_edit_success": "合集编辑成功!", - "create": "创造", - "edit_collection": "编辑收藏", - "error_creating_collection": "创建集合时出错", - "error_editing_collection": "编辑集合时出错", - "new_collection": "新系列", - "public_collection": "公共收藏" + "create": "创建", + "edit_collection": "编辑合集", + "error_creating_collection": "创建合集时出错", + "error_editing_collection": "编辑合集时出错", + "new_collection": "新建合集", + "public_collection": "公开合集" }, "notes": { "add_a_link": "添加链接", @@ -404,46 +473,49 @@ "note_delete_error": "删除笔记时出错", "note_deleted": "备注删除成功!", "note_editor": "笔记编辑器", - "note_public": "该笔记是公开的,因为它属于公共收藏。", + "note_public": "此笔记因位于公开合集中而自动公开。", "open": "打开", - "save": "节省", + "save": "保存", "invalid_url": "无效网址", "note_viewer": "笔记查看器" }, "transportation": { - "date_and_time": "日期", - "date_time": "开始日期", + "date_and_time": "日期和时间", + "date_time": "开始日期和时间", "edit": "编辑", - "edit_transportation": "编辑交通", + "edit_transportation": "编辑交通信息", "end_date_time": "结束日期", - "error_editing_transportation": "编辑交通时出错", - "flight_number": "航班", - "from_location": "出发地点", + "error_editing_transportation": "编辑交通信息时出错", + "flight_number": "航班号", + "from_location": "出发地", "modes": { "bike": "自行车", "boat": "船", - "bus": "公共汽车", - "car": "车", + "bus": "巴士/公车", + "car": "汽车", "other": "其他", "plane": "飞机", - "train": "火车", + "train": "火车/动车/高铁", "walking": "步行" }, - "new_transportation": "新交通", + "new_transportation": "新交通信息", "provide_start_date": "请提供开始日期", "start": "开始", - "to_location": "前往地点", + "to_location": "目的地", "transport_type": "运输类型", - "transportation_added": "交通添加成功!", - "transportation_delete_error": "删除交通时出错", - "transportation_deleted": "交通删除成功!", - "transportation_edit_success": "交通编辑成功!", - "type": "类型" + "transportation_added": "交通信息添加成功!", + "transportation_delete_error": "删除交通信息时出错", + "transportation_deleted": "交通信息删除成功!", + "transportation_edit_success": "交通信息编辑成功!", + "type": "类型", + "ending_airport_desc": "输入到达机场代码(如:LAX)", + "fetch_location_information": "获取位置信息", + "starting_airport_desc": "输入出发机场代码(如:JFK)" }, "search": { - "adventurelog_results": "冒险日志结果", + "adventurelog_results": "AdventureLog 结果", "online_results": "在线结果", - "public_adventures": "公共冒险" + "public_adventures": "已公开的冒险" }, "map": { "add_adventure": "添加新冒险", @@ -454,34 +526,25 @@ "show_visited_regions": "显示访问过的地区", "view_details": "查看详情" }, - "languages": { - "de": "德语", - "en": "英语", - "es": "西班牙语", - "fr": "法语", - "it": "意大利语", - "nl": "荷兰语", - "sv": "瑞典", - "zh": "中国人", - "pl": "波兰语" - }, + "languages": {}, "share": { "no_users_shared": "没有与之共享的用户", "not_shared_with": "不与共享", - "share_desc": "与其他用户分享此收藏。", + "share_desc": "与其他用户分享此合集。", "shared": "共享", "shared_with": "共享对象", "unshared": "未共享", "with": "和", "go_to_settings": "前往设置", - "no_shared_found": "未找到与您共享的集合。", + "no_shared_found": "未找到与您共享的合集。", "set_public": "为了允许用户与您共享,您需要将您的个人资料设置为公开。" }, "profile": { "member_since": "会员自", "user_stats": "用户统计", "visited_countries": "访问过的国家", - "visited_regions": "访问地区" + "visited_regions": "访问过的地区", + "visited_cities": "访问过的城市" }, "categories": { "category_name": "类别名称", @@ -494,11 +557,74 @@ }, "dashboard": { "add_some": "为什么不开始计划你的下一次冒险呢?\n您可以通过单击下面的按钮添加新的冒险。", - "countries_visited": "访问国家", - "no_recent_adventures": "最近没有冒险吗?", + "countries_visited": "已访问国家数量", + "no_recent_adventures": "最近不去冒险吗?", "recent_adventures": "最近的冒险", - "total_adventures": "全面冒险", - "total_visited_regions": "总访问地区", - "welcome_back": "欢迎回来" + "total_adventures": "冒险总数", + "total_visited_regions": "已访问地区数量", + "welcome_back": "欢迎回来", + "total_visited_cities": "已访问城市数量" + }, + "immich": { + "api_key": "Immich API 密钥", + "api_note": "注意:这必须是 Immich API 服务器的 URL,因此它可能以 /api 结尾,除非您有自定义配置。", + "disable": "禁用", + "enable_immich": "启用 Immich", + "imageid_required": "需要图像 ID", + "immich": "Immich", + "immich_desc": "将您的 Immich 帐户与 AdventureLog 集成,以便您搜索照片库并导入冒险照片。", + "immich_disabled": "Immich 集成成功禁用!", + "immich_enabled": "Immich 集成成功启用!", + "immich_error": "更新 Immich 集成时出错", + "immich_integration": "Immich 整合", + "immich_updated": "Immich 设置更新成功!", + "integration_enabled": "启用集成", + "integration_fetch_error": "从 Immich 集成获取数据时出错", + "integration_missing": "后端缺少 Immich 集成", + "load_more": "加载更多", + "no_items_found": "未找到物品", + "query_required": "需要查询", + "server_down": "Immich 服务器当前已关闭或无法访问", + "server_url": "Immich 服务器网址", + "update_integration": "更新集成", + "documentation": "Immich 集成文档", + "localhost_note": "注意:除非您相应地设置了 docker 网络,否则 localhost 很可能无法工作。\n建议使用服务器的IP地址或域名。" + }, + "recomendations": { + "address": "地址", + "contact": "联系方式", + "phone": "电话", + "recommendation": "推荐", + "website": "网站" + }, + "lodging": { + "campground": "露营地", + "check_in": "入住", + "check_out": "退房", + "date_and_time": "日期和时间", + "edit": "编辑", + "edit_lodging": "编辑住宿", + "error_editing_lodging": "编辑住宿时出错", + "hostel": "旅馆", + "hotel": "酒店", + "house": "房子", + "lodging_added": "住宿添加成功!", + "lodging_delete_error": "删除住宿时出错", + "lodging_deleted": "住宿删除成功!", + "lodging_edit_success": "住宿编辑成功!", + "lodging_type": "住宿类型", + "motel": "汽车旅馆", + "start": "开始", + "type": "类型", + "villa": "别墅", + "apartment": "公寓", + "bnb": "民宿", + "cabin": "仓房", + "new_lodging": "新住宿", + "other": "其他", + "provide_start_date": "请提供开始日期", + "reservation_number": "预订号", + "resort": "度假村", + "current_timezone": "当前时区" } } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3010bca..dc904ea 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -14,8 +14,9 @@ register('nl', () => import('../locales/nl.json')); register('sv', () => import('../locales/sv.json')); register('pl', () => import('../locales/pl.json')); + register('ko', () => import('../locales/ko.json')); - let locales = ['en', 'es', 'fr', 'de', 'it', 'zh', 'nl', 'sv', 'pl']; + let locales = ['en', 'es', 'fr', 'de', 'it', 'zh', 'nl', 'sv', 'pl', 'ko']; if (browser) { init({ diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 8d0446a..6d5b21d 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,6 +1,8 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; import { redirect, type Actions } from '@sveltejs/kit'; -import { themes } from '$lib'; +// @ts-ignore +import psl from 'psl'; +import { getRandomBackground, themes } from '$lib'; import { fetchCSRFToken } from '$lib/index.server'; import type { PageServerLoad } from './$types'; @@ -9,6 +11,13 @@ const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { if (event.locals.user) { return redirect(302, '/dashboard'); + } else { + const background = getRandomBackground(); + return { + props: { + background + } + }; } }) satisfies PageServerLoad; @@ -32,16 +41,43 @@ export const actions: Actions = { return; } - const res = await fetch(`${serverEndpoint}/_allauth/browser/v1/auth/session`, { + const res = await fetch(`${serverEndpoint}/auth/browser/v1/auth/session`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken + 'X-CSRFToken': csrfToken, // Ensure CSRF token is in header + Referer: event.url.origin, // Include Referer header + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}` }, credentials: 'include' }); - if (res.status == 401) { + + // Get the proper cookie domain using psl + const hostname = event.url.hostname; + let cookieDomain; + + // Check if hostname is an IP address + const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + const isLocalhost = hostname === 'localhost'; + const isSingleLabel = hostname.split('.').length === 1; + + if (!isIPAddress && !isSingleLabel && !isLocalhost) { + const parsed = psl.parse(hostname); + + if (parsed && parsed.domain) { + // Use the parsed domain (e.g., mydomain.com) + cookieDomain = `.${parsed.domain}`; + } + } + + // Delete the session cookie + event.cookies.delete('sessionid', { + path: '/', + secure: event.url.protocol === 'https:', + domain: cookieDomain // Undefined for IP addresses, used for domain names + }); + + if (res.status === 401) { return redirect(302, '/login'); } else { return redirect(302, '/'); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9fadd74..b67404a 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,126 +1,117 @@ -
    -
    -
    -
    -
    - {#if data.user} - {#if data.user.first_name && data.user.first_name !== null} -

    - {data.user.first_name.charAt(0).toUpperCase() + data.user.first_name.slice(1)}, - {$t('home.hero_1')} -

    - {:else} -

    - {$t('home.hero_1')} -

    - {/if} - {:else} -

    - {$t('home.hero_1')} -

    - {/if} -

    - {$t('home.hero_2')} -

    -
    -
    - {#if data.user} - - {:else} - - - {/if} -
    + +
    +
    + +
    + {#if data.user} + {#if data.user.first_name && data.user.first_name !== null} +

    + {data.user.first_name.charAt(0).toUpperCase() + data.user.first_name.slice(1)}, {$t( + 'home.hero_1' + )} +

    + {:else} +

    + {$t('home.hero_1')} +

    + {/if} + {:else} +

    + {$t('home.hero_1')} +

    + {/if} +

    + {$t('home.hero_2')} +

    +
    + {#if data.user} + + {:else} + + + {/if}
    +
    + +
    Hero
    -
    -
    -
    -
    -
    - {$t('home.key_features')} -
    -

    - {$t('home.desc_1')} -

    -

    - {$t('home.desc_2')} -

    + + +
    +
    +
    +
    + {$t('home.key_features')}
    +

    + {$t('home.desc_1')} +

    +

    + {$t('home.desc_2')} +

    -
    - - Image -
    -
      -
    • -
      -

      {$t('home.feature_1')}

      -

      - {$t('home.feature_1_desc')} -

      -
      +
      + +
      + World map with pins +
      + +
      +
        +
      • +

        {$t('home.feature_1')}

        +

        + {$t('home.feature_1_desc')} +

      • -
      • -
        -

        {$t('home.feature_2')}

        -

        - {$t('home.feature_2_desc')} -

        -
        +
      • +

        {$t('home.feature_2')}

        +

        + {$t('home.feature_2_desc')} +

      • -
      • -
        -

        {$t('home.feature_3')}

        -

        - {$t('home.feature_3_desc')} -

        -
        +
      • +

        {$t('home.feature_3')}

        +

        + {$t('home.feature_3_desc')} +

      diff --git a/frontend/src/routes/_allauth/[...path]/+server.ts b/frontend/src/routes/_allauth/[...path]/+server.ts deleted file mode 100644 index 681a3fa..0000000 --- a/frontend/src/routes/_allauth/[...path]/+server.ts +++ /dev/null @@ -1,94 +0,0 @@ -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(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 }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} - -export async function PATCH({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} - -export async function PUT({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} - -export async function DELETE({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}` : ''; - return handleRequest(url, params, request, fetch, cookies, searchParam, false); -} - -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}/_allauth/${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 csrfToken = await fetchCSRFToken(); - if (!csrfToken) { - return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); - } - - try { - const response = await fetch(targetUrl, { - method: request.method, - headers: { - ...Object.fromEntries(headers), - 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` - }, - 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) { - return new Response(null, { - status: 204, - headers: response.headers - }); - } - - 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: cleanHeaders - }); - } catch (error) { - console.error('Error forwarding request:', error); - return json({ error: 'Internal Server Error' }, { status: 500 }); - } -} diff --git a/frontend/src/routes/activities/+page.server.ts b/frontend/src/routes/activities/+page.server.ts deleted file mode 100644 index 238e6b4..0000000 --- a/frontend/src/routes/activities/+page.server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { fetchCSRFToken } from '$lib/index.server'; -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; - -export const load = (async (event) => { - if (!event.locals.user) { - return redirect(302, '/login'); - } - let csrfToken = await fetchCSRFToken(); - let allActivities: string[] = []; - let res = await event.fetch(`${endpoint}/api/activity-types/types/`, { - headers: { - 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` - }, - credentials: 'include' - }); - console.log(res); - let data = await res.json(); - if (data) { - allActivities = data; - } - return { - props: { - activities: allActivities - } - }; -}) satisfies PageServerLoad; - -export const actions: Actions = { - getActivities: async (event) => { - let csrfToken = await fetchCSRFToken(); - let allActivities: string[] = []; - let res = await fetch(`${endpoint}/api/activity-types/types/`, { - headers: { - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}` - } - }); - console.log(res); - let data = await res.json(); - if (data) { - allActivities = data; - } - return { activities: allActivities }; - } -}; diff --git a/frontend/src/routes/activities/+page.svelte b/frontend/src/routes/activities/+page.svelte deleted file mode 100644 index 89aa22c..0000000 --- a/frontend/src/routes/activities/+page.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - {#each activities as activity} - - - - - {/each} - -
      {$t('navbar.tag')}{$t('adventures.actions')}
      {activity} - -
      - - - My Tags - - diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..c32b3ba --- /dev/null +++ b/frontend/src/routes/admin/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from '../$types'; +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const load: PageServerLoad = async (event) => { + 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 redirect(302, publicUrl + '/admin/'); +}; diff --git a/frontend/src/routes/adventures/+page.server.ts b/frontend/src/routes/adventures/+page.server.ts index da69fdf..9875399 100644 --- a/frontend/src/routes/adventures/+page.server.ts +++ b/frontend/src/routes/adventures/+page.server.ts @@ -69,11 +69,31 @@ export const actions: Actions = { method: 'POST', headers: { Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`, - 'X-CSRFToken': csrfToken + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header }, body: formData }); let data = await res.json(); return data; + }, + attachment: async (event) => { + let formData = await event.request.formData(); + let csrfToken = await fetchCSRFToken(); + let sessionId = event.cookies.get('sessionid'); + let res = await fetch(`${serverEndpoint}/api/attachments/`, { + method: 'POST', + headers: { + Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`, + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header + }, + body: formData + }); + let data = await res.json(); + + console.log(res); + console.log(data); + return data; } }; diff --git a/frontend/src/routes/adventures/+page.svelte b/frontend/src/routes/adventures/+page.svelte index ea5e415..fa59fcc 100644 --- a/frontend/src/routes/adventures/+page.svelte +++ b/frontend/src/routes/adventures/+page.svelte @@ -261,7 +261,7 @@ {$t('categories.manage_categories')}

      {$t('adventures.sort')}

      diff --git a/frontend/src/routes/adventures/[id]/+page.server.ts b/frontend/src/routes/adventures/[id]/+page.server.ts index bba55aa..eed47ba 100644 --- a/frontend/src/routes/adventures/[id]/+page.server.ts +++ b/frontend/src/routes/adventures/[id]/+page.server.ts @@ -66,7 +66,9 @@ export const actions: Actions = { let res = await fetch(`${serverEndpoint}/api/adventures/${event.params.id}`, { method: 'DELETE', headers: { - Cookie: `sessionid=${event.cookies.get('sessionid')}; csrftoken=${csrfToken}`, + Referer: event.url.origin, // Include Referer header + Cookie: `sessionid=${event.cookies.get('sessionid')}; + csrftoken=${csrfToken}`, 'X-CSRFToken': csrfToken }, credentials: 'include' diff --git a/frontend/src/routes/adventures/[id]/+page.svelte b/frontend/src/routes/adventures/[id]/+page.svelte index f151bb4..674a5f4 100644 --- a/frontend/src/routes/adventures/[id]/+page.svelte +++ b/frontend/src/routes/adventures/[id]/+page.svelte @@ -4,14 +4,74 @@ import type { PageData } from './$types'; import { goto } from '$app/navigation'; import Lost from '$lib/assets/undraw_lost.svg'; - import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre'; + import { DefaultMarker, MapLibre, Popup, GeoJSON, LineLayer } from 'svelte-maplibre'; import { t } from 'svelte-i18n'; import { marked } from 'marked'; // Import the markdown parser + import DOMPurify from 'dompurify'; + // @ts-ignore + import toGeoJSON from '@mapbox/togeojson'; + + import LightbulbOn from '~icons/mdi/lightbulb-on'; + + let geojson: any; const renderMarkdown = (markdown: string) => { - return marked(markdown); + return marked(markdown) as string; }; + async function getGpxFiles() { + let gpxfiles: string[] = []; + + // Collect all GPX file attachments + if (adventure.attachments && adventure.attachments.length > 0) { + gpxfiles = adventure.attachments + .filter((attachment) => attachment.extension === 'gpx') + .map((attachment) => attachment.file); + } + + // Initialize the GeoJSON collection + geojson = { + type: 'FeatureCollection', + features: [] + }; + + // Process each GPX file concurrently + if (gpxfiles.length > 0) { + const promises = gpxfiles.map(async (gpxfile) => { + try { + const gpxFileName = gpxfile.split('/').pop(); + const res = await fetch('/gpx/' + gpxFileName); + + if (!res.ok) { + console.error(`Failed to fetch GPX file: ${gpxFileName}`); + return []; + } + + const gpxData = await res.text(); + const parser = new DOMParser(); + const gpx = parser.parseFromString(gpxData, 'text/xml'); + + // Convert GPX to GeoJSON and return features + const convertedGeoJSON = toGeoJSON.gpx(gpx); + return convertedGeoJSON.features || []; + } catch (error) { + console.error(`Error processing GPX file ${gpxfile}:`, error); + return []; + } + }); + + // Use Promise.allSettled to ensure every promise resolves, + // even if some requests fail. + const results = await Promise.allSettled(promises); + + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value.length > 0) { + geojson.features.push(...result.value); + } + }); + } + } + export let data: PageData; console.log(data); @@ -30,18 +90,32 @@ import ClipboardList from '~icons/mdi/clipboard-list'; import AdventureModal from '$lib/components/AdventureModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; + import AttachmentCard from '$lib/components/AttachmentCard.svelte'; - onMount(() => { + onMount(async () => { if (data.props.adventure) { adventure = data.props.adventure; + // sort so that any image in adventure_images .is_primary is first + adventure.images.sort((a, b) => { + if (a.is_primary && !b.is_primary) { + return -1; + } else if (!a.is_primary && b.is_primary) { + return 1; + } else { + return 0; + } + }); } else { notFound = true; } + await getGpxFiles(); }); - function saveEdit(event: CustomEvent) { + async function saveEdit(event: CustomEvent) { adventure = event.detail; isEditModalOpen = false; + geojson = null; + await getGpxFiles(); } @@ -120,7 +194,10 @@ alt={adventure.name} /> -
      + +
      {#each adventure.images as _, i}
      {/if} +
      @@ -156,6 +234,43 @@
      + {#if adventure.user} +
      + {#if adventure.user.profile_pic} +
      +
      + {adventure.user.username} +
      +
      + {:else} +
      +
      + {adventure.user.first_name + ? adventure.user.first_name.charAt(0) + : adventure.user.username.charAt(0)}{adventure.user.last_name + ? adventure.user.last_name.charAt(0) + : ''} +
      +
      + {/if} + +
      + {#if adventure.user.public_profile} + + {adventure.user.first_name || adventure.user.username}{' '} + {adventure.user.last_name} + + {:else} + + {adventure.user.first_name || adventure.user.username}{' '} + {adventure.user.last_name} + + {/if} +
      +
      + {/if}
      {adventure.is_public ? 'Public' : 'Private'}
      + {#if adventure.location}
      - {@html renderMarkdown(adventure.description)} + {@html DOMPurify.sanitize(renderMarkdown(adventure.description))} {/if}
      @@ -316,60 +432,137 @@
      {/if}
      - {#if adventure.longitude && adventure.latitude} -
      -
      -

      {$t('adventures.latitude')}

      -

      {adventure.latitude}° N

      + {#if (adventure.longitude && adventure.latitude) || geojson} + {#if adventure.longitude && adventure.latitude} +
      +
      +

      {$t('adventures.latitude')}

      +

      {adventure.latitude}° N

      +
      +
      +

      {$t('adventures.longitude')}

      +

      {adventure.longitude}° W

      +
      -
      -

      {$t('adventures.longitude')}

      -

      {adventure.longitude}° W

      -
      -
      + {/if} + {$t('adventures.open_in_maps')} + + {#if geojson} + + + + + {/if} + - - -
      {adventure.name}
      -

      - {adventure.category?.display_name + ' ' + adventure.category?.icon} -

      - {#if adventure.visits.length > 0} -

      - {#each adventure.visits as visit} - {visit.start_date - ? new Date(visit.start_date).toLocaleDateString(undefined, { - timeZone: 'UTC' - }) - : ''} - {visit.end_date && - visit.end_date !== '' && - visit.end_date !== visit.start_date - ? ' - ' + - new Date(visit.end_date).toLocaleDateString(undefined, { - timeZone: 'UTC' - }) - : ''} -
      - {/each} + {#if adventure.longitude && adventure.latitude} + + +

      {adventure.name}
      +

      + {adventure.category?.display_name + ' ' + adventure.category?.icon}

      - {/if} -
      -
      + {#if adventure.visits.length > 0} +

      + {#each adventure.visits as visit} + {visit.start_date + ? new Date(visit.start_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} + {visit.end_date && + visit.end_date !== '' && + visit.end_date !== visit.start_date + ? ' - ' + + new Date(visit.end_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} +
      + {/each} +

      + {/if} + + + {/if}
      {/if}
      + {#if adventure.attachments && adventure.attachments.length > 0} +
      + +

      + {$t('adventures.attachments')} +
      + +
      +

      + +
      + {#if adventure.attachments && adventure.attachments.length > 0} +
      + {#each adventure.attachments as attachment} + + {/each} +
      + {/if} +
      +
      + {/if} + {#if adventure.images && adventure.images.length > 0} +
      +

      {$t('adventures.images')}

      +
      + {#if adventure.images && adventure.images.length > 0} +
      + {#each adventure.images as image} +
      + + + + +
      (image_url = image.image)} + >
      + {#if image.is_primary} +
      + {$t('adventures.primary')} +
      + {/if} +
      + {/each} +
      + {/if} +
      +
      + {/if}
    diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 33c2e2a..815d4a7 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -12,7 +12,7 @@ export async function GET(event) { /** @type {import('./$types').RequestHandler} */ export async function POST({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + const searchParam = url.search ? `${url.search}` : ''; return handleRequest(url, params, request, fetch, cookies, searchParam, true); } @@ -53,18 +53,25 @@ async function handleRequest( const headers = new Headers(request.headers); + // Delete existing csrf cookie by setting an expired date + cookies.delete('csrftoken', { path: '/' }); + + // Generate a new csrf token (using your existing fetchCSRFToken function) const csrfToken = await fetchCSRFToken(); if (!csrfToken) { return json({ error: 'CSRF token is missing or invalid' }, { status: 400 }); } + // Set the new csrf token in both headers and cookies + const cookieHeader = `csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax`; + try { const response = await fetch(targetUrl, { method: request.method, headers: { ...Object.fromEntries(headers), 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: cookieHeader }, body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, diff --git a/frontend/src/routes/auth/[...path]/+server.ts b/frontend/src/routes/auth/[...path]/+server.ts index 7e0c8b0..dc608a1 100644 --- a/frontend/src/routes/auth/[...path]/+server.ts +++ b/frontend/src/routes/auth/[...path]/+server.ts @@ -1,69 +1,86 @@ 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}` : ''; + 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}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } export async function PATCH({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } export async function PUT({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } export async function DELETE({ url, params, request, fetch, cookies }) { - return handleRequest(url, params, request, fetch, cookies, true); + const searchParam = url.search ? `${url.search}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } -// 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}`; - if (requreTrailingSlash && !targetUrl.endsWith('/')) { + const add_trailing_slash_list = ['disable-password']; + + // Ensure the path ends with a trailing slash + if ((requreTrailingSlash && !targetUrl.endsWith('/')) || add_trailing_slash_list.includes(path)) { targetUrl += '/'; } + // Append query parameters to the path correctly + targetUrl += searchParam; // This will add or 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 +88,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); diff --git a/frontend/src/routes/calendar/+page.svelte b/frontend/src/routes/calendar/+page.svelte index 50987c2..99f233a 100644 --- a/frontend/src/routes/calendar/+page.svelte +++ b/frontend/src/routes/calendar/+page.svelte @@ -33,6 +33,6 @@
    Download Calendar{$t('adventures.download_calendar')}
    diff --git a/frontend/src/routes/collections/+page.server.ts b/frontend/src/routes/collections/+page.server.ts index f88e5ee..20e2c40 100644 --- a/frontend/src/routes/collections/+page.server.ts +++ b/frontend/src/routes/collections/+page.server.ts @@ -96,6 +96,7 @@ export const actions: Actions = { method: 'POST', headers: { 'X-CSRFToken': csrfToken, + Referer: event.url.origin, // Include Referer header Cookie: `sessionid=${sessionid}; csrftoken=${csrfToken}` }, body: formDataToSend @@ -174,9 +175,11 @@ export const actions: Actions = { method: 'PATCH', headers: { 'X-CSRFToken': csrfToken, - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}` + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + Referer: event.url.origin // Include Referer header }, body: formDataToSend, + credentials: 'include' }); diff --git a/frontend/src/routes/collections/[id]/+page.server.ts b/frontend/src/routes/collections/[id]/+page.server.ts index bf54a5b..f672eed 100644 --- a/frontend/src/routes/collections/[id]/+page.server.ts +++ b/frontend/src/routes/collections/[id]/+page.server.ts @@ -63,7 +63,8 @@ export const actions: Actions = { headers: { Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header }, credentials: 'include' }); diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index c4107b8..a63dbe5 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -1,5 +1,5 @@ {#if isShowingLinkModal} @@ -276,6 +428,15 @@ /> {/if} +{#if isShowingLodgingModal} + (isShowingLodgingModal = false)} + on:save={saveOrCreateLodging} + {collection} + /> +{/if} + {#if isAdventureModalOpen} {$t('adventures.checklist')} + - {/if} @@ -546,6 +721,23 @@
    {/if} + {#if lodging.length > 0} +

    {$t('adventures.lodging')}

    +
    + {#each lodging as hotel} + { + lodging = lodging.filter((t) => t.id != event.detail); + }} + on:edit={editLodging} + {collection} + /> + {/each} +
    + {/if} + {#if notes.length > 0}

    {$t('adventures.notes')}

    @@ -587,7 +779,7 @@ {/if} - {#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0} + {#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0 && lodging.length == 0} {/if} {/if} @@ -635,6 +827,10 @@ new Date(collection.start_date), numberOfDays )[dateString] || []} + {@const dayLodging = + groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays)[ + dateString + ] || []} {@const dayNotes = groupNotesByDate(notes, new Date(collection.start_date), numberOfDays)[dateString] || []} @@ -696,6 +892,18 @@ /> {/each} {/if} + {#if dayLodging.length > 0} + {#each dayLodging as hotel} + { + lodging = lodging.filter((t) => t.id != event.detail); + }} + on:edit={editLodging} + /> + {/each} + {/if} {#if dayChecklists.length > 0} {#each dayChecklists as checklist} - {#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0} + {#if dayAdventures.length == 0 && dayTransportations.length == 0 && dayNotes.length == 0 && dayChecklists.length == 0 && dayLodging.length == 0}

    {$t('adventures.nothing_planned')}

    {/if}
    @@ -734,25 +942,68 @@ > {#each adventures as adventure} {#if adventure.longitude && adventure.latitude} - - -
    {adventure.name}
    -

    - {adventure.category?.display_name + ' ' + adventure.category?.icon} -

    -
    -
    + + + {adventure.category?.icon} + + {#if isPopupOpen} + (isPopupOpen = false)}> + {#if adventure.images && adventure.images.length > 0} + + {/if} +
    {adventure.name}
    +

    + {adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')} +

    +

    + {adventure.category?.display_name + ' ' + adventure.category?.icon} +

    + {#if adventure.visits && adventure.visits.length > 0} +

    + {#each adventure.visits as visit} + {visit.start_date + ? new Date(visit.start_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} + {visit.end_date && + visit.end_date !== '' && + visit.end_date !== visit.start_date + ? ' - ' + + new Date(visit.end_date).toLocaleDateString(undefined, { + timeZone: 'UTC' + }) + : ''} +
    + {/each} +

    + {/if} + +
    + {/if} +
    {/if} {/each} {#each transportations as transportation} - {#if transportation.destination_latitude && transportation.destination_longitude} + {#if transportation.origin_latitude && transportation.origin_longitude && transportation.destination_latitude && transportation.destination_longitude} + {getTransportationEmoji(transportation.type)} @@ -764,15 +1015,15 @@

    - {/if} - {#if transportation.origin_latitude && transportation.origin_longitude} + + {getTransportationEmoji(transportation.type)} @@ -784,6 +1035,57 @@

    + + + + + + {/if} + {/each} + + {#each lodging as hotel} + {#if hotel.longitude && hotel.latitude} + + + {getLodgingIcon(hotel.type)} + + +
    {hotel.name}
    +

    + {hotel.type} +

    +
    +
    {/if} {/each} @@ -800,6 +1102,188 @@
    {/if} + {#if currentView == 'recommendations' && data.user} +
    +
    +

    Adventure Recommendations

    + {#each adventures as adventure} + {#if adventure.longitude && adventure.latitude} + + {/if} + {/each} + {#if adventures.length == 0} +
    +

    {$t('adventures.no_adventures_to_recommendations')}

    +
    + {/if} +
    + +
    + {Math.round(recomendationsRange / 1600)} mile ({( + (recomendationsRange / 1600) * + 1.6 + ).toFixed(1)} km) +
    +
    + (recomendationType = 'tourism')} + /> + (recomendationType = 'food')} + /> + (recomendationType = 'lodging')} + /> +
    + {#if recomendationTags.length > 0} + + {/if} +
    + + {#if recomendationsData} + + {#each filteredRecomendations as recomendation} + {#if recomendation.longitude && recomendation.latitude && recomendation.name} + + + {osmTagToEmoji(recomendation.tag)} + + {#if isPopupOpen} + (isPopupOpen = false)}> +
    {recomendation.name}
    + +

    + {`${recomendation.tag} ${osmTagToEmoji(recomendation.tag)}`} +

    + + + +
    + {/if} +
    + {/if} + {/each} +
    + {#each filteredRecomendations as recomendation} + {#if recomendation.name && recomendation.longitude && recomendation.latitude} +
    +
    +

    + {recomendation.name || $t('recomendations.recommendation')} +

    +
    {recomendation.tag}
    + {#if recomendation.address} +

    + {$t('recomendations.address')}: + {recomendation.address.housenumber} + {recomendation.address.street}, {recomendation.address.city}, {recomendation + .address.state} + {recomendation.address.postcode} +

    + {/if} + {#if recomendation.contact} +

    + {$t('recomendations.contact')}: + {#if recomendation.contact.phone} + {$t('recomendations.phone')}: {recomendation.contact.phone} + {/if} + {#if recomendation.contact.email} + {$t('auth.email')}: {recomendation.contact.email} + {/if} + {#if recomendation.contact.website} + {$t('recomendations.website')}: + {recomendation.contact.website} + {/if} +

    + {/if} + +
    +
    + {/if} + {/each} + {/if} + {#if loadingRecomendations} +
    +
    +
    + +
    +

    + {$t('adventures.finding_recommendations')}... +

    +
    +
    +
    +
    + {/if} +
    +
    + {/if} {/if} diff --git a/frontend/src/routes/dashboard/+page.server.ts b/frontend/src/routes/dashboard/+page.server.ts index 5db5aef..c73b3c0 100644 --- a/frontend/src/routes/dashboard/+page.server.ts +++ b/frontend/src/routes/dashboard/+page.server.ts @@ -20,11 +20,14 @@ export const load = (async (event) => { let stats = null; - let res = await event.fetch(`${serverEndpoint}/api/stats/counts/`, { - headers: { - Cookie: `sessionid=${event.cookies.get('sessionid')}` + let res = await event.fetch( + `${serverEndpoint}/api/stats/counts/${event.locals.user.username}/`, + { + headers: { + Cookie: `sessionid=${event.cookies.get('sessionid')}` + } } - }); + ); if (!res.ok) { console.error('Failed to fetch user stats'); } else { diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 00139e5..905493d 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -2,12 +2,54 @@ import AdventureCard from '$lib/components/AdventureCard.svelte'; import type { PageData } from './$types'; import { t } from 'svelte-i18n'; + import { onMount } from 'svelte'; + import { gsap } from 'gsap'; + // Initial animation for page load + onMount(() => { + // Stat animations with quicker duration + gsap.from('.stat', { + opacity: 0, + y: 50, + duration: 0.6, // Quicker animation duration + stagger: 0.1, // Faster staggering between elements + ease: 'power2.out' // Slightly sharper easing for quicker feel + }); + + gsap.from('.stat-title', { + opacity: 0, + x: -50, // Smaller movement for quicker animation + duration: 0.6, // Quicker animation duration + stagger: 0.1, // Faster staggering + ease: 'power2.out' // Slightly sharper easing for quicker feel + }); + + // Stat values with faster reveal and snappier effect + gsap.from('.stat-value', { + opacity: 0, + scale: 0.8, // Slightly less scaling for a snappier effect + duration: 1, // Shorter duration + stagger: 0.2, // Faster staggering + ease: 'elastic.out(0.75, 0.5)', // Slightly snappier bounce + delay: 0 // Faster delay for quicker sequencing + }); + + // Adventure card animations with quicker reveal + gsap.from('.adventure-card', { + opacity: 0, + y: 50, // Less movement for snappier feel + duration: 0.8, // Quicker duration + stagger: 0.1, // Faster staggering + ease: 'power2.out', + delay: 0 // Shorter delay for quicker appearance + }); + }); export let data: PageData; import FlagCheckeredVariantIcon from '~icons/mdi/flag-checkered-variant'; import Airplane from '~icons/mdi/airplane'; import CityVariantOutline from '~icons/mdi/city-variant-outline'; + import MapMarkerStarOutline from '~icons/mdi/map-marker-star-outline'; const user = data.user; const recentAdventures = data.props.adventures; @@ -17,18 +59,13 @@
    -

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

    +

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

    -
    -
    - -
    -
    {$t('dashboard.countries_visited')}
    -
    {stats.country_count}
    -
    @@ -36,13 +73,27 @@
    {$t('dashboard.total_adventures')}
    {stats.adventure_count}
    +
    +
    + +
    +
    {$t('dashboard.countries_visited')}
    +
    {stats.visited_country_count}
    +
    - +
    {$t('dashboard.total_visited_regions')}
    {stats.visited_region_count}
    +
    +
    + +
    +
    {$t('dashboard.total_visited_cities')}
    +
    {stats.visited_city_count}
    +
    @@ -50,7 +101,9 @@

    {$t('dashboard.recent_adventures')}

    {#each recentAdventures as adventure} - +
    + +
    {/each}
    {/if} @@ -58,7 +111,7 @@ {#if recentAdventures.length === 0}

    {$t('dashboard.no_recent_adventures')}

    diff --git a/frontend/src/routes/gpx/[file]/+server.ts b/frontend/src/routes/gpx/[file]/+server.ts new file mode 100644 index 0000000..9edaa38 --- /dev/null +++ b/frontend/src/routes/gpx/[file]/+server.ts @@ -0,0 +1,22 @@ +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +/** @type {import('./$types').RequestHandler} */ +export async function GET(event) { + let sessionid = event.cookies.get('sessionid'); + let fileName = event.params.file; + let res = await fetch(`${endpoint}/media/attachments/${fileName}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${sessionid}` + } + }); + let data = await res.text(); + return new Response(data, { + status: res.status, + headers: { + 'Content-Type': 'application/xml' + } + }); +} diff --git a/frontend/src/routes/immich/[key]/+server.ts b/frontend/src/routes/immich/[key]/+server.ts new file mode 100644 index 0000000..33d33de --- /dev/null +++ b/frontend/src/routes/immich/[key]/+server.ts @@ -0,0 +1,54 @@ +import type { RequestHandler } from './$types'; + +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; +const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + +export const GET: RequestHandler = async (event) => { + try { + const key = event.params.key; + + // Forward the session ID from cookies + const sessionid = event.cookies.get('sessionid'); + if (!sessionid) { + return new Response(JSON.stringify({ error: 'Session ID is missing' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Proxy the request to the backend + const res = await fetch(`${endpoint}/api/integrations/immich/get/${key}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${sessionid}` + } + }); + + if (!res.ok) { + // Return an error response if the backend request fails + const errorData = await res.json(); + return new Response(JSON.stringify(errorData), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get the image as a Blob + const image = await res.blob(); + + // Create a Response to pass the image back + return new Response(image, { + status: res.status, + headers: { + 'Content-Type': res.headers.get('Content-Type') || 'image/jpeg' + } + }); + } catch (error) { + console.error('Error proxying request:', error); + return new Response(JSON.stringify({ error: 'Failed to fetch image' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 1b50518..25a5e95 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -1,9 +1,11 @@ import { fail, redirect, type RequestEvent } from '@sveltejs/kit'; - +// @ts-ignore +import psl from 'psl'; 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 +14,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 } }; } @@ -33,12 +42,13 @@ export const actions: Actions = { const csrfToken = await fetchCSRFToken(); // Initial login attempt - const loginFetch = await event.fetch(`${serverEndpoint}/_allauth/browser/v1/auth/login`, { + const loginFetch = await event.fetch(`${serverEndpoint}/auth/browser/v1/auth/login`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}` + Cookie: `csrftoken=${csrfToken}`, + Referer: event.url.origin // Include Referer header }, body: JSON.stringify({ username, password }), credentials: 'include' @@ -59,13 +69,14 @@ export const actions: Actions = { // Attempt MFA authentication const sessionId = extractSessionId(loginFetch.headers.get('Set-Cookie')); const mfaLoginFetch = await event.fetch( - `${serverEndpoint}/_allauth/browser/v1/auth/2fa/authenticate`, + `${serverEndpoint}/auth/browser/v1/auth/2fa/authenticate`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}` + Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`, + Referer: event.url.origin // Include Referer header }, body: JSON.stringify({ code: totp }), credentials: 'include' @@ -102,12 +113,33 @@ function handleSuccessfulLogin(event: RequestEvent, respo const match = setCookieHeader.match(sessionIdRegex); if (match) { const [, sessionId, expiryString] = match; + + // Get the proper cookie domain using psl + const hostname = event.url.hostname; + let cookieDomain; + + // Check if hostname is an IP address + const isIPAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + const isLocalhost = hostname === 'localhost'; + const isSingleLabel = hostname.split('.').length === 1; + + if (!isIPAddress && !isSingleLabel && !isLocalhost) { + const parsed = psl.parse(hostname); + + if (parsed && parsed.domain) { + // Use the parsed domain (e.g., mydomain.com) + cookieDomain = `.${parsed.domain}`; + } + } + // Do not set a domain for IP addresses or invalid hostnames + event.cookies.set('sessionid', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', - secure: true, - expires: new Date(expiryString) + secure: event.url.protocol === 'https:', + expires: new Date(expiryString), + domain: cookieDomain // Set the domain dynamically or omit if undefined }); } } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3bf6c98..bfccb5f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -9,14 +9,42 @@ 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 { gsap } from 'gsap'; // Import GSAP + import { onMount } from 'svelte'; + + onMount(() => { + gsap.from('.card', { + opacity: 0, + y: 50, + duration: 1, + ease: 'power3.out' + }); + gsap.from('.text-center', { + opacity: 0, + x: -50, + duration: 1, + ease: 'power2.out' + }); + gsap.from('.input', { + opacity: 0, + y: 30, + duration: 1, + ease: 'power2.out' + }); + }); import ImageInfoModal from '$lib/components/ImageInfoModal.svelte'; import type { Background } from '$lib/types.js'; - let quote: { quote: string; author: string } = data.props.quote; + let quote: { quote: string; author: string } = data.props?.quote ?? { quote: '', author: '' }; - let background: Background = data.props.background; + let background: Background = data.props?.background ?? { url: '' }; {#if isImageInfoModalOpen} @@ -52,16 +80,35 @@ class="block input input-bordered w-full max-w-xs" />
    {#if $page.form?.mfa_required} - +
    {/if} + {#if socialProviders.length > 0} +

    {$t('auth.or_3rd_party')}
    + + {/if} +

    {$t('auth.signup')}

    diff --git a/frontend/src/routes/map/+page.svelte b/frontend/src/routes/map/+page.svelte index f550760..b240606 100644 --- a/frontend/src/routes/map/+page.svelte +++ b/frontend/src/routes/map/+page.svelte @@ -10,6 +10,8 @@ let createModalOpen: boolean = false; let showGeo: boolean = false; + export let initialLatLng: { lat: number; lng: number } | null = null; + let visitedRegions: VisitedRegion[] = data.props.visitedRegions; let adventures: Adventure[] = data.props.adventures; @@ -49,6 +51,11 @@ newLatitude = e.detail.lngLat.lat; } + function newAdventure() { + initialLatLng = { lat: newLatitude, lng: newLongitude } as { lat: number; lng: number }; + createModalOpen = true; + } + function createNewAdventure(event: CustomEvent) { adventures = [...adventures, event.detail]; newMarker = null; @@ -86,7 +93,7 @@ />

    {#if newMarker} - +
    + {#if adventure.longitude && adventure.latitude} + {$t('adventures.open_in_maps')} + {/if} + +
    {/if} @@ -197,7 +214,7 @@ - Travel Map + Adventure Map diff --git a/frontend/src/routes/profile/+page.server.ts b/frontend/src/routes/profile/+page.server.ts deleted file mode 100644 index 825a867..0000000 --- a/frontend/src/routes/profile/+page.server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad, RequestEvent } from '../$types'; -const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; - -export const load: PageServerLoad = async (event: RequestEvent) => { - const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; - if (!event.locals.user || !event.cookies.get('sessionid')) { - return redirect(302, '/login'); - } - - let sessionId = event.cookies.get('sessionid'); - let stats = null; - - let res = await event.fetch(`${endpoint}/api/stats/counts/`, { - headers: { - Cookie: `sessionid=${sessionId}` - } - }); - if (!res.ok) { - console.error('Failed to fetch user stats'); - } else { - stats = await res.json(); - } - - return { - user: event.locals.user, - stats - }; -}; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte deleted file mode 100644 index 8722c26..0000000 --- a/frontend/src/routes/profile/+page.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
    -
    - - {#if data.user.profile_pic} -
    -
    - Profile -
    -
    - {/if} - - - {#if data.user && data.user.first_name && data.user.last_name} -

    - {data.user.first_name} - {data.user.last_name} -

    - {/if} -

    {data.user.username}

    - - - {#if data.user && data.user.date_joined} -
    -

    {$t('profile.member_since')}

    -
    - -

    - {new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })} -

    -
    -
    - {/if} -
    - - - {#if stats} -
    - -

    - {$t('profile.user_stats')} -

    - -
    -
    -
    -
    {$t('navbar.adventures')}
    -
    {stats.adventure_count}
    -
    - -
    -
    {$t('navbar.collections')}
    -
    {stats.trips_count}
    -
    - -
    -
    {$t('profile.visited_countries')}
    -
    - {Math.round((stats.country_count / stats.total_countries) * 100)}% -
    -
    - {stats.country_count}/{stats.total_countries} -
    -
    - -
    -
    {$t('profile.visited_regions')}
    -
    - {Math.round((stats.visited_region_count / stats.total_regions) * 100)}% -
    -
    - {stats.visited_region_count}/{stats.total_regions} -
    -
    -
    -
    - {/if} -
    - - - Profile | AdventureLog - - diff --git a/frontend/src/routes/profile/[uuid]/+page.server.ts b/frontend/src/routes/profile/[uuid]/+page.server.ts new file mode 100644 index 0000000..9cccd6f --- /dev/null +++ b/frontend/src/routes/profile/[uuid]/+page.server.ts @@ -0,0 +1,49 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad, RequestEvent } from '../../$types'; +import { t } from 'svelte-i18n'; +const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; + +export const load: PageServerLoad = async (event: RequestEvent) => { + const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; + + // @ts-ignore + let username = event.params.uuid as string; + + if (!username) { + return error(404, 'Not found'); + } + + // let sessionId = event.cookies.get('sessionid'); + let stats = null; + + let res = await event.fetch(`${endpoint}/api/stats/counts/${username}`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${event.cookies.get('sessionid')}` + } + }); + if (!res.ok) { + console.error('Failed to fetch user stats'); + } else { + stats = await res.json(); + } + + let userData = await event.fetch(`${endpoint}/auth/user/${username}/`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${event.cookies.get('sessionid')}` + } + }); + if (!userData.ok) { + return error(404, 'Not found'); + } + + let data = await userData.json(); + + return { + user: data.user, + adventures: data.adventures, + collections: data.collections, + stats: stats + }; +}; diff --git a/frontend/src/routes/profile/[uuid]/+page.svelte b/frontend/src/routes/profile/[uuid]/+page.svelte new file mode 100644 index 0000000..02d82c9 --- /dev/null +++ b/frontend/src/routes/profile/[uuid]/+page.svelte @@ -0,0 +1,184 @@ + + +
    +
    + + {#if user.profile_pic} +
    +
    + Profile +
    +
    + {:else} + +
    +
    + {#if user.first_name && user.last_name} + Profile + {:else} + Profile + {/if} +
    +
    + {/if} + + + {#if user && user.first_name && user.last_name} +

    + {user.first_name} + {user.last_name} +

    + {/if} +

    {user.username}

    + + + {#if user && user.date_joined} +
    +

    {$t('profile.member_since')}

    +
    + +

    + {new Date(user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })} +

    +
    +
    + {/if} +
    + + + {#if stats} +
    + +

    + {$t('profile.user_stats')} +

    + +
    +
    +
    +
    {$t('navbar.adventures')}
    +
    {stats.adventure_count}
    +
    + +
    +
    {$t('navbar.collections')}
    +
    {stats.trips_count}
    +
    + +
    +
    {$t('profile.visited_countries')}
    +
    + {stats.visited_country_count} +
    +
    + {Math.round((stats.visited_country_count / stats.total_countries) * 100)}% {$t( + 'adventures.of' + )} + {stats.total_countries} +
    +
    + +
    +
    {$t('profile.visited_regions')}
    +
    + {stats.visited_region_count} +
    +
    + {Math.round((stats.visited_region_count / stats.total_regions) * 100)}% {$t( + 'adventures.of' + )} + {stats.total_regions} +
    +
    + +
    +
    {$t('profile.visited_cities')}
    +
    + {stats.visited_city_count} +
    +
    + {Math.round((stats.visited_city_count / stats.total_cities) * 100)}% {$t( + 'adventures.of' + )} + {stats.total_cities} +
    +
    +
    +
    + {/if} + + +
    + +

    + {$t('auth.user_adventures')} +

    + + {#if adventures && adventures.length === 0} +

    + {$t('auth.no_public_adventures')} +

    + {:else} +
    + {#each adventures as adventure} + + {/each} +
    + {/if} + + +
    + +

    + {$t('auth.user_collections')} +

    + + {#if collections && collections.length === 0} +

    + {$t('auth.no_public_collections')} +

    + {:else} +
    + {#each collections as collection} + + {/each} +
    + {/if} +
    + + + {user.first_name || user.username}'s Profile | AdventureLog + + diff --git a/frontend/src/routes/search/+page.server.ts b/frontend/src/routes/search/+page.server.ts index 8ff7e23..45838a8 100644 --- a/frontend/src/routes/search/+page.server.ts +++ b/frontend/src/routes/search/+page.server.ts @@ -1,14 +1,15 @@ -import type { Adventure, OpenStreetMapPlace } from '$lib/types'; -import { fail } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { appVersion } from '$lib/config'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; export const load = (async (event) => { + if (!event.locals.user) { + return redirect(302, '/login'); + } + const query = event.url.searchParams.get('query'); - const property = event.url.searchParams.get('property') || 'all'; if (!query) { return { data: [] }; @@ -16,15 +17,12 @@ export const load = (async (event) => { let sessionId = event.cookies.get('sessionid'); - let res = await fetch( - `${serverEndpoint}/api/adventures/search/?query=${query}&property=${property}`, - { - headers: { - 'Content-Type': 'application/json', - Cookie: `sessionid=${sessionId}` - } + let res = await fetch(`${serverEndpoint}/api/search/?query=${query}`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `sessionid=${sessionId}` } - ); + }); if (!res.ok) { console.error('Failed to fetch search data'); @@ -32,27 +30,16 @@ export const load = (async (event) => { return { error: error.error }; } - let adventures: Adventure[] = await res.json(); - - let osmRes = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, { - headers: { - 'User-Agent': `AdventureLog / ${appVersion} ` - } - }); - - if (!osmRes.ok) { - console.error('Failed to fetch OSM data'); - let error = await res.json(); - return { error: error.error }; - } - - let osmData = (await osmRes.json()) as OpenStreetMapPlace[]; + let data = await res.json(); return { - props: { - adventures, - query, - osmData - } + adventures: data.adventures, + collections: data.collections, + users: data.users, + countries: data.countries, + regions: data.regions, + cities: data.cities, + visited_cities: data.visited_cities, + visited_regions: data.visited_regions }; }) satisfies PageServerLoad; diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index d85902f..1e96cbb 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -1,185 +1,104 @@ -{#if isAdventureModalOpen} - (isAdventureModalOpen = false)} - on:save={filterByProperty} - /> -{/if} +

    Search{query ? `: ${query}` : ''}

    -{#if myAdventures.length === 0 && osmResults.length === 0} - -{/if} - -{#if myAdventures.length !== 0} -

    {$t('search.adventurelog_results')}

    -
    -
    - (property = 'all')} - /> - (property = 'name')} - /> - (property = 'location')} - /> - (property = 'description')} - /> - (property = 'activity_types')} - /> -
    - -
    -{/if} - -{#if myAdventures.length > 0} -

    {$t('adventures.my_adventures')}

    -
    - {#each myAdventures as adventure} - +{#if adventures.length > 0} +

    Adventures

    +
    + {#each adventures as adventure} + {/each}
    {/if} -{#if publicAdventures.length > 0} -

    {$t('search.public_adventures')}

    -
    - {#each publicAdventures as adventure} - +{#if collections.length > 0} +

    Collections

    +
    + {#each collections as collection} + {/each}
    {/if} -{#if myAdventures.length > 0 && osmResults.length > 0 && publicAdventures.length > 0} -
    -{/if} -{#if osmResults.length > 0} -

    {$t('search.online_results')}

    -
    - {#each osmResults as result} -
    -

    {result.display_name}

    -

    {result.type}

    -

    {result.lat}, {result.lon}

    -
    + +{#if countries.length > 0} +

    Countries

    +
    + {#each countries as country} + {/each}
    {/if} +{#if regions.length > 0} +

    Regions

    +
    + {#each regions as region} + vr.region === region.id)} /> + {/each} +
    +{/if} + +{#if cities.length > 0} +

    Cities

    +
    + {#each cities as city} + vc.city === city.id)} /> + {/each} +
    +{/if} + +{#if users.length > 0} +

    Users

    +
    + {#each users as user} + + {/each} +
    +{/if} + +{#if adventures.length === 0 && regions.length === 0 && cities.length === 0 && countries.length === 0 && collections.length === 0 && users.length === 0} +

    + {$t('adventures.no_results')} +

    +{/if} + - Search{query ? `: ${query}` : ''} - + Search: {query} + diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index beb8432..0d6bd26 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -1,7 +1,7 @@ import { fail, redirect, type Actions } from '@sveltejs/kit'; import type { PageServerLoad } from '../$types'; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; -import type { User } from '$lib/types'; +import type { ImmichIntegration, User } from '$lib/types'; import { fetchCSRFToken } from '$lib/index.server'; const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; @@ -31,7 +31,7 @@ export const load: PageServerLoad = async (event) => { }); let user = (await res.json()) as User; - let emailFetch = await fetch(`${endpoint}/_allauth/browser/v1/account/email`, { + let emailFetch = await fetch(`${endpoint}/auth/browser/v1/account/email`, { headers: { Cookie: `sessionid=${sessionId}` } @@ -45,22 +45,48 @@ export const load: PageServerLoad = async (event) => { return redirect(302, '/'); } - let mfaAuthenticatorFetch = await fetch( - `${endpoint}/_allauth/browser/v1/account/authenticators`, - { - headers: { - Cookie: `sessionid=${sessionId}` - } + let mfaAuthenticatorFetch = await fetch(`${endpoint}/auth/browser/v1/account/authenticators`, { + headers: { + Cookie: `sessionid=${sessionId}` } - ); + }); let mfaAuthenticatorResponse = (await mfaAuthenticatorFetch.json()) as MFAAuthenticatorResponse; let authenticators = (mfaAuthenticatorResponse.data.length > 0) as boolean; + let immichIntegration: ImmichIntegration | null = null; + let immichIntegrationsFetch = await fetch(`${endpoint}/api/integrations/immich/`, { + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + if (immichIntegrationsFetch.ok) { + immichIntegration = await immichIntegrationsFetch.json(); + } + + let socialProvidersFetch = await fetch(`${endpoint}/auth/social-providers`, { + headers: { + Cookie: `sessionid=${sessionId}` + } + }); + let socialProviders = await socialProvidersFetch.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 + authenticators, + immichIntegration, + publicUrl, + socialProviders } }; }; @@ -86,7 +112,8 @@ export const actions: Actions = { const resCurrent = await fetch(`${endpoint}/auth/user-metadata/`, { headers: { - Cookie: `sessionid=${sessionId}` + Cookie: `sessionid=${sessionId}`, + Referer: event.url.origin // Include Referer header } }); @@ -137,6 +164,7 @@ export const actions: Actions = { let res = await fetch(`${endpoint}/auth/update-user/`, { method: 'PATCH', headers: { + Referer: event.url.origin, // Include Referer header Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, 'X-CSRFToken': csrfToken }, @@ -168,33 +196,59 @@ 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(); - let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - current_password, - new_password: password1 - }) - }); - if (!res.ok) { - return fail(res.status, { message: 'settings.error_change_password' }); + if (current_password) { + let res = await fetch(`${endpoint}/auth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Referer: event.url.origin, // Include Referer header + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password, + new_password: password1 + }) + }); + if (!res.ok) { + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; + } else { + let res = await fetch(`${endpoint}/auth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Referer: event.url.origin, // Include Referer header + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + new_password: password1 + }) + }); + if (!res.ok) { + console.log('Error:', await res.json()); + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; } - return { success: true }; }, changeEmail: async (event) => { if (!event.locals.user) { @@ -213,6 +267,7 @@ export const actions: Actions = { let res = await fetch(`${endpoint}/auth/change-email/`, { method: 'POST', headers: { + Referer: event.url.origin, // Include Referer header Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0c0b10a..1ca20db 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -2,14 +2,16 @@ import { enhance } from '$app/forms'; import { page } from '$app/stores'; import { addToast } from '$lib/toasts'; - import type { User } from '$lib/types.js'; + import type { ImmichIntegration, User } from '$lib/types.js'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; import { t } from 'svelte-i18n'; import TotpModal from '$lib/components/TOTPModal.svelte'; import { appTitle, appVersion } from '$lib/config.js'; + import ImmichLogo from '$lib/assets/immich.svg'; export let data; + console.log(data); let user: User; let emails: typeof data.props.emails; if (data.user) { @@ -17,7 +19,17 @@ emails = data.props.emails; } + let new_password_disable_setting: boolean = false; + let new_email: string = ''; + let public_url: string = data.props.publicUrl; + let immichIntegration = data.props.immichIntegration; + + let newImmichIntegration: ImmichIntegration = { + server_url: '', + api_key: '', + id: '' + }; let isMFAModalOpen: boolean = false; @@ -51,14 +63,17 @@ }); let data = await res.json(); if (res.ok) { - addToast('success', `${data.new_regions} ${$t('adventures.regions_updated')}`); + addToast( + 'success', + `${data.new_regions} ${$t('adventures.regions_updated')}. ${data.new_cities} ${$t('adventures.cities_updated')}.` + ); } else { addToast('error', $t('adventures.error_updating_regions')); } } async function removeEmail(email: { email: any; verified?: boolean; primary?: boolean }) { - let res = await fetch('/_allauth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'DELETE', headers: { 'Content-Type': 'application/json' @@ -73,8 +88,38 @@ } } + async function disablePassword() { + if (user.disable_password) { + let res = await fetch('/auth/disable-password/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + addToast('success', $t('settings.password_disabled')); + } else { + addToast('error', $t('settings.password_disabled_error')); + user.disable_password = false; + } + } else { + let res = await fetch('/auth/disable-password/', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + addToast('success', $t('settings.password_enabled')); + } else { + addToast('error', $t('settings.password_enabled_error')); + user.disable_password = true; + } + } + } + async function verifyEmail(email: { email: any; verified?: boolean; primary?: boolean }) { - let res = await fetch('/_allauth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'PUT', headers: { 'Content-Type': 'application/json' @@ -89,7 +134,7 @@ } async function addEmail() { - let res = await fetch('/_allauth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -108,7 +153,7 @@ } async function primaryEmail(email: { email: any; verified?: boolean; primary?: boolean }) { - let res = await fetch('/_allauth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'PATCH', headers: { 'Content-Type': 'application/json' @@ -131,8 +176,56 @@ } } + async function enableImmichIntegration() { + if (!immichIntegration?.id) { + let res = await fetch('/api/integrations/immich/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newImmichIntegration) + }); + let data = await res.json(); + if (res.ok) { + addToast('success', $t('immich.immich_enabled')); + immichIntegration = data; + } else { + addToast('error', $t('immich.immich_error')); + } + } else { + let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newImmichIntegration) + }); + let data = await res.json(); + if (res.ok) { + addToast('success', $t('immich.immich_updated')); + immichIntegration = data; + } else { + addToast('error', $t('immich.immich_error')); + } + } + } + + async function disableImmichIntegration() { + if (immichIntegration && immichIntegration.id) { + let res = await fetch(`/api/integrations/immich/${immichIntegration.id}/`, { + method: 'DELETE' + }); + if (res.ok) { + addToast('success', $t('immich.immich_disabled')); + immichIntegration = null; + } else { + addToast('error', $t('immich.immich_error')); + } + } + } + async function disableMfa() { - const res = await fetch('/_allauth/browser/v1/account/authenticators/totp', { + const res = await fetch('/auth/browser/v1/account/authenticators/totp', { method: 'DELETE' }); if (res.ok) { @@ -174,7 +267,9 @@ class="space-y-6" >
    - +
    - +
    - +
    - + - +
    @@ -239,20 +342,24 @@
    -
    - - -
    + {#if user.has_password} +
    + + +
    + {/if}
    - +
    -
    + {#if $page.form?.message} +
    + {$t($page.form?.message)} +
    + {/if}
    {/each} {#if emails.length === 0} -

    {$t('settings.no_email_set')}

    +

    {$t('settings.no_email_set')}

    {/if}
    - + - +
    @@ -342,10 +454,16 @@
    {#if !data.props.authenticators} -

    {$t('settings.mfa_not_enabled')}

    - +

    {$t('settings.mfa_not_enabled')}

    + {#if !emails.some((e) => e.verified)} +
    + {$t('settings.no_verified_email_warning')} +
    + {:else} + + {/if} {:else}
    + + {#if user.is_staff} +
    +

    + {$t('settings.administration_settings')} +

    + +
    + {/if} + + +
    +

    {$t('settings.social_oidc_auth')}

    +
    +

    + {$t('settings.social_auth_desc')} +

    + + {$t('settings.launch_account_connections')} + + {#if data.props.socialProviders && data.props.socialProviders.length > 0} +
    +

    {$t('settings.password_disable')}

    +

    {$t('settings.password_disable_desc')}

    + +
    + + + + {#if user.disable_password} +
    {$t('settings.password_disabled')}
    + {/if} + {#if !user.disable_password} +
    {$t('settings.password_enabled')}
    + {/if} + {#if user.disable_password} +
    + {$t('settings.password_disable_warning')} +
    + {/if} +
    +
    + {/if} +
    +
    + + +
    +

    + {$t('immich.immich_integration')} + Immich +

    +
    +

    + {$t('immich.immich_desc')} + {$t('immich.documentation')} +

    + {#if immichIntegration} +
    +
    {$t('immich.integration_enabled')}
    +
    + + +
    +
    + {/if} + {#if !immichIntegration || newImmichIntegration.id} +
    +
    + + + {#if newImmichIntegration.server_url && !newImmichIntegration.server_url.endsWith('api')} +

    + {$t('immich.api_note')} +

    + {/if} + {#if newImmichIntegration.server_url && (newImmichIntegration.server_url.indexOf('localhost') !== -1 || newImmichIntegration.server_url.indexOf('127.0.0.1') !== -1)} +

    + {$t('immich.localhost_note')} +

    + {/if} +
    +
    + + +
    + +
    + {/if} +
    +
    +

    {$t('adventures.visited_region_check')}

    diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts index 4a1d857..1e39414 100644 --- a/frontend/src/routes/signup/+page.server.ts +++ b/frontend/src/routes/signup/+page.server.ts @@ -51,12 +51,13 @@ export const actions: Actions = { const tokenPromise = await csrfTokenFetch.json(); const csrfToken = tokenPromise.csrfToken; - const loginFetch = await event.fetch(`${serverEndpoint}/_allauth/browser/v1/auth/signup`, { + const loginFetch = await event.fetch(`${serverEndpoint}/auth/browser/v1/auth/signup`, { method: 'POST', headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}` + Cookie: `csrftoken=${csrfToken}`, + Referer: event.url.origin // Include Referer header }, body: JSON.stringify({ username: username, @@ -93,7 +94,7 @@ export const actions: Actions = { path: '/', httpOnly: true, sameSite: 'lax', - secure: true, + secure: event.url.protocol === 'https:', expires: expiryDate }); } diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte index 6643e06..ecbcced 100644 --- a/frontend/src/routes/signup/+page.svelte +++ b/frontend/src/routes/signup/+page.svelte @@ -4,6 +4,29 @@ export let data; console.log(data); + import { gsap } from 'gsap'; // Import GSAP + import { onMount } from 'svelte'; + + onMount(() => { + gsap.from('.card', { + opacity: 0, + y: 50, + duration: 1, + ease: 'power3.out' + }); + gsap.from('.text-center', { + opacity: 0, + x: -50, + duration: 1, + ease: 'power2.out' + }); + gsap.from('.input', { + opacity: 0, + y: 30, + duration: 1, + ease: 'power2.out' + }); + }); import FileImageBox from '~icons/mdi/file-image-box'; diff --git a/frontend/src/routes/user/reset-password/+page.server.ts b/frontend/src/routes/user/reset-password/+page.server.ts index f91db59..3c52a54 100644 --- a/frontend/src/routes/user/reset-password/+page.server.ts +++ b/frontend/src/routes/user/reset-password/+page.server.ts @@ -16,12 +16,13 @@ export const actions: Actions = { let csrfToken = await fetchCSRFToken(); - let res = await fetch(`${endpoint}/_allauth/browser/v1/auth/password/request`, { + let res = await fetch(`${endpoint}/auth/browser/v1/auth/password/request`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, - Cookie: `csrftoken=${csrfToken}` + Cookie: `csrftoken=${csrfToken}`, + Referer: event.url.origin // Include Referer header }, body: JSON.stringify({ email diff --git a/frontend/src/routes/user/reset-password/[key]/+page.server.ts b/frontend/src/routes/user/reset-password/[key]/+page.server.ts index 2db51f6..03bd9f4 100644 --- a/frontend/src/routes/user/reset-password/[key]/+page.server.ts +++ b/frontend/src/routes/user/reset-password/[key]/+page.server.ts @@ -29,19 +29,17 @@ export const actions: Actions = { const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const csrfToken = await fetchCSRFToken(); - const response = await event.fetch( - `${serverEndpoint}/_allauth/browser/v1/auth/password/reset`, - { - headers: { - 'Content-Type': 'application/json', - Cookie: `csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken - }, - method: 'POST', - credentials: 'include', - body: JSON.stringify({ key: key, password: password }) - } - ); + const response = await event.fetch(`${serverEndpoint}/auth/browser/v1/auth/password/reset`, { + headers: { + 'Content-Type': 'application/json', + Cookie: `csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + Referer: event.url.origin // Include Referer header + }, + method: 'POST', + credentials: 'include', + body: JSON.stringify({ key: key, password: password }) + }); if (response.status !== 401) { const error_message = await response.json(); diff --git a/frontend/src/routes/user/verify-email/[key]/+page.server.ts b/frontend/src/routes/user/verify-email/[key]/+page.server.ts index d9b641b..1898d86 100644 --- a/frontend/src/routes/user/verify-email/[key]/+page.server.ts +++ b/frontend/src/routes/user/verify-email/[key]/+page.server.ts @@ -11,7 +11,7 @@ export const load = (async (event) => { const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const csrfToken = await fetchCSRFToken(); - let verifyFetch = await event.fetch(`${serverEndpoint}/_allauth/browser/v1/auth/email/verify`, { + let verifyFetch = await event.fetch(`${serverEndpoint}/auth/browser/v1/auth/email/verify`, { headers: { Cookie: `csrftoken=${csrfToken}`, 'X-CSRFToken': csrfToken diff --git a/frontend/src/routes/worldtravel/+page.server.ts b/frontend/src/routes/worldtravel/+page.server.ts index ec696bf..b84ae39 100644 --- a/frontend/src/routes/worldtravel/+page.server.ts +++ b/frontend/src/routes/worldtravel/+page.server.ts @@ -30,80 +30,3 @@ export const load = (async (event) => { } } }) satisfies PageServerLoad; - -export const actions: Actions = { - markVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.regionId) { - return { - status: 400 - }; - } - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ region: body.regionId }) - }); - - if (!res.ok) { - console.error('Failed to mark country as visited'); - return { status: 500 }; - } else { - return { - status: 200, - data: await res.json() - }; - } - }, - removeVisited: async (event) => { - const body = await event.request.json(); - - if (!body || !body.visitId) { - return { - status: 400 - }; - } - - const visitId = body.visitId as number; - - let sessionId = event.cookies.get('sessionid'); - - if (!event.locals.user || !sessionId) { - return redirect(302, '/login'); - } - - let csrfToken = await fetchCSRFToken(); - - const res = await fetch(`${endpoint}/api/visitedregion/${visitId}/`, { - method: 'DELETE', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - } - }); - - if (res.status !== 204) { - console.error('Failed to remove country from visited'); - return { status: 500 }; - } else { - return { - status: 200 - }; - } - } -}; diff --git a/frontend/src/routes/worldtravel/+page.svelte b/frontend/src/routes/worldtravel/+page.svelte index d27a676..dad43a1 100644 --- a/frontend/src/routes/worldtravel/+page.svelte +++ b/frontend/src/routes/worldtravel/+page.svelte @@ -1,8 +1,10 @@ -

    Regions in {country?.name}

    +

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

    -
    Region Stats
    -
    {numVisitedRegions}/{numRegions} Visited
    +
    {$t('worldtravel.region_stats')}
    +
    {numVisitedRegions}/{numRegions} {$t('adventures.visited')}
    {#if numRegions === numVisitedRegions} -
    You've visited all regions in {country?.name} 🎉!
    +
    {$t('worldtravel.all_visited')} {country?.name} 🎉!
    {:else} -
    Keep exploring!
    +
    {$t('adventures.keep_exploring')}
    {/if}
    +
    + + {#if searchQuery.length > 0} + +
    + +
    + {/if} +
    +
    - {#each regions as region} + {#each filteredRegions as region} visitedRegion.region === region.id)} @@ -110,8 +129,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}
    @@ -161,7 +184,6 @@ {/if} {/each} -
    diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts new file mode 100644 index 0000000..b92b47a --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.server.ts @@ -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; diff --git a/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte new file mode 100644 index 0000000..d5a8ea8 --- /dev/null +++ b/frontend/src/routes/worldtravel/[id]/[id]/+page.svelte @@ -0,0 +1,173 @@ + + +

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

    + +

    + {allCities.length} + Cities Found +

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

    {$t('worldtravel.no_cities_found')}

    +{/if} + + + Cities in {data.props?.region.name} | World Travel + + diff --git a/frontend/src/service-worker/indes.ts b/frontend/src/service-worker/indes.ts new file mode 100644 index 0000000..5a4466b --- /dev/null +++ b/frontend/src/service-worker/indes.ts @@ -0,0 +1,60 @@ +/// + +import { build, files, version } from '$service-worker'; + +const CACHE = `cache-${version}`; + +const ASSETS = [ + ...build, // the app itself + ...files // everything in `static` +]; + +self.addEventListener('install', (event) => { + // Create a new cache and add all files to it + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + event.waitUntil(addFilesToCache()); +}); + +self.addEventListener('activate', (event) => { + // Remove previous cached data from disk + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + event.waitUntil(deleteOldCaches()); +}); + +self.addEventListener('fetch', (event) => { + // ignore POST requests, etc + if (event.request.method !== 'GET') return; + + async function respond() { + const url = new URL(event.request.url); + const cache = await caches.open(CACHE); + + // `build`/`files` can always be served from the cache + if (ASSETS.includes(url.pathname)) { + return cache.match(url.pathname); + } + + // for everything else, try the network first, but + // fall back to the cache if we're offline + try { + const response = await fetch(event.request); + + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + + return response; + } catch { + return cache.match(event.request); + } + } + + event.respondWith(respond()); +}); diff --git a/frontend/static/adventurelog.svg b/frontend/static/adventurelog.svg new file mode 100644 index 0000000..92667f2 --- /dev/null +++ b/frontend/static/adventurelog.svg @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/static/backgrounds/adventurelog_showcase_6.webp b/frontend/static/backgrounds/adventurelog_showcase_6.webp new file mode 100644 index 0000000..1cdf2c1 Binary files /dev/null and b/frontend/static/backgrounds/adventurelog_showcase_6.webp differ diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json new file mode 100644 index 0000000..9f08d31 --- /dev/null +++ b/frontend/static/manifest.json @@ -0,0 +1,16 @@ +{ + "short_name": "AdventureLog", + "name": "AdventureLog", + "start_url": "/dashboard", + "icons": [ + { + "src": "adventurelog.svg", + "type": "image/svg+xml", + "sizes": "any" + } + ], + "background_color": "#2a323c", + "display": "standalone", + "scope": "/", + "description": "Self-hostable travel tracker and trip planner." +} \ No newline at end of file diff --git a/screenshots/adventures.png b/screenshots/adventures.png deleted file mode 100644 index f49fbc3..0000000 Binary files a/screenshots/adventures.png and /dev/null differ diff --git a/screenshots/countries.png b/screenshots/countries.png deleted file mode 100644 index 7b37ec8..0000000 Binary files a/screenshots/countries.png and /dev/null differ diff --git a/screenshots/details.png b/screenshots/details.png deleted file mode 100644 index 0dee567..0000000 Binary files a/screenshots/details.png and /dev/null differ diff --git a/screenshots/edit.png b/screenshots/edit.png deleted file mode 100644 index 8aea3a7..0000000 Binary files a/screenshots/edit.png and /dev/null differ diff --git a/screenshots/itinerary.png b/screenshots/itinerary.png deleted file mode 100644 index e6107a6..0000000 Binary files a/screenshots/itinerary.png and /dev/null differ diff --git a/screenshots/map.png b/screenshots/map.png deleted file mode 100644 index e52799b..0000000 Binary files a/screenshots/map.png and /dev/null differ diff --git a/screenshots/regions.png b/screenshots/regions.png deleted file mode 100644 index f04a37f..0000000 Binary files a/screenshots/regions.png and /dev/null differ