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/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 4df60b0..3b48223 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software de - 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/server/.env.example b/backend/server/.env.example index 4c1f9ad..598aeb7 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -25,10 +25,10 @@ EMAIL_BACKEND='console' # ------------------- # # For Developers to start a Demo Database -# docker run --name postgres-admin -e POSTGRES_USER=admin -e POSTGRES_PASSWORD=admin -e POSTGRES_DB=admin -p 5432:5432 -d postgis/postgis:15-3.3 +# 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='admin' +# 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/migrations/0001_initial.py b/backend/server/achievements/migrations/0001_initial.py new file mode 100644 index 0000000..e38e509 --- /dev/null +++ b/backend/server/achievements/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.8 on 2025-02-04 04:34 + +import django.db.models.deletion +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='Achievement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('description', models.TextField()), + ('icon', models.ImageField(blank=True, null=True, upload_to='achievements/')), + ('condition', models.JSONField()), + ], + ), + migrations.CreateModel( + name='UserAchievement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('earned_at', models.DateTimeField(auto_now_add=True)), + ('achievement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='achievements.achievement')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'achievement')}, + }, + ), + ] diff --git a/backend/server/achievements/migrations/__init__.py b/backend/server/achievements/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/achievements/models.py b/backend/server/achievements/models.py new file mode 100644 index 0000000..8659bc2 --- /dev/null +++ b/backend/server/achievements/models.py @@ -0,0 +1,33 @@ +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) # Used for frontend lookups, e.g. "achievements.first_adventure" + type = models.CharField(max_length=255) # 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 a1a1101..51c9bac 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -1,8 +1,8 @@ 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, Attachment -from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity +from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Hotel +from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity from allauth.account.decorators import secure_admin_login admin.autodiscover() @@ -140,6 +140,7 @@ admin.site.register(Category, CategoryAdmin) admin.site.register(City, CityAdmin) admin.site.register(VisitedCity) admin.site.register(Attachment) +admin.site.register(Hotel) admin.site.site_header = 'AdventureLog Admin' admin.site.site_title = 'AdventureLog Admin Site' 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/models.py b/backend/server/adventures/models.py index af0d7b9..96d439b 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -318,4 +318,37 @@ 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 Hotel(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) + 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('Hotels associated with a public collection must be public. Collection: ' + self.collection.name + ' Hotel: ' + self.name) + if self.user_id != self.collection.user_id: + raise ValidationError('Hotels must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Hotel owner: ' + self.user_id.username) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index a7c0bd2..5771c7c 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,8 +1,9 @@ from django.utils import timezone import os -from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment +from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Hotel from rest_framework import serializers from main.utils import CustomModelSerializer +from users.serializers import CustomUserDetailsSerializer class AdventureImageSerializer(CustomModelSerializer): @@ -80,15 +81,16 @@ class AdventureSerializer(CustomModelSerializer): 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', 'attachments' + '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): @@ -126,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(): @@ -197,6 +203,17 @@ class TransportationSerializer(CustomModelSerializer): ] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] +class HotelSerializer(CustomModelSerializer): + + class Meta: + model = Hotel + 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' + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] + class NoteSerializer(CustomModelSerializer): class Meta: @@ -283,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') + hotels = HotelSerializer(many=True, read_only=True, source='hotel_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', 'hotels'] read_only_fields = ['id', 'created_at', 'updated_at', 'user_id'] def to_representation(self, instance): @@ -296,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 16ab3b5..3c7eff8 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -18,6 +18,7 @@ router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-cale router.register(r'overpass', OverpassViewSet, basename='overpass') router.register(r'search', GlobalSearchView, basename='search') router.register(r'attachments', AttachmentViewSet, basename='attachments') +router.register(r'hotels', HotelViewSet, basename='hotels') urlpatterns = [ diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 171df52..957af52 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -12,4 +12,5 @@ from .reverse_geocode_view import * from .stats_view import * from .transportation_view import * from .global_search_view import * -from .attachment_view import * \ No newline at end of file +from .attachment_view import * +from .hotel_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/adventure_view.py b/backend/server/adventures/views/adventure_view.py index 5249e00..71be405 100644 --- a/backend/server/adventures/views/adventure_view.py +++ b/backend/server/adventures/views/adventure_view.py @@ -1,3 +1,4 @@ +from django.utils import timezone from django.db import transaction from django.core.exceptions import PermissionDenied from django.db.models import Q, Max @@ -5,7 +6,6 @@ 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 from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import AdventureSerializer @@ -98,9 +98,17 @@ class AdventureViewSet(viewsets.ModelViewSet): user_id=request.user.id ) - if is_visited.lower() in ['true', 'false']: - is_visited_bool = is_visited.lower() == 'true' - queryset = queryset.filter(is_visited=is_visited_bool) + is_visited_param = request.query_params.get('is_visited') + if is_visited_param is not None: + # Convert is_visited_param to a boolean + is_visited_bool = (is_visited_param.lower() == 'true') + + # Filter logic: "visited" means at least one visit with start_date <= today + now = timezone.now().date() + if is_visited_bool: + queryset = queryset.filter(visits__start_date__lte=now).distinct() + else: + queryset = queryset.exclude(visits__start_date__lte=now).distinct() queryset = self.apply_sorting(queryset) return self.paginate_and_respond(queryset, request) diff --git a/backend/server/adventures/views/hotel_view.py b/backend/server/adventures/views/hotel_view.py new file mode 100644 index 0000000..4f5b0eb --- /dev/null +++ b/backend/server/adventures/views/hotel_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 Hotel +from adventures.serializers import HotelSerializer +from rest_framework.exceptions import PermissionDenied +from adventures.permissions import IsOwnerOrSharedWithFullAccess +from rest_framework.permissions import IsAuthenticated + +class HotelViewSet(viewsets.ModelViewSet): + queryset = Hotel.objects.all() + serializer_class = HotelSerializer + permission_classes = [IsOwnerOrSharedWithFullAccess] + + def list(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return Response(status=status.HTTP_403_FORBIDDEN) + queryset = Hotel.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 Hotel.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 Hotel.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/stats_view.py b/backend/server/adventures/views/stats_view.py index 23733a5..37d9f07 100644 --- a/backend/server/adventures/views/stats_view.py +++ b/backend/server/adventures/views/stats_view.py @@ -2,29 +2,42 @@ 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. """ - permission_classes = [IsAuthenticated] + @action(detail=False, methods=['get'], url_path='counts/(?P[^/.]+)') + 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 - @action(detail=False, methods=['get']) - def counts(self, request): + # get the counts for the user adventure_count = Adventure.objects.filter( - user_id=request.user.id).count() + user_id=user.id).count() trips_count = Collection.objects.filter( - user_id=request.user.id).count() + user_id=user.id).count() visited_city_count = VisitedCity.objects.filter( - user_id=request.user.id).count() + user_id=user.id).count() total_cities = City.objects.count() visited_region_count = VisitedRegion.objects.filter( - user_id=request.user.id).count() + user_id=user.id).count() total_regions = Region.objects.count() visited_country_count = VisitedRegion.objects.filter( - user_id=request.user.id).values('region__country').distinct().count() + user_id=user.id).values('region__country').distinct().count() total_countries = Country.objects.count() return Response({ 'adventure_count': adventure_count, diff --git a/backend/server/integrations/views.py b/backend/server/integrations/views.py index 935e28c..dbf383a 100644 --- a/backend/server/integrations/views.py +++ b/backend/server/integrations/views.py @@ -63,25 +63,31 @@ class ImmichIntegrationView(viewsets.ViewSet): return integration query = request.query_params.get('query', '') + date = request.query_params.get('date', '') - if not query: + if not query and not date: return Response( { - 'message': 'Query is required.', + '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: - immich_fetch = requests.post(f'{integration.server_url}/search/smart', headers={ + url = f'{integration.server_url}/search/{"smart" if query else "metadata"}' + immich_fetch = requests.post(url, headers={ 'x-api-key': integration.api_key }, - json = { - 'query': query - } + json = arguments ) res = immich_fetch.json() except requests.exceptions.ConnectionError: @@ -219,10 +225,14 @@ class ImmichIntegrationView(viewsets.ViewSet): ) if 'assets' in res: - return Response( - res['assets'], - status=status.HTTP_200_OK - ) + 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( { diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index c83c711..1c894c5 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = ( 'users', 'integrations', 'django.contrib.gis', + 'achievements', # 'widget_tweaks', # 'slippers', @@ -128,7 +129,7 @@ USE_L10N = True USE_TZ = True unParsedFrontenedUrl = getenv('FRONTEND_URL', 'http://localhost:3000') -FRONTEND_URL = unParsedFrontenedUrl.replace("'", "").replace('"', '') +FRONTEND_URL = unParsedFrontenedUrl.translate(str.maketrans('', '', '\'"')) SESSION_COOKIE_SAMESITE = None @@ -301,5 +302,5 @@ LOGGING = { }, }, } -# https://github.com/dr5hn/countries-states-cities-database/tags -COUNTRY_REGION_JSON_VERSION = 'v2.5' \ No newline at end of file + +ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app') \ No newline at end of file diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index b7bb2a1..60ce08e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -22,7 +22,7 @@ urlpatterns = [ 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'), diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index 0e0828f..81bacd6 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -118,5 +118,7 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): # 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/views.py b/backend/server/users/views.py index b03760e..0d0fca1 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -11,6 +11,8 @@ 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 User = get_user_model() @@ -79,12 +81,27 @@ 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) + # 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] diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py index f5c5702..9930b8e 100644 --- a/backend/server/worldtravel/management/commands/download-countries.py +++ b/backend/server/worldtravel/management/commands/download-countries.py @@ -8,7 +8,7 @@ import ijson from django.conf import settings -COUNTRY_REGION_JSON_VERSION = settings.COUNTRY_REGION_JSON_VERSION +ADVENTURELOG_CDN_URL = settings.ADVENTURELOG_CDN_URL media_root = settings.MEDIA_ROOT @@ -27,7 +27,7 @@ def saveCountryFlag(country_code): print(f'Flag for {country_code} already exists') return - res = requests.get(f'https://flagcdn.com/h240/{country_code}.png'.lower()) + res = requests.get(f'{ADVENTURELOG_CDN_URL}/data/flags/{country_code}.png'.lower()) if res.status_code == 200: with open(flag_path, 'wb') as f: f.write(res.content) @@ -39,30 +39,56 @@ class Command(BaseCommand): help = 'Imports the world travel data' def add_arguments(self, parser): - parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file') + parser.add_argument('--force', action='store_true', help='Force re-download of AdventureLog setup content from the CDN') 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')) + current_version_json = os.path.join(settings.MEDIA_ROOT, 'data_version.json') + try: + cdn_version_json = requests.get(f'{ADVENTURELOG_CDN_URL}/data/version.json') + cdn_version_json.raise_for_status() + cdn_version = cdn_version_json.json().get('version') + if os.path.exists(current_version_json): + with open(current_version_json, 'r') as f: + local_version = f.read().strip() + self.stdout.write(self.style.SUCCESS(f'Local version: {local_version}')) else: - self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json')) + local_version = None + + if force or local_version != cdn_version: + with open(current_version_json, 'w') as f: + f.write(cdn_version) + self.stdout.write(self.style.SUCCESS('Version updated successfully to ' + cdn_version)) + else: + self.stdout.write(self.style.SUCCESS('Data is already up-to-date. Run with --force to re-download')) return - elif not os.path.isfile(countries_json_path): - self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file')) + except requests.RequestException as e: + self.stdout.write(self.style.ERROR(f'Error fetching version from the CDN: {e}, skipping data import. Try restarting the container once CDN connection has been restored.')) 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.')) + + self.stdout.write(self.style.SUCCESS('Fetching latest data from the AdventureLog CDN located at: ' + ADVENTURELOG_CDN_URL)) + + # Delete the existing flags + flags_dir = os.path.join(media_root, 'flags') + if os.path.exists(flags_dir): + for file in os.listdir(flags_dir): + os.remove(os.path.join(flags_dir, file)) + + # Delete the existing countries, regions, and cities json files + countries_json_path = os.path.join(media_root, 'countries_states_cities.json') + if os.path.exists(countries_json_path): + os.remove(countries_json_path) + self.stdout.write(self.style.SUCCESS('countries_states_cities.json deleted successfully')) + + # Download the latest countries, regions, and cities json file + res = requests.get(f'{ADVENTURELOG_CDN_URL}/data/countries_states_cities.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_states_cities.json downloaded successfully')) else: - self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.')) + self.stdout.write(self.style.ERROR('Error downloading countries_states_cities.json')) return with open(countries_json_path, 'r') as f: 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.yml b/docker-compose.yml index eca6a8c..562bd27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: web: - #build: ./frontend/ - image: ghcr.io/seanmorley15/adventurelog-frontend:latest + build: ./frontend/ + #image: ghcr.io/seanmorley15/adventurelog-frontend:latest container_name: adventurelog-frontend restart: unless-stopped environment: @@ -25,8 +25,8 @@ services: - postgres_data:/var/lib/postgresql/data/ server: - #build: ./backend/ - image: ghcr.io/seanmorley15/adventurelog-backend:latest + build: ./backend/ + #image: ghcr.io/seanmorley15/adventurelog-backend:latest container_name: adventurelog-backend restart: unless-stopped environment: @@ -38,7 +38,7 @@ services: - DJANGO_ADMIN_USERNAME=admin - DJANGO_ADMIN_PASSWORD=admin - DJANGO_ADMIN_EMAIL=admin@example.com - - PUBLIC_URL=http://localhost:8016 # Match the outward port, used for the creation of image urls + - PUBLIC_URL='http://localhost:8016' # Match the outward port, used for the creation of image urls - CSRF_TRUSTED_ORIGINS=http://localhost:8016,http://localhost:8015 # Comma separated list of trusted origins for CSRF - DEBUG=False - FRONTEND_URL=http://localhost:8015 # Used for email generation. This should be the url of the frontend diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md index 1c13f50..ff57ac3 100644 --- a/documentation/docs/configuration/social_auth/authentik.md +++ b/documentation/docs/configuration/social_auth/authentik.md @@ -55,6 +55,10 @@ This configuration is done in the [Admin Panel](../../guides/admin_panel.md). Yo 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. diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md index cbfe41b..51efb74 100644 --- a/documentation/docs/configuration/social_auth/github.md +++ b/documentation/docs/configuration/social_auth/github.md @@ -35,10 +35,15 @@ This configuration is done in the [Admin Panel](../../guides/admin_panel.md). Yo - 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) -4. Save the configuration. - -Users should now be able to log in to AdventureLog using their GitHub account, and link it to existing accounts. diff --git a/frontend/package.json b/frontend/package.json index b80fe8d..ae71362 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", @@ -47,7 +46,6 @@ "psl": "^1.15.0", "qrcode": "^1.5.4", "svelte-i18n": "^4.0.1", - "svelte-maplibre": "^0.9.8", - "tsparticles": "^3.7.1" + "svelte-maplibre": "^0.9.8" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ea075fa..edde452 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: svelte-maplibre: specifier: ^0.9.8 version: 0.9.8(svelte@4.2.19) - tsparticles: - specifier: ^3.7.1 - version: 3.7.1 devDependencies: '@event-calendar/core': specifier: ^3.7.1 @@ -51,9 +48,6 @@ 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.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@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.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4))) @@ -741,11 +735,6 @@ packages: 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 - '@sveltejs/adapter-node@5.2.0': resolution: {integrity: sha512-HVZoei2078XSyPmvdTHE03VXDUD0ytTvMuMHMQP0j6zX4nPDpCcKrgvU7baEblMeCCMdM/shQvstFxOJPQKlUQ==} peerDependencies: @@ -785,147 +774,6 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' - '@tsparticles/basic@3.7.1': - resolution: {integrity: sha512-oJMJ3qzYUROYaOEsaFXkVynxT2OTWBXbQ9MNc1bJi/bVc1VOU44VN7X/KmiZjD+w1U+Qalk6BeVvDRwpFshblw==} - - '@tsparticles/engine@3.7.1': - resolution: {integrity: sha512-GYzBgq/oOE9YJdOL1++MoawWmYg4AvVct6CIrJGx84ZRb3U2owYmLsRGabYl0qX1CWWOvUG569043RJmyp/vQA==} - - '@tsparticles/interaction-external-attract@3.7.1': - resolution: {integrity: sha512-cpnMsFJ7ZJNKccpQvskKvSs1ofknByHE6FGqbEb17ij7HqvbECQOCOVKHPFnYipHe14cXor/Cd+nVisRcTASoQ==} - - '@tsparticles/interaction-external-bounce@3.7.1': - resolution: {integrity: sha512-npvU9Qt6WDonjezHqi+hWM44ga2Oh5yXdr8eSoJpvuHZrCP7rIdRSz5XseHouO1bMS9DbXk86sx4qwrhB5w58w==} - - '@tsparticles/interaction-external-bubble@3.7.1': - resolution: {integrity: sha512-WdbYL46lMfuf2g5kfVB1hhhxRBtEXDvnwz8PJwLKurSThL/27bqsqysyXsMzXtLByXUneGhtJTj4D5I5RYdgjA==} - - '@tsparticles/interaction-external-connect@3.7.1': - resolution: {integrity: sha512-hqx0ANIbjLIz/nxmk0LvqANBiNLLmVybbCA7N+xDHtEORvpKjNlKEvMz6Razocl6vRjoHZ/olSwcxIG84dh/cg==} - - '@tsparticles/interaction-external-grab@3.7.1': - resolution: {integrity: sha512-JMYpFW+7YvkpK5MYlt4Ec3Gwb5ZxS7RLVL8IRUSd5yJOw25husPTYg+FQywxrt5WhKe+tPsCAYo+uGIbTTHi9w==} - - '@tsparticles/interaction-external-pause@3.7.1': - resolution: {integrity: sha512-Kkp+7sCe24hawH0XvS1V6UCCuHfMvpLK7oseqSam9Gt4SyGrFvaqIXxkjXhRhn9MysJyKFPBV4/dtBM1HR9p6A==} - - '@tsparticles/interaction-external-push@3.7.1': - resolution: {integrity: sha512-4VoaR5jvXgQdB7irtq4uSZYr5c+D6TBTVEnLVpBfJhUs6jhw6mgN5g7yp5izIYkK0AlcO431MHn8dvJacvRLDw==} - - '@tsparticles/interaction-external-remove@3.7.1': - resolution: {integrity: sha512-FRBW7U7zD5MkO6/b7e8iSMk/UTtRLY2XiIVFZNsKri3Re3yPpvZzzd5tl2YlYGQlg1Xc+K8SJYMQQA3PtgQ/Tg==} - - '@tsparticles/interaction-external-repulse@3.7.1': - resolution: {integrity: sha512-mwM06dVmg2FEvHMQsPOfRBQWACbjf3qnelODkqI9DSVxQ0B8DESP4BYNXyraFGYv00YiPzRv5Xy/uejHdbsQUA==} - - '@tsparticles/interaction-external-slow@3.7.1': - resolution: {integrity: sha512-CfCAs3kUQC3pLOj0dbzn5AolQyBHgjxORLdfnYBhepvFV1BXB+4ytChRfXBzjykBPI6U+rCnw5Fk/vVjAroSFA==} - - '@tsparticles/interaction-external-trail@3.7.1': - resolution: {integrity: sha512-M7lNQUWP15m8YIDP/JZcZAXaVJLqdwpBs0Uv9F6dU6jsnNXwwHFVFZ+1icrnlbgl9k/Ehhodbdo5weE7GHhQhQ==} - - '@tsparticles/interaction-particles-attract@3.7.1': - resolution: {integrity: sha512-UABbBORKaiItAT8vR0t4ye2H3VE6/Ah4zcylBlnq0Jd5yDkyP4rnkwhicaY6y4Zlfwyx+0PWdAC4f/ziFAyObg==} - - '@tsparticles/interaction-particles-collisions@3.7.1': - resolution: {integrity: sha512-0GY9++Gn2KXViyeifXWkH7a2UO5+uRwyS1rDeTN8eleyiq2j9zQf4xztZEIft8T0hTetq2rkWxQ92j2kev6NVA==} - - '@tsparticles/interaction-particles-links@3.7.1': - resolution: {integrity: sha512-BxCXAAOBNmEvlyOQzwprryW8YdtMIop2v4kgSCff5MCtDwYWoQIfzaQlWbBAkD9ey6BoF8iMjhBUaY1MnDecTA==} - - '@tsparticles/move-base@3.7.1': - resolution: {integrity: sha512-LPtMHwJHhzwfRIcSAk814fY9NcRiICwaEbapaJSYyP1DwscSXqOWoyAEWwzV9hMgAcPdsED6nGeg8RCXGm58lw==} - - '@tsparticles/move-parallax@3.7.1': - resolution: {integrity: sha512-B40azo6EJyMdI+kmIxpqWDaObPwODTYLDCikzkZ73n5tS6OhFUlkz81Scfo+g1iGTdryKFygUKhVGcG1EFuA5g==} - - '@tsparticles/plugin-absorbers@3.7.1': - resolution: {integrity: sha512-3s+fILLV1tdKOq/bXwfoxFVbzkWwXpdWTC2C0QIP6BFwDSQqV5txluiLEf7SCf8C5etQ6dstEnOgVbdzK7+eWA==} - - '@tsparticles/plugin-easing-quad@3.7.1': - resolution: {integrity: sha512-nSwKCRe6C/noCi3dyZlm1GiQGask0aXdWDuS36b82iwzwQ01cBTXeXR25mLr4fsfMLFfYAZXyBxEMMpw3rkSiw==} - - '@tsparticles/plugin-emitters-shape-circle@3.7.1': - resolution: {integrity: sha512-eBwktnGROkiyCvtrSwdPpoRbIjQgV/Odq//0dw8D+qUdnox6dNzzhJjz8L2LAA2kQZBqtdBqV2kcx3w5ZdqoEQ==} - - '@tsparticles/plugin-emitters-shape-square@3.7.1': - resolution: {integrity: sha512-nvGBsRLrkiz6Q38TJRl8Y/eu9i1ChQ9oorQydLBok+iZ6MefuOj39iYsAOkD1w9yRVrFWKHG6CR1mmJUniz/HA==} - - '@tsparticles/plugin-emitters@3.7.1': - resolution: {integrity: sha512-WV5Uwxp/Ckqv5kZynTj6mj13jYbQCArNLFv8ks+zjdlteoyT5EhQl4rg+TalaySCb1zCd6Fu2Scp35l3JJgbnw==} - - '@tsparticles/plugin-hex-color@3.7.1': - resolution: {integrity: sha512-7xu3MV8EdNNejjYyEmrq5fCDdYAcqz/9VatLpnwtwR5Q5t2qI0tD4CrcGaFfC/rTAVJacfiJe02UV/hlj03KKA==} - - '@tsparticles/plugin-hsl-color@3.7.1': - resolution: {integrity: sha512-zzAI1CuoCMBJhgeYZ5Rq42nGbPg35ZzIs11eQegjsWG5Msm5QKSj60qPzERnoUcCc4HCKtIWP7rYMz6h3xpoEg==} - - '@tsparticles/plugin-rgb-color@3.7.1': - resolution: {integrity: sha512-taEraTpCYR6jpjflqBL95tN0zFU8JrAChXTt8mxVn7gddxoNMHI/LGymEPRCweLukwV6GQyAGOkeGEdWDPtYTA==} - - '@tsparticles/shape-circle@3.7.1': - resolution: {integrity: sha512-kmOWaUuFwuTtcCFYjuyJbdA5qDqWdGsharLalYnIczkLu2c1I8jJo/OmGePKhWn62ocu7mqKMomfElkIcw2AsA==} - - '@tsparticles/shape-emoji@3.7.1': - resolution: {integrity: sha512-mX18c/xhYVljS/r5Xbowzclw+1YwhtWoQFOOfkmjjZppA+RjgcwSKLvH6E20PaH1yVTjBOfSF+3POKpwsULzTg==} - - '@tsparticles/shape-image@3.7.1': - resolution: {integrity: sha512-eDzfkQhqLY6fb9QH2Vo9TGfdJBFFpYnojhxQxc7IdzIwOFMD3JK4B52RVl9oowR+rNE8dNp6P2L+eMAF4yld0g==} - - '@tsparticles/shape-line@3.7.1': - resolution: {integrity: sha512-lMPYApUpg7avxmYPfHHr4dQepZSNn/g0Q1/g2+lnTi8ZtUBiCZ2WMVy9R3GOzyozbnzigLQ6AJRnOpsUZV7H4g==} - - '@tsparticles/shape-polygon@3.7.1': - resolution: {integrity: sha512-5FrRfpYC3qnvV2nXBLE4Q0v+SMNWJO8xgzh6MBFwfptvqH4EOrqc/58eS5x0jlf+evwf9LjPgeGkOTcwaHHcYQ==} - - '@tsparticles/shape-square@3.7.1': - resolution: {integrity: sha512-7VCqbRwinjBZ+Ryme27rOtl+jKrET8qDthqZLrAoj3WONBqyt+R9q6SXAJ9WodqEX68IBvcluqbFY5qDZm8iAQ==} - - '@tsparticles/shape-star@3.7.1': - resolution: {integrity: sha512-3G4oipioyWKLEQYT11Sx3k6AObu3dbv/A5LRqGGTQm5IR6UACa+INwykZYI0a+MdJJMb83E0e4Fn3hlZbi0/8w==} - - '@tsparticles/shape-text@3.7.1': - resolution: {integrity: sha512-aU1V9O8uQQBlL0jGFh9Q0b5vQ1Ji6Oo5ptyyj5yJ5uP/ZU00L0Vhk4DNyLXpaU0+H6OBoPpCqnvEsZBB9/HmCQ==} - - '@tsparticles/slim@3.7.1': - resolution: {integrity: sha512-OtJEhud2KleX7OxiG2r/VYriHNIwTpFm3sPFy4EOJzAD0EW7KZoKXGpGn5gwGI1NWeB0jso92yNTrTC2ZTW0qw==} - - '@tsparticles/updater-color@3.7.1': - resolution: {integrity: sha512-QimV3yn17dcdJx7PpTwLtw9BhkQ0q8qFF035OdcZpnynBPAO/hg0zvSMpMGoeuDVFH02wWBy4h2/BYCv6wh6Sw==} - - '@tsparticles/updater-destroy@3.7.1': - resolution: {integrity: sha512-krXNoMDKyeyE/ZjQh3LVjrLYivFefQOQ9i+B7RpMe7x4h+iRgpB6npTCqidGQ82+hZ8G6xfQ9ToduebWwK4JGg==} - - '@tsparticles/updater-life@3.7.1': - resolution: {integrity: sha512-NY5gUrgO5AsARNC0usP9PKahXf7JCxbP/H1vzTfA0SJw4veANfWTldOvhIlcm2CHVP5P1b827p0hWsBHECwz7A==} - - '@tsparticles/updater-opacity@3.7.1': - resolution: {integrity: sha512-YcyviCooTv7SAKw7sxd84CfJqZ7dYPSdYZzCpedV6TKIObRiwLqXlyLXQGJ3YltghKQSCSolmVy8woWBCDm1qA==} - - '@tsparticles/updater-out-modes@3.7.1': - resolution: {integrity: sha512-Cb5sWquRtUYLSiFpmBjjYKRdpNV52diCo9+qMtK1oVlldDBhUwqO+1TQjdlaA2yl5DURlY9ZfOHXvY+IT7CHCw==} - - '@tsparticles/updater-roll@3.7.1': - resolution: {integrity: sha512-gHLRqpTGVGPJBEAIPUiYVembIn5bcaTXXxsUJEM/IN+GIOvj2uZZGZ4r2aFTA6WugqEbJsJdblDSvMfouyz7Ug==} - - '@tsparticles/updater-rotate@3.7.1': - resolution: {integrity: sha512-toVHwl+h6SvtA8dyxSA2kMH2QdDA71vehuAa+HoRqf1y06h5kxyYiMKZFHCqDJ6lFfRPs47MjrC9dD2bDz14MQ==} - - '@tsparticles/updater-size@3.7.1': - resolution: {integrity: sha512-+Y0H0PnDJVIsJ+zHTyubYu1jtRFmVnY1dAv3VCjScIDw6bcpL/ol+HrtHTGIX0WbMyUfjCyALfAoaXi/Wm8VcQ==} - - '@tsparticles/updater-stroke-color@3.7.1': - resolution: {integrity: sha512-VHhQkCNuxjx/Hy7A+g0Yijb24T0+wQ3jNsF/yfrR9dEdZWSBiimZLvV1bilPdAeEtieAJTAZo2VNhcD1snF0iQ==} - - '@tsparticles/updater-tilt@3.7.1': - resolution: {integrity: sha512-pSOXoXPre1VPKC5nC5GW0L9jw63w1dVdsDdggEau7MP9xO7trko9L/KyayBX12Y4Ief1ca12Incxxr67hw7GGA==} - - '@tsparticles/updater-twinkle@3.7.1': - resolution: {integrity: sha512-maRTqPbeZcxBK6s1ry+ih71qSVaitfP1KTrAKR38v26GMwyO6z+zYV2bu9WTRt21FRFAoxlMLWxNu21GtQoXDA==} - - '@tsparticles/updater-wobble@3.7.1': - resolution: {integrity: sha512-YIlNg4L0w4egQJhPLpgcvcfv9+X621+cQsrdN9sSmajxhhwtEQvQUvFUzGTcvpjVi+GcBNp0t4sCKEzoP8iaYw==} - '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -2234,9 +2082,6 @@ packages: tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} - tsparticles@3.7.1: - resolution: {integrity: sha512-NNkOYIo01eHpDuaJxDCGgcLEMZKEJTCN/XPVCLg7VxgEWN19rjXpDnDguISxadS8GSFPws7hpGgbeDDAm3MX+Q==} - type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -2875,11 +2720,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.31.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.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.4.12(@types/node@22.5.4)))(svelte@4.2.19)(vite@5.4.12(@types/node@22.5.4)) - import-meta-resolve: 4.1.0 - '@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) @@ -2946,228 +2786,6 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.4 - '@tsparticles/basic@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - '@tsparticles/move-base': 3.7.1 - '@tsparticles/plugin-hex-color': 3.7.1 - '@tsparticles/plugin-hsl-color': 3.7.1 - '@tsparticles/plugin-rgb-color': 3.7.1 - '@tsparticles/shape-circle': 3.7.1 - '@tsparticles/updater-color': 3.7.1 - '@tsparticles/updater-opacity': 3.7.1 - '@tsparticles/updater-out-modes': 3.7.1 - '@tsparticles/updater-size': 3.7.1 - - '@tsparticles/engine@3.7.1': {} - - '@tsparticles/interaction-external-attract@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-bounce@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-bubble@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-connect@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-grab@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-pause@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-push@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-remove@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-repulse@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-slow@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-external-trail@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-particles-attract@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-particles-collisions@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/interaction-particles-links@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/move-base@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/move-parallax@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-absorbers@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-easing-quad@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-emitters-shape-circle@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - '@tsparticles/plugin-emitters': 3.7.1 - - '@tsparticles/plugin-emitters-shape-square@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - '@tsparticles/plugin-emitters': 3.7.1 - - '@tsparticles/plugin-emitters@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-hex-color@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-hsl-color@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/plugin-rgb-color@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-circle@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-emoji@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-image@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-line@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-polygon@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-square@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-star@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/shape-text@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/slim@3.7.1': - dependencies: - '@tsparticles/basic': 3.7.1 - '@tsparticles/engine': 3.7.1 - '@tsparticles/interaction-external-attract': 3.7.1 - '@tsparticles/interaction-external-bounce': 3.7.1 - '@tsparticles/interaction-external-bubble': 3.7.1 - '@tsparticles/interaction-external-connect': 3.7.1 - '@tsparticles/interaction-external-grab': 3.7.1 - '@tsparticles/interaction-external-pause': 3.7.1 - '@tsparticles/interaction-external-push': 3.7.1 - '@tsparticles/interaction-external-remove': 3.7.1 - '@tsparticles/interaction-external-repulse': 3.7.1 - '@tsparticles/interaction-external-slow': 3.7.1 - '@tsparticles/interaction-particles-attract': 3.7.1 - '@tsparticles/interaction-particles-collisions': 3.7.1 - '@tsparticles/interaction-particles-links': 3.7.1 - '@tsparticles/move-parallax': 3.7.1 - '@tsparticles/plugin-easing-quad': 3.7.1 - '@tsparticles/shape-emoji': 3.7.1 - '@tsparticles/shape-image': 3.7.1 - '@tsparticles/shape-line': 3.7.1 - '@tsparticles/shape-polygon': 3.7.1 - '@tsparticles/shape-square': 3.7.1 - '@tsparticles/shape-star': 3.7.1 - '@tsparticles/updater-life': 3.7.1 - '@tsparticles/updater-rotate': 3.7.1 - '@tsparticles/updater-stroke-color': 3.7.1 - - '@tsparticles/updater-color@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-destroy@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-life@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-opacity@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-out-modes@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-roll@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-rotate@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-size@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-stroke-color@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-tilt@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-twinkle@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - - '@tsparticles/updater-wobble@3.7.1': - dependencies: - '@tsparticles/engine': 3.7.1 - '@types/cookie@0.6.0': {} '@types/estree@1.0.6': {} @@ -4539,22 +4157,6 @@ snapshots: tslib@2.6.3: {} - tsparticles@3.7.1: - dependencies: - '@tsparticles/engine': 3.7.1 - '@tsparticles/interaction-external-trail': 3.7.1 - '@tsparticles/plugin-absorbers': 3.7.1 - '@tsparticles/plugin-emitters': 3.7.1 - '@tsparticles/plugin-emitters-shape-circle': 3.7.1 - '@tsparticles/plugin-emitters-shape-square': 3.7.1 - '@tsparticles/shape-text': 3.7.1 - '@tsparticles/slim': 3.7.1 - '@tsparticles/updater-destroy': 3.7.1 - '@tsparticles/updater-roll': 3.7.1 - '@tsparticles/updater-tilt': 3.7.1 - '@tsparticles/updater-twinkle': 3.7.1 - '@tsparticles/updater-wobble': 3.7.1 - type-detect@4.0.8: {} type@2.7.3: {} diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index d6d0121..3a5afc9 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -1,52 +1,35 @@ @@ -811,150 +597,7 @@ -
- -
- {$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.city - ? reverseGeocodePlace.city + ', ' - : ''}{reverseGeocodePlace.region}, - {reverseGeocodePlace.country} -

-

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

- {#if reverseGeocodePlace.city} -

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

- {/if} -
- {#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)} - - {/if} - {#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)} - - {/if} - {/if} -
-
-
+
@@ -1257,6 +900,7 @@ it would also work to just use on:click on the MapLibre component itself. --> {#if immichIntegration} { url = e.detail; fetchImage(); diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index 01eb068..4bae036 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -34,7 +34,9 @@ ? `${user.first_name} ${user.last_name}` : user.username}

-
  • +
  • + +
  • diff --git a/frontend/src/lib/components/HotelModal.svelte b/frontend/src/lib/components/HotelModal.svelte new file mode 100644 index 0000000..241be48 --- /dev/null +++ b/frontend/src/lib/components/HotelModal.svelte @@ -0,0 +1,315 @@ + + + + + + + diff --git a/frontend/src/lib/components/ImmichSelect.svelte b/frontend/src/lib/components/ImmichSelect.svelte index 8668f6f..b1d137d 100644 --- a/frontend/src/lib/components/ImmichSelect.svelte +++ b/frontend/src/lib/components/ImmichSelect.svelte @@ -1,32 +1,81 @@ @@ -111,20 +117,27 @@ on:click={() => (currentAlbum = '')} type="radio" class="join-item btn" - bind:group={searchOrSelect} + bind:group={searchCategory} value="search" aria-label="Search" /> +
    - {#if searchOrSelect === 'search'} + {#if searchCategory === 'search'}
    - {:else} + {:else if searchCategory === 'date'} + + {:else if searchCategory === 'album'} +
    + {$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.city + ? reverseGeocodePlace.city + ', ' + : ''}{reverseGeocodePlace.region}, + {reverseGeocodePlace.country} +

    +

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

    + {#if reverseGeocodePlace.city} +

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

    + {/if} +
    + {#if !reverseGeocodePlace.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/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 5e0d475..f69cb27 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -17,7 +17,8 @@ // Event listener for focusing input function handleKeydown(event: KeyboardEvent) { - if (event.key === '/' && document.activeElement !== inputElement) { + // Ignore any keypresses in an input/textarea field, so we don't interfere with typing. + if (event.key === '/' && !["INPUT", "TEXTAREA"].includes((event.target as HTMLElement)?.tagName)) { event.preventDefault(); // Prevent browser's search shortcut if (inputElement) { inputElement.focus(); 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/index.ts b/frontend/src/lib/index.ts index 17dcae4..e5d96e3 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -464,3 +464,13 @@ export function osmTagToEmoji(tag: string) { 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); + }; +} \ No newline at end of file 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 c97c43d..30f5b27 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; @@ -41,6 +40,7 @@ export type Adventure = { is_visited?: boolean; category: Category | null; attachments: Attachment[]; + user?: User | null; }; export type Country = { @@ -113,6 +113,7 @@ export type Collection = { end_date: string | null; transportations?: Transportation[]; notes?: Note[]; + hotels?: Hotel[]; checklists?: Checklist[]; is_archived?: boolean; shared_with: string[] | undefined; @@ -262,3 +263,23 @@ export type Attachment = { user_id: string; name: string; }; + +export type Hotel = { + id: string; + user_id: string; + name: 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 6115fe0..c01123e 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -234,7 +234,8 @@ "images": "Bilder", "primary": "Primär", "upload": "Hochladen", - "view_attachment": "Anhang anzeigen" + "view_attachment": "Anhang anzeigen", + "of": "von" }, "home": { "desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit", @@ -302,7 +303,11 @@ "both_passwords_required": "Beide Passwörter sind erforderlich", "new_password": "Neues Passwort", "reset_failed": "Passwort konnte nicht zurückgesetzt werden", - "or_3rd_party": "Oder melden Sie sich bei einem Drittanbieter an" + "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." diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 2b989e2..011dfbf 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -165,6 +165,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!", @@ -190,6 +191,7 @@ "no_description_found": "No description found", "adventure_created": "Adventure created", "adventure_create_error": "Failed to create adventure", + "hotel": "Hotel", "create_adventure": "Create Adventure", "adventure_updated": "Adventure updated", "adventure_update_error": "Failed to update adventure", @@ -326,7 +328,11 @@ "new_password": "New Password (6+ characters)", "both_passwords_required": "Both passwords are required", "reset_failed": "Failed to reset password", - "or_3rd_party": "Or login with a third-party service" + "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." diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 0f00856..6b98c34 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -281,7 +281,8 @@ "primary": "Primario", "upload": "Subir", "view_attachment": "Ver archivo adjunto", - "attachment_name": "Nombre del archivo adjunto" + "attachment_name": "Nombre del archivo adjunto", + "of": "de" }, "worldtravel": { "all": "Todo", @@ -326,7 +327,11 @@ "both_passwords_required": "Se requieren ambas contraseñas", "new_password": "Nueva contraseña", "reset_failed": "No se pudo restablecer la contraseña", - "or_3rd_party": "O inicie sesión con un servicio de terceros" + "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." diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index e99f79d..8d6df46 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -234,7 +234,8 @@ "images": "Images", "primary": "Primaire", "upload": "Télécharger", - "view_attachment": "Voir la pièce jointe" + "view_attachment": "Voir la pièce jointe", + "of": "de" }, "home": { "desc_1": "Découvrez, planifiez et explorez en toute simplicité", @@ -302,7 +303,11 @@ "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", - "or_3rd_party": "Ou connectez-vous avec un service tiers" + "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." diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 12877dc..ad0f0c1 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -234,7 +234,8 @@ "images": "Immagini", "primary": "Primario", "upload": "Caricamento", - "view_attachment": "Visualizza allegato" + "view_attachment": "Visualizza allegato", + "of": "Di" }, "home": { "desc_1": "Scopri, pianifica ed esplora con facilità", @@ -302,7 +303,11 @@ "both_passwords_required": "Sono necessarie entrambe le password", "new_password": "Nuova parola d'ordine", "reset_failed": "Impossibile reimpostare la password", - "or_3rd_party": "Oppure accedi con un servizio di terze parti" + "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." diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 58506d7..c438b32 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -234,7 +234,8 @@ "images": "Afbeeldingen", "primary": "Primair", "upload": "Uploaden", - "view_attachment": "Bijlage bekijken" + "view_attachment": "Bijlage bekijken", + "of": "van" }, "home": { "desc_1": "Ontdek, plan en verken met gemak", @@ -301,8 +302,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.", - "or_3rd_party": "Of log in met een service van derden" + "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." diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 31134d5..982d8d8 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -281,7 +281,8 @@ "images": "Obrazy", "primary": "Podstawowy", "upload": "Wgrywać", - "view_attachment": "Zobacz załącznik" + "view_attachment": "Zobacz załącznik", + "of": "z" }, "worldtravel": { "country_list": "Lista krajów", @@ -326,7 +327,11 @@ "both_passwords_required": "Obydwa hasła są wymagane", "new_password": "Nowe hasło", "reset_failed": "Nie udało się zresetować hasła", - "or_3rd_party": "Lub zaloguj się za pomocą usługi strony trzeciej" + "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." diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 919c6d9..a2373ca 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -234,7 +234,8 @@ "images": "Bilder", "primary": "Primär", "upload": "Ladda upp", - "view_attachment": "Visa bilaga" + "view_attachment": "Visa bilaga", + "of": "av" }, "home": { "desc_1": "Upptäck, planera och utforska med lätthet", @@ -326,7 +327,11 @@ "both_passwords_required": "Båda lösenorden krävs", "new_password": "Nytt lösenord", "reset_failed": "Det gick inte att återställa lösenordet", - "or_3rd_party": "Eller logga in med en tredjepartstjänst" + "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." diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 7270e3b..1ecb246 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -234,7 +234,8 @@ "images": "图片", "primary": "基本的", "upload": "上传", - "view_attachment": "查看附件" + "view_attachment": "查看附件", + "of": "的" }, "home": { "desc_1": "轻松发现、规划和探索", @@ -302,7 +303,11 @@ "both_passwords_required": "两个密码都需要", "new_password": "新密码", "reset_failed": "重置密码失败", - "or_3rd_party": "或者使用第三方服务登录" + "or_3rd_party": "或者使用第三方服务登录", + "no_public_adventures": "找不到公共冒险", + "no_public_collections": "找不到公共收藏", + "user_adventures": "用户冒险", + "user_collections": "用户收集" }, "worldtravel": { "all": "全部", 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/[id]/+page.svelte b/frontend/src/routes/adventures/[id]/+page.svelte index 35cd73c..64a59d6 100644 --- a/frontend/src/routes/adventures/[id]/+page.svelte +++ b/frontend/src/routes/adventures/[id]/+page.svelte @@ -12,6 +12,7 @@ import toGeoJSON from '@mapbox/togeojson'; import LightbulbOn from '~icons/mdi/lightbulb-on'; + import Account from '~icons/mdi/account'; let geojson: any; @@ -221,6 +222,40 @@
    +
    + {#if adventure.user.profile_pic} +
    +
    + +
    +
    + {: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} +
    +
    {adventure.is_public ? 'Public' : 'Private'}
    + {#if adventure.location}
    - import type { Adventure, Checklist, Collection, Note, Transportation } from '$lib/types'; + import type { Adventure, Checklist, Collection, Hotel, Note, Transportation } from '$lib/types'; import { onMount } from 'svelte'; import type { PageData } from './$types'; import { marked } from 'marked'; // Import the markdown parser @@ -35,6 +35,7 @@ import TransportationModal from '$lib/components/TransportationModal.svelte'; import CardCarousel from '$lib/components/CardCarousel.svelte'; import { goto } from '$app/navigation'; + import HotelModal from '$lib/components/HotelModal.svelte'; export let data: PageData; console.log(data); @@ -115,6 +116,7 @@ let numAdventures: number = 0; let transportations: Transportation[] = []; + let hotels: Hotel[] = []; let notes: Note[] = []; let checklists: Checklist[] = []; @@ -174,6 +176,9 @@ if (collection.transportations) { transportations = collection.transportations; } + if (collection.hotels) { + hotels = collection.hotels; + } if (collection.notes) { notes = collection.notes; } @@ -243,6 +248,8 @@ let adventureToEdit: Adventure | null = null; let transportationToEdit: Transportation | null = null; + let isShowingHotelModal: boolean = false; + let hotelToEdit: Hotel | null = null; let isAdventureModalOpen: boolean = false; let isNoteModalOpen: boolean = false; let noteToEdit: Note | null; @@ -260,6 +267,11 @@ isShowingTransportationModal = true; } + function editHotel(event: CustomEvent) { + hotelToEdit = event.detail; + isShowingHotelModal = true; + } + function saveOrCreateAdventure(event: CustomEvent) { if (adventures.find((adventure) => adventure.id === event.detail.id)) { adventures = adventures.map((adventure) => { @@ -355,6 +367,22 @@ } isShowingTransportationModal = false; } + + function saveOrCreateHotel(event: CustomEvent) { + if (hotels.find((hotel) => hotel.id === event.detail.id)) { + // Update existing hotel + hotels = hotels.map((hotel) => { + if (hotel.id === event.detail.id) { + return event.detail; + } + return hotel; + }); + } else { + // Create new hotel + hotels = [event.detail, ...hotels]; + } + isShowingHotelModal = false; + } {#if isShowingLinkModal} @@ -376,6 +404,15 @@ /> {/if} +{#if isShowingHotelModal} + (isShowingHotelModal = false)} + on:save={saveOrCreateHotel} + {collection} + /> +{/if} + {#if isAdventureModalOpen} {$t('adventures.checklist')} + - {#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.visited_country_count / stats.total_countries) * 100)}% -
    -
    - {stats.visited_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} -
    -
    - -
    -
    {$t('profile.visited_cities')}
    -
    - {Math.round((stats.visited_city_count / stats.total_cities) * 100)}% -
    -
    - {stats.visited_city_count}/{stats.total_cities} -
    -
    -
    -
    - {/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..f98f782 --- /dev/null +++ b/frontend/src/routes/profile/[uuid]/+page.server.ts @@ -0,0 +1,39 @@ +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}`, {}); + if (!res.ok) { + console.error('Failed to fetch user stats'); + } else { + stats = await res.json(); + } + + let userData = await event.fetch(`${endpoint}/auth/user/${username}/`); + 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/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