mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-19 12:59:36 +02:00
Merge branch 'development' into main
This commit is contained in:
commit
57db1e088f
75 changed files with 2113 additions and 1152 deletions
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* @seanmorley15
|
46
.github/workflows/cdn-beta.yml
vendored
Normal file
46
.github/workflows/cdn-beta.yml
vendored
Normal file
|
@ -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
|
46
.github/workflows/cdn-latest.yml
vendored
Normal file
46
.github/workflows/cdn-latest.yml
vendored
Normal file
|
@ -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
|
43
.github/workflows/cdn-release.yml
vendored
Normal file
43
.github/workflows/cdn-release.yml
vendored
Normal file
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
# ------------------- #
|
0
backend/server/achievements/__init__.py
Normal file
0
backend/server/achievements/__init__.py
Normal file
9
backend/server/achievements/admin.py
Normal file
9
backend/server/achievements/admin.py
Normal file
|
@ -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)
|
6
backend/server/achievements/apps.py
Normal file
6
backend/server/achievements/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AchievementsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'achievements'
|
0
backend/server/achievements/management/__init__.py
Normal file
0
backend/server/achievements/management/__init__.py
Normal file
|
@ -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}"))
|
39
backend/server/achievements/migrations/0001_initial.py
Normal file
39
backend/server/achievements/migrations/0001_initial.py
Normal file
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
0
backend/server/achievements/migrations/__init__.py
Normal file
0
backend/server/achievements/migrations/__init__.py
Normal file
33
backend/server/achievements/models.py
Normal file
33
backend/server/achievements/models.py
Normal file
|
@ -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}"
|
3
backend/server/achievements/tests.py
Normal file
3
backend/server/achievements/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
3
backend/server/achievements/views.py
Normal file
3
backend/server/achievements/views.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
|
@ -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'
|
||||
|
|
39
backend/server/adventures/migrations/0022_hotel.py
Normal file
39
backend/server/adventures/migrations/0022_hotel.py
Normal file
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -318,4 +318,37 @@ class Category(models.Model):
|
|||
|
||||
|
||||
def __str__(self):
|
||||
return self.name + ' - ' + self.display_name + ' - ' + self.icon
|
||||
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
|
|
@ -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
|
||||
|
||||
return representation
|
|
@ -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 = [
|
||||
|
|
|
@ -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 *
|
||||
from .attachment_view import *
|
||||
from .hotel_view import *
|
|
@ -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)
|
||||
|
|
84
backend/server/adventures/views/hotel_view.py
Normal file
84
backend/server/adventures/views/hotel_view.py
Normal file
|
@ -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)
|
|
@ -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<username>[^/.]+)')
|
||||
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,
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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'
|
||||
|
||||
ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
|
|
@ -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/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
|
||||
path('auth/user/<str:username>/', 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'),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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:
|
||||
|
|
1
cdn/.gitignore
vendored
Normal file
1
cdn/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
data/
|
36
cdn/Dockerfile
Normal file
36
cdn/Dockerfile
Normal file
|
@ -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"]
|
3
cdn/README.md
Normal file
3
cdn/README.md
Normal file
|
@ -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.
|
9
cdn/docker-compose.yml
Normal file
9
cdn/docker-compose.yml
Normal file
|
@ -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
|
9
cdn/entrypoint.sh
Normal file
9
cdn/entrypoint.sh
Normal file
|
@ -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;'
|
90
cdn/index.html
Normal file
90
cdn/index.html
Normal file
|
@ -0,0 +1,90 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="https://adventurelog.app/adventurelog.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AdventureLog CDN</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(to right, #1e3c72, #2a5298);
|
||||
color: white;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-container {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
border-radius: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.attribution-background {
|
||||
background: rgba(42, 71, 105, 0.808);
|
||||
border-radius: 15px;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero-container">
|
||||
<div>
|
||||
<h1 class="fw-bold">Welcome to the AdventureLog CDN</h1>
|
||||
<p class="fs-5">
|
||||
This is a content delivery network for the AdventureLog project. You
|
||||
can browse the content by clicking the button below.
|
||||
</p>
|
||||
<a href="/data/" class="btn btn-light btn-lg fw-bold">Browse Content</a>
|
||||
</div>
|
||||
<div class="container mt-5">
|
||||
<h2 class="fw-bold">About AdventureLog</h2>
|
||||
<p class="fs-5">
|
||||
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 <a href="https://seanmorley.com">Sean Morley</a> and is
|
||||
open source. View it on GitHub here:
|
||||
<a href="https://github.com/seanmorley15/AdventureLog">AdventureLog</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Data Attributions -->
|
||||
<div class="container mt-5">
|
||||
<div class="card shadow-sm border-0 attribution-background">
|
||||
<div class="card-body">
|
||||
<h2 class="fw-bold text-primary">Data Attributions</h2>
|
||||
<p class="fs-5 text-white">
|
||||
The data provided in this CDN is sourced from the following
|
||||
repositories:
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<a
|
||||
href="https://flagpedia.net/"
|
||||
class="text-decoration-none fw-semibold"
|
||||
>
|
||||
🌍 Flagpedia - Flags of the World
|
||||
</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a
|
||||
href="https://github.com/dr5hn/countries-states-cities-database/"
|
||||
class="text-decoration-none fw-semibold"
|
||||
>
|
||||
🏙️ dr5hn/countries-states-cities-database - Country, Region,
|
||||
and City Data
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
87
cdn/main.py
Normal file
87
cdn/main.py
Normal file
|
@ -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')
|
13
cdn/nginx.conf
Normal file
13
cdn/nginx.conf
Normal file
|
@ -0,0 +1,13 @@
|
|||
events {}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /data/ {
|
||||
root /var/www/html;
|
||||
autoindex on; # Enable directory listing
|
||||
}
|
||||
}
|
||||
}
|
1
cdn/requirements.txt
Normal file
1
cdn/requirements.txt
Normal file
|
@ -0,0 +1 @@
|
|||
osm2geojson==0.2.5
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
4. Save the configuration.
|
||||
|
||||
Users should now be able to log in to AdventureLog using their GitHub account, and link it to existing accounts.
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
398
frontend/pnpm-lock.yaml
generated
398
frontend/pnpm-lock.yaml
generated
|
@ -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: {}
|
||||
|
|
|
@ -1,52 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type {
|
||||
Adventure,
|
||||
Attachment,
|
||||
Category,
|
||||
Collection,
|
||||
OpenStreetMapPlace,
|
||||
Point,
|
||||
ReverseGeocode
|
||||
} from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import type { Adventure, Attachment, Category, Collection } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { t } from 'svelte-i18n';
|
||||
export let longitude: number | null = null;
|
||||
export let latitude: number | null = null;
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let query: string = '';
|
||||
let places: OpenStreetMapPlace[] = [];
|
||||
let images: { id: string; image: string; is_primary: boolean }[] = [];
|
||||
let warningMessage: string = '';
|
||||
let constrainDates: boolean = false;
|
||||
|
||||
let categories: Category[] = [];
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let immichIntegration: boolean = false;
|
||||
|
||||
import ActivityComplete from './ActivityComplete.svelte';
|
||||
import { appVersion } from '$lib/config';
|
||||
import CategoryDropdown from './CategoryDropdown.svelte';
|
||||
import { findFirstValue } from '$lib';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import ImmichSelect from './ImmichSelect.svelte';
|
||||
|
||||
import Star from '~icons/mdi/star';
|
||||
import Crown from '~icons/mdi/crown';
|
||||
import AttachmentCard from './AttachmentCard.svelte';
|
||||
import LocationDropdown from './LocationDropdown.svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let wikiError: string = '';
|
||||
|
||||
let noPlaces: boolean = false;
|
||||
|
||||
let is_custom_location: boolean = false;
|
||||
|
||||
let reverseGeocodePlace: ReverseGeocode | null = null;
|
||||
|
||||
let adventure: Adventure = {
|
||||
id: '',
|
||||
name: '',
|
||||
|
@ -101,38 +84,18 @@
|
|||
attachments: adventureToEdit?.attachments || []
|
||||
};
|
||||
|
||||
let markers: Point[] = [];
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
console.log('open');
|
||||
});
|
||||
|
||||
let url: string = '';
|
||||
let imageError: string = '';
|
||||
let wikiImageError: string = '';
|
||||
|
||||
let old_display_name: string = '';
|
||||
let triggerMarkVisted: boolean = false;
|
||||
|
||||
images = adventure.images || [];
|
||||
|
||||
if (longitude && latitude) {
|
||||
adventure.latitude = latitude;
|
||||
adventure.longitude = longitude;
|
||||
reverseGeocode(true);
|
||||
}
|
||||
|
||||
$: {
|
||||
is_custom_location = adventure.location != reverseGeocodePlace?.display_name;
|
||||
}
|
||||
|
||||
if (adventure.longitude && adventure.latitude) {
|
||||
markers = [];
|
||||
markers = [
|
||||
{
|
||||
lngLat: { lng: adventure.longitude, lat: adventure.latitude },
|
||||
location: adventure.location || '',
|
||||
name: adventure.name,
|
||||
activity_type: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
$: {
|
||||
if (!adventure.rating) {
|
||||
adventure.rating = NaN;
|
||||
|
@ -230,11 +193,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function clearMap() {
|
||||
console.log('CLEAR');
|
||||
markers = [];
|
||||
}
|
||||
|
||||
let imageSearch: string = adventure.name || '';
|
||||
|
||||
async function removeImage(id: string) {
|
||||
|
@ -258,51 +216,6 @@
|
|||
close();
|
||||
}
|
||||
|
||||
let willBeMarkedVisited: boolean = false;
|
||||
|
||||
$: {
|
||||
willBeMarkedVisited = false; // Reset before evaluating
|
||||
|
||||
const today = new Date(); // Cache today's date to avoid redundant calculations
|
||||
|
||||
for (const visit of adventure.visits) {
|
||||
const startDate = new Date(visit.start_date);
|
||||
const endDate = visit.end_date ? new Date(visit.end_date) : null;
|
||||
|
||||
// If the visit has both a start date and an end date, check if it started by today
|
||||
if (startDate && endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
|
||||
// If the visit has a start date but no end date, check if it started by today
|
||||
if (startDate && !endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
}
|
||||
|
||||
console.log('WMBV:', willBeMarkedVisited);
|
||||
}
|
||||
|
||||
let previousCoords: { lat: number; lng: number } | null = null;
|
||||
|
||||
$: if (markers.length > 0) {
|
||||
const newLat = Math.round(markers[0].lngLat.lat * 1e6) / 1e6;
|
||||
const newLng = Math.round(markers[0].lngLat.lng * 1e6) / 1e6;
|
||||
|
||||
if (!previousCoords || previousCoords.lat !== newLat || previousCoords.lng !== newLng) {
|
||||
adventure.latitude = newLat;
|
||||
adventure.longitude = newLng;
|
||||
previousCoords = { lat: newLat, lng: newLng };
|
||||
reverseGeocode();
|
||||
}
|
||||
|
||||
if (!adventure.name) {
|
||||
adventure.name = markers[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
async function makePrimaryImage(image_id: string) {
|
||||
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
|
||||
method: 'POST'
|
||||
|
@ -407,28 +320,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
async function geocode(e: Event | null) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (!query) {
|
||||
alert($t('adventures.no_location'));
|
||||
return;
|
||||
}
|
||||
let res = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, {
|
||||
headers: {
|
||||
'User-Agent': `AdventureLog / ${appVersion} `
|
||||
}
|
||||
});
|
||||
console.log(res);
|
||||
let data = (await res.json()) as OpenStreetMapPlace[];
|
||||
places = data;
|
||||
if (data.length === 0) {
|
||||
noPlaces = true;
|
||||
} else {
|
||||
noPlaces = false;
|
||||
}
|
||||
}
|
||||
|
||||
let new_start_date: string = '';
|
||||
let new_end_date: string = '';
|
||||
|
@ -459,93 +350,6 @@
|
|||
new_notes = '';
|
||||
}
|
||||
|
||||
async function markVisited() {
|
||||
console.log(reverseGeocodePlace);
|
||||
if (reverseGeocodePlace) {
|
||||
if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) {
|
||||
let region_res = await fetch(`/api/visitedregion`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ region: reverseGeocodePlace.region_id })
|
||||
});
|
||||
if (region_res.ok) {
|
||||
reverseGeocodePlace.region_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`);
|
||||
}
|
||||
}
|
||||
if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) {
|
||||
let city_res = await fetch(`/api/visitedcity`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: reverseGeocodePlace.city_id })
|
||||
});
|
||||
if (city_res.ok) {
|
||||
reverseGeocodePlace.city_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.city} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reverseGeocode(force_update: boolean = false) {
|
||||
let res = await fetch(
|
||||
`/api/reverse-geocode/reverse_geocode/?lat=${adventure.latitude}&lon=${adventure.longitude}`
|
||||
);
|
||||
let data = await res.json();
|
||||
if (data.error) {
|
||||
console.log(data.error);
|
||||
reverseGeocodePlace = null;
|
||||
return;
|
||||
}
|
||||
reverseGeocodePlace = data;
|
||||
|
||||
console.log(reverseGeocodePlace);
|
||||
console.log(is_custom_location);
|
||||
|
||||
if (
|
||||
reverseGeocodePlace &&
|
||||
reverseGeocodePlace.display_name &&
|
||||
(!is_custom_location || force_update)
|
||||
) {
|
||||
old_display_name = reverseGeocodePlace.display_name;
|
||||
adventure.location = reverseGeocodePlace.display_name;
|
||||
}
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let immichIntegration: boolean = false;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
console.log('open');
|
||||
let categoryFetch = await fetch('/api/categories/categories');
|
||||
if (categoryFetch.ok) {
|
||||
categories = await categoryFetch.json();
|
||||
} else {
|
||||
addToast('error', $t('adventures.category_fetch_error'));
|
||||
}
|
||||
// Check for Immich Integration
|
||||
let res = await fetch('/api/integrations');
|
||||
if (!res.ok) {
|
||||
addToast('error', $t('immich.integration_fetch_error'));
|
||||
} else {
|
||||
let data = await res.json();
|
||||
if (data.immich) {
|
||||
immichIntegration = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
@ -567,20 +371,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function addMarker(e: CustomEvent<any>) {
|
||||
markers = [];
|
||||
markers = [
|
||||
...markers,
|
||||
{
|
||||
lngLat: e.detail.lngLat,
|
||||
name: '',
|
||||
location: '',
|
||||
activity_type: ''
|
||||
}
|
||||
];
|
||||
console.log(markers);
|
||||
}
|
||||
|
||||
function imageSubmit() {
|
||||
return async ({ result }: any) => {
|
||||
if (result.type === 'success') {
|
||||
|
@ -600,6 +390,8 @@
|
|||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
triggerMarkVisted = true;
|
||||
|
||||
console.log(adventure);
|
||||
if (adventure.id === '') {
|
||||
console.log(categories);
|
||||
|
@ -655,12 +447,6 @@
|
|||
addToast('error', $t('adventures.adventure_update_error'));
|
||||
}
|
||||
}
|
||||
if (
|
||||
adventure.is_visited &&
|
||||
(!reverseGeocodePlace?.region_visited || !reverseGeocodePlace?.city_visited)
|
||||
) {
|
||||
markVisited();
|
||||
}
|
||||
imageSearch = adventure.name;
|
||||
}
|
||||
</script>
|
||||
|
@ -811,150 +597,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.location_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
|
||||
<div>
|
||||
<label for="latitude">{$t('adventures.location')}</label><br />
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
bind:value={adventure.location}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{#if is_custom_location}
|
||||
<button
|
||||
class="btn btn-primary ml-2"
|
||||
type="button"
|
||||
on:click={() => (adventure.location = reverseGeocodePlace?.display_name)}
|
||||
>{$t('adventures.set_to_pin')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form on:submit={geocode} class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('adventures.search_for_location')}
|
||||
class="input input-bordered w-full max-w-xs mb-2"
|
||||
id="search"
|
||||
name="search"
|
||||
bind:value={query}
|
||||
/>
|
||||
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button>
|
||||
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
|
||||
>{$t('adventures.clear_map')}</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
{#if places.length > 0}
|
||||
<div class="mt-4 max-w-full">
|
||||
<h3 class="font-bold text-lg mb-4">{$t('adventures.search_results')}</h3>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{#each places as place}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
|
||||
on:click={() => {
|
||||
markers = [
|
||||
{
|
||||
lngLat: { lng: Number(place.lon), lat: Number(place.lat) },
|
||||
location: place.display_name,
|
||||
name: place.name,
|
||||
activity_type: place.type
|
||||
}
|
||||
];
|
||||
}}
|
||||
>
|
||||
{place.display_name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if noPlaces}
|
||||
<p class="text-error text-lg">{$t('adventures.no_results')}</p>
|
||||
{/if}
|
||||
<!-- </div> -->
|
||||
<div>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
where you might not have access to the top-level `MapLibre` component. In this case
|
||||
it would also work to just use on:click on the MapLibre component itself. -->
|
||||
<MapEvents on:click={addMarker} />
|
||||
|
||||
{#each markers as marker}
|
||||
<DefaultMarker lngLat={marker.lngLat} />
|
||||
{/each}
|
||||
</MapLibre>
|
||||
{#if reverseGeocodePlace}
|
||||
<div class="mt-2">
|
||||
<p>
|
||||
{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
</p>
|
||||
<p>
|
||||
{reverseGeocodePlace.region}:
|
||||
{reverseGeocodePlace.region_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
{#if reverseGeocodePlace.city}
|
||||
<p>
|
||||
{reverseGeocodePlace.city}:
|
||||
{reverseGeocodePlace.city_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
|
||||
<button type="button" class="btn btn-neutral" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
|
||||
<div role="alert" class="alert alert-info mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
{$t('adventures.will_be_marked')}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LocationDropdown bind:item={adventure} bind:triggerMarkVisted />
|
||||
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
|
||||
<input type="checkbox" />
|
||||
|
@ -1257,6 +900,7 @@ it would also work to just use on:click on the MapLibre component itself. -->
|
|||
|
||||
{#if immichIntegration}
|
||||
<ImmichSelect
|
||||
{adventure}
|
||||
on:fetchImage={(e) => {
|
||||
url = e.detail;
|
||||
fetchImage();
|
||||
|
|
|
@ -34,7 +34,9 @@
|
|||
? `${user.first_name} ${user.last_name}`
|
||||
: user.username}
|
||||
</p>
|
||||
<li><button on:click={() => goto('/profile')}>{$t('navbar.profile')}</button></li>
|
||||
<li>
|
||||
<button on:click={() => goto(`/profile/${user.username}`)}>{$t('navbar.profile')}</button>
|
||||
</li>
|
||||
<li><button on:click={() => goto('/adventures')}>{$t('navbar.my_adventures')}</button></li>
|
||||
<li><button on:click={() => goto('/shared')}>{$t('navbar.shared_with_me')}</button></li>
|
||||
<li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li>
|
||||
|
|
315
frontend/src/lib/components/HotelModal.svelte
Normal file
315
frontend/src/lib/components/HotelModal.svelte
Normal file
|
@ -0,0 +1,315 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import { appVersion } from '$lib/config';
|
||||
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
|
||||
import type { Collection, Hotel, ReverseGeocode, OpenStreetMapPlace, Point } from '$lib/types';
|
||||
import LocationDropdown from './LocationDropdown.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let collection: Collection;
|
||||
export let hotelToEdit: Hotel | null = null;
|
||||
|
||||
let modal: HTMLDialogElement;
|
||||
let constrainDates: boolean = false;
|
||||
let hotel: Hotel = { ...initializeHotel(hotelToEdit) };
|
||||
let fullStartDate: string = '';
|
||||
let fullEndDate: string = '';
|
||||
let reverseGeocodePlace: any | null = null;
|
||||
let query: string = '';
|
||||
let places: OpenStreetMapPlace[] = [];
|
||||
let noPlaces: boolean = false;
|
||||
let is_custom_location: boolean = false;
|
||||
let markers: Point[] = [];
|
||||
|
||||
// Format date as local datetime
|
||||
function toLocalDatetime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
|
||||
}
|
||||
|
||||
// Initialize hotel with values from hotelToEdit or default values
|
||||
function initializeHotel(hotelToEdit: Hotel | null): Hotel {
|
||||
return {
|
||||
id: hotelToEdit?.id || '',
|
||||
user_id: hotelToEdit?.user_id || '',
|
||||
name: hotelToEdit?.name || '',
|
||||
description: hotelToEdit?.description || '',
|
||||
rating: hotelToEdit?.rating || NaN,
|
||||
link: hotelToEdit?.link || '',
|
||||
check_in: hotelToEdit?.check_in || null,
|
||||
check_out: hotelToEdit?.check_out || null,
|
||||
reservation_number: hotelToEdit?.reservation_number || '',
|
||||
price: hotelToEdit?.price || null,
|
||||
latitude: hotelToEdit?.latitude || null,
|
||||
longitude: hotelToEdit?.longitude || null,
|
||||
location: hotelToEdit?.location || '',
|
||||
is_public: hotelToEdit?.is_public || false,
|
||||
collection: hotelToEdit?.collection || '',
|
||||
created_at: hotelToEdit?.created_at || '',
|
||||
updated_at: hotelToEdit?.updated_at || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Set full start and end dates from collection
|
||||
if (collection.start_date && collection.end_date) {
|
||||
fullStartDate = `${collection.start_date}T00:00`;
|
||||
fullEndDate = `${collection.end_date}T23:59`;
|
||||
}
|
||||
|
||||
// Handle rating change
|
||||
$: {
|
||||
if (!hotel.rating) {
|
||||
hotel.rating = NaN;
|
||||
}
|
||||
}
|
||||
|
||||
// Show modal on mount
|
||||
onMount(() => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) modal.showModal();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
// Close modal on escape key press
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') close();
|
||||
}
|
||||
|
||||
// Handle form submission (save hotel)
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (hotel.check_in && !hotel.check_out) {
|
||||
const checkInDate = new Date(hotel.check_in);
|
||||
checkInDate.setDate(checkInDate.getDate() + 1);
|
||||
hotel.check_out = checkInDate.toISOString();
|
||||
}
|
||||
|
||||
if (hotel.check_in && hotel.check_out && hotel.check_in > hotel.check_out) {
|
||||
addToast('error', $t('adventures.start_before_end_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create or update hotel
|
||||
const url = hotel.id === '' ? '/api/hotels' : `/api/hotels/${hotel.id}`;
|
||||
const method = hotel.id === '' ? 'POST' : 'PATCH';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hotel)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.id) {
|
||||
hotel = data as Hotel;
|
||||
const toastMessage =
|
||||
hotel.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated';
|
||||
addToast('success', $t(toastMessage));
|
||||
dispatch('save', hotel);
|
||||
} else {
|
||||
const errorMessage =
|
||||
hotel.id === '' ? 'adventures.adventure_create_error' : 'adventures.adventure_update_error';
|
||||
addToast('error', $t(errorMessage));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-2xl">
|
||||
{hotelToEdit
|
||||
? $t('transportation.edit_transportation')
|
||||
: $t('transportation.new_transportation')}
|
||||
</h3>
|
||||
<div class="modal-action items-center">
|
||||
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
|
||||
<!-- Basic Information Section -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.basic_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name">
|
||||
{$t('adventures.name')}<span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
bind:value={hotel.name}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description">{$t('adventures.description')}</label><br />
|
||||
<MarkdownEditor bind:text={hotel.description} editor_height={'h-32'} />
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label for="rating">{$t('adventures.rating')}</label><br />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
hidden
|
||||
bind:value={hotel.rating}
|
||||
id="rating"
|
||||
name="rating"
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
<div class="rating -ml-3 mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="rating-hidden"
|
||||
checked={Number.isNaN(hotel.rating)}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (hotel.rating = 1)}
|
||||
checked={hotel.rating === 1}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (hotel.rating = 2)}
|
||||
checked={hotel.rating === 2}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (hotel.rating = 3)}
|
||||
checked={hotel.rating === 3}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (hotel.rating = 4)}
|
||||
checked={hotel.rating === 4}
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
name="rating-2"
|
||||
class="mask mask-star-2 bg-orange-400"
|
||||
on:click={() => (hotel.rating = 5)}
|
||||
checked={hotel.rating === 5}
|
||||
/>
|
||||
{#if hotel.rating}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error ml-2"
|
||||
on:click={() => (hotel.rating = NaN)}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Link -->
|
||||
<div>
|
||||
<label for="link">{$t('adventures.link')}</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
name="link"
|
||||
bind:value={hotel.link}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.date_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- Start Date -->
|
||||
<div>
|
||||
<label for="date">
|
||||
{$t('adventures.start_date')}
|
||||
</label>
|
||||
|
||||
{#if collection && collection.start_date && collection.end_date}<label
|
||||
class="label cursor-pointer flex items-start space-x-2"
|
||||
>
|
||||
<span class="label-text">{$t('adventures.date_constrain')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="constrain_dates"
|
||||
name="constrain_dates"
|
||||
on:change={() => (constrainDates = !constrainDates)}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="date"
|
||||
name="date"
|
||||
bind:value={hotel.check_in}
|
||||
min={constrainDates ? fullStartDate : ''}
|
||||
max={constrainDates ? fullEndDate : ''}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Date -->
|
||||
{#if hotel.check_in}
|
||||
<div>
|
||||
<label for="end_date">
|
||||
{$t('adventures.end_date')}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="end_date"
|
||||
name="end_date"
|
||||
min={constrainDates ? hotel.check_in : ''}
|
||||
max={constrainDates ? fullEndDate : ''}
|
||||
bind:value={hotel.check_out}
|
||||
class="input input-bordered w-full max-w-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Information -->
|
||||
<LocationDropdown bind:item={hotel} />
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('notes.save')}
|
||||
</button>
|
||||
<button type="button" class="btn" on:click={close}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
|
@ -1,32 +1,81 @@
|
|||
<script lang="ts">
|
||||
let immichSearchValue: string = '';
|
||||
let searchOrSelect: string = 'search';
|
||||
let immichError: string = '';
|
||||
let immichNext: string = '';
|
||||
let immichPage: number = 1;
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImmichLogo from '$lib/assets/immich.svg';
|
||||
import type { Adventure, ImmichAlbum } from '$lib/types';
|
||||
import { debounce } from '$lib';
|
||||
|
||||
let immichImages: any[] = [];
|
||||
let immichSearchValue: string = '';
|
||||
let searchCategory: 'search' | 'date' | 'album' = 'date';
|
||||
let immichError: string = '';
|
||||
let immichNextURL: string = '';
|
||||
let loading = false;
|
||||
|
||||
export let adventure: Adventure | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let albums: ImmichAlbum[] = [];
|
||||
let currentAlbum: string = '';
|
||||
|
||||
let selectedDate: string =
|
||||
(adventure as Adventure | null)?.visits
|
||||
.map((v) => new Date(v.end_date || v.start_date))
|
||||
.sort((a, b) => +b - +a)[0]
|
||||
?.toISOString()
|
||||
?.split('T')[0] || '';
|
||||
if (!selectedDate) {
|
||||
selectedDate = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentAlbum) {
|
||||
immichImages = [];
|
||||
fetchAlbumAssets(currentAlbum);
|
||||
} else {
|
||||
immichImages = [];
|
||||
} else if (searchCategory === 'date' && selectedDate) {
|
||||
searchImmich();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreImmich() {
|
||||
// The next URL returned by our API is a absolute url to API, but we need to use the relative path, to use the frontend api proxy.
|
||||
const url = new URL(immichNextURL);
|
||||
immichNextURL = url.pathname + url.search;
|
||||
return fetchAssets(immichNextURL, true);
|
||||
}
|
||||
|
||||
async function fetchAssets(url: string, usingNext = false) {
|
||||
loading = true;
|
||||
try {
|
||||
let res = await fetch(url);
|
||||
immichError = '';
|
||||
if (!res.ok) {
|
||||
let data = await res.json();
|
||||
let errorMessage = data.message;
|
||||
console.error('Error in handling fetchAsstes', errorMessage);
|
||||
immichError = $t(data.code);
|
||||
} else {
|
||||
let data = await res.json();
|
||||
if (data.results && data.results.length > 0) {
|
||||
if (usingNext) {
|
||||
immichImages = [...immichImages, ...data.results];
|
||||
} else {
|
||||
immichImages = data.results;
|
||||
}
|
||||
} else {
|
||||
immichError = $t('immich.no_items_found');
|
||||
}
|
||||
|
||||
immichNextURL = data.next || '';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlbumAssets(album_id: string) {
|
||||
let res = await fetch(`/api/integrations/immich/albums/${album_id}`);
|
||||
if (res.ok) {
|
||||
let data = await res.json();
|
||||
immichNext = '';
|
||||
immichImages = data;
|
||||
}
|
||||
return fetchAssets(`/api/integrations/immich/albums/${album_id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -37,66 +86,23 @@
|
|||
}
|
||||
});
|
||||
|
||||
let immichImages: any[] = [];
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImmichLogo from '$lib/assets/immich.svg';
|
||||
import type { ImmichAlbum } from '$lib/types';
|
||||
|
||||
async function searchImmich() {
|
||||
let res = await fetch(`/api/integrations/immich/search/?query=${immichSearchValue}`);
|
||||
if (!res.ok) {
|
||||
let data = await res.json();
|
||||
let errorMessage = data.message;
|
||||
console.log(errorMessage);
|
||||
immichError = $t(data.code);
|
||||
} else {
|
||||
let data = await res.json();
|
||||
console.log(data);
|
||||
immichError = '';
|
||||
if (data.results && data.results.length > 0) {
|
||||
immichImages = data.results;
|
||||
} else {
|
||||
immichError = $t('immich.no_items_found');
|
||||
}
|
||||
if (data.next) {
|
||||
immichNext =
|
||||
'/api/integrations/immich/search?query=' +
|
||||
immichSearchValue +
|
||||
'&page=' +
|
||||
(immichPage + 1);
|
||||
} else {
|
||||
immichNext = '';
|
||||
}
|
||||
function buildQueryParams() {
|
||||
let params = new URLSearchParams();
|
||||
if (immichSearchValue && searchCategory === 'search') {
|
||||
params.append('query', immichSearchValue);
|
||||
} else if (selectedDate && searchCategory === 'date') {
|
||||
params.append('date', selectedDate);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function loadMoreImmich() {
|
||||
let res = await fetch(immichNext);
|
||||
if (!res.ok) {
|
||||
let data = await res.json();
|
||||
let errorMessage = data.message;
|
||||
console.log(errorMessage);
|
||||
immichError = $t(data.code);
|
||||
} else {
|
||||
let data = await res.json();
|
||||
console.log(data);
|
||||
immichError = '';
|
||||
if (data.results && data.results.length > 0) {
|
||||
immichImages = [...immichImages, ...data.results];
|
||||
} else {
|
||||
immichError = $t('immich.no_items_found');
|
||||
}
|
||||
if (data.next) {
|
||||
immichNext =
|
||||
'/api/integrations/immich/search?query=' +
|
||||
immichSearchValue +
|
||||
'&page=' +
|
||||
(immichPage + 1);
|
||||
immichPage++;
|
||||
} else {
|
||||
immichNext = '';
|
||||
}
|
||||
}
|
||||
const searchImmich = debounce(() => {
|
||||
_searchImmich();
|
||||
}, 500); // Debounce the search function to avoid multiple requests on every key press
|
||||
|
||||
async function _searchImmich() {
|
||||
immichImages = [];
|
||||
return fetchAssets(`/api/integrations/immich/search/?${buildQueryParams()}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -111,20 +117,27 @@
|
|||
on:click={() => (currentAlbum = '')}
|
||||
type="radio"
|
||||
class="join-item btn"
|
||||
bind:group={searchOrSelect}
|
||||
bind:group={searchCategory}
|
||||
value="search"
|
||||
aria-label="Search"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
class="join-item btn"
|
||||
bind:group={searchOrSelect}
|
||||
value="select"
|
||||
bind:group={searchCategory}
|
||||
value="date"
|
||||
aria-label="Show by date"
|
||||
/>
|
||||
<input
|
||||
type="radio"
|
||||
class="join-item btn"
|
||||
bind:group={searchCategory}
|
||||
value="album"
|
||||
aria-label="Select Album"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{#if searchOrSelect === 'search'}
|
||||
{#if searchCategory === 'search'}
|
||||
<form on:submit|preventDefault={searchImmich}>
|
||||
<input
|
||||
type="text"
|
||||
|
@ -134,7 +147,13 @@
|
|||
/>
|
||||
<button type="submit" class="btn btn-neutral mt-2">Search</button>
|
||||
</form>
|
||||
{:else}
|
||||
{:else if searchCategory === 'date'}
|
||||
<input
|
||||
type="date"
|
||||
bind:value={selectedDate}
|
||||
class="input input-bordered w-full max-w-xs mt-2"
|
||||
/>
|
||||
{:else if searchCategory === 'album'}
|
||||
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
|
||||
<option value="" disabled selected>Select an Album</option>
|
||||
{#each albums as album}
|
||||
|
@ -147,14 +166,25 @@
|
|||
|
||||
<p class="text-red-500">{immichError}</p>
|
||||
<div class="flex flex-wrap gap-4 mr-4 mt-2">
|
||||
{#if loading}
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[100] w-24 h-24"
|
||||
>
|
||||
<span class="loading loading-spinner w-24 h-24"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each immichImages as image}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}>
|
||||
<!-- svelte-ignore a11y-img-redundant-alt -->
|
||||
<img
|
||||
src={`/immich/${image.id}`}
|
||||
alt="Image from Immich"
|
||||
class="h-24 w-24 object-cover rounded-md"
|
||||
/>
|
||||
<h4>
|
||||
{image.fileCreatedAt?.split('T')[0] || 'Unknown'}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
|
@ -168,7 +198,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if immichNext}
|
||||
{#if immichNextURL}
|
||||
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
336
frontend/src/lib/components/LocationDropdown.svelte
Normal file
336
frontend/src/lib/components/LocationDropdown.svelte
Normal file
|
@ -0,0 +1,336 @@
|
|||
<script lang="ts">
|
||||
import { appVersion } from '$lib/config';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { Adventure, Hotel, OpenStreetMapPlace, Point, ReverseGeocode } from '$lib/types';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
|
||||
|
||||
export let item: Adventure | Hotel;
|
||||
export let triggerMarkVisted: boolean = false;
|
||||
|
||||
let reverseGeocodePlace: ReverseGeocode | null = null;
|
||||
let markers: Point[] = [];
|
||||
|
||||
let query: string = '';
|
||||
let is_custom_location: boolean = false;
|
||||
let willBeMarkedVisited: boolean = false;
|
||||
let previousCoords: { lat: number; lng: number } | null = null;
|
||||
let old_display_name: string = '';
|
||||
let places: OpenStreetMapPlace[] = [];
|
||||
let noPlaces: boolean = false;
|
||||
|
||||
$: if (markers.length > 0) {
|
||||
const newLat = Math.round(markers[0].lngLat.lat * 1e6) / 1e6;
|
||||
const newLng = Math.round(markers[0].lngLat.lng * 1e6) / 1e6;
|
||||
|
||||
if (!previousCoords || previousCoords.lat !== newLat || previousCoords.lng !== newLng) {
|
||||
item.latitude = newLat;
|
||||
item.longitude = newLng;
|
||||
previousCoords = { lat: newLat, lng: newLng };
|
||||
reverseGeocode();
|
||||
}
|
||||
|
||||
if (!item.name) {
|
||||
item.name = markers[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
console.log(triggerMarkVisted);
|
||||
}
|
||||
|
||||
$: if (triggerMarkVisted && willBeMarkedVisited) {
|
||||
markVisited();
|
||||
triggerMarkVisted = false;
|
||||
}
|
||||
|
||||
$: {
|
||||
is_custom_location = Boolean(
|
||||
item.location != reverseGeocodePlace?.display_name && item.location
|
||||
);
|
||||
}
|
||||
|
||||
if (item.longitude && item.latitude) {
|
||||
markers = [];
|
||||
markers = [
|
||||
{
|
||||
lngLat: { lng: item.longitude, lat: item.latitude },
|
||||
location: item.location || '',
|
||||
name: item.name,
|
||||
activity_type: ''
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
$: {
|
||||
if ('visits' in item) {
|
||||
willBeMarkedVisited = false; // Reset before evaluating
|
||||
|
||||
const today = new Date(); // Cache today's date to avoid redundant calculations
|
||||
|
||||
for (const visit of item.visits) {
|
||||
const startDate = new Date(visit.start_date);
|
||||
const endDate = visit.end_date ? new Date(visit.end_date) : null;
|
||||
|
||||
// If the visit has both a start date and an end date, check if it started by today
|
||||
if (startDate && endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
|
||||
// If the visit has a start date but no end date, check if it started by today
|
||||
if (startDate && !endDate && startDate <= today) {
|
||||
willBeMarkedVisited = true;
|
||||
break; // Exit the loop since we've determined the result
|
||||
}
|
||||
}
|
||||
|
||||
console.log('WMBV:', willBeMarkedVisited);
|
||||
}
|
||||
}
|
||||
|
||||
async function markVisited() {
|
||||
console.log(reverseGeocodePlace);
|
||||
if (reverseGeocodePlace) {
|
||||
if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) {
|
||||
let region_res = await fetch(`/api/visitedregion`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ region: reverseGeocodePlace.region_id })
|
||||
});
|
||||
if (region_res.ok) {
|
||||
reverseGeocodePlace.region_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`);
|
||||
}
|
||||
}
|
||||
if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) {
|
||||
let city_res = await fetch(`/api/visitedcity`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: reverseGeocodePlace.city_id })
|
||||
});
|
||||
if (city_res.ok) {
|
||||
reverseGeocodePlace.city_visited = true;
|
||||
addToast('success', `Visit to ${reverseGeocodePlace.city} marked`);
|
||||
} else {
|
||||
addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addMarker(e: CustomEvent<any>) {
|
||||
markers = [];
|
||||
markers = [
|
||||
...markers,
|
||||
{
|
||||
lngLat: e.detail.lngLat,
|
||||
name: '',
|
||||
location: '',
|
||||
activity_type: ''
|
||||
}
|
||||
];
|
||||
console.log(markers);
|
||||
}
|
||||
|
||||
async function geocode(e: Event | null) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (!query) {
|
||||
alert($t('adventures.no_location'));
|
||||
return;
|
||||
}
|
||||
let res = await fetch(`https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2`, {
|
||||
headers: {
|
||||
'User-Agent': `AdventureLog / ${appVersion} `
|
||||
}
|
||||
});
|
||||
console.log(res);
|
||||
let data = (await res.json()) as OpenStreetMapPlace[];
|
||||
places = data;
|
||||
if (data.length === 0) {
|
||||
noPlaces = true;
|
||||
} else {
|
||||
noPlaces = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reverseGeocode(force_update: boolean = false) {
|
||||
let res = await fetch(
|
||||
`/api/reverse-geocode/reverse_geocode/?lat=${item.latitude}&lon=${item.longitude}`
|
||||
);
|
||||
let data = await res.json();
|
||||
if (data.error) {
|
||||
console.log(data.error);
|
||||
reverseGeocodePlace = null;
|
||||
return;
|
||||
}
|
||||
reverseGeocodePlace = data;
|
||||
|
||||
console.log(reverseGeocodePlace);
|
||||
console.log(is_custom_location);
|
||||
|
||||
if (
|
||||
reverseGeocodePlace &&
|
||||
reverseGeocodePlace.display_name &&
|
||||
(!is_custom_location || force_update)
|
||||
) {
|
||||
old_display_name = reverseGeocodePlace.display_name;
|
||||
item.location = reverseGeocodePlace.display_name;
|
||||
}
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
function clearMap() {
|
||||
console.log('CLEAR');
|
||||
markers = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
{$t('adventures.location_information')}
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
|
||||
<div>
|
||||
<label for="latitude">{$t('adventures.location')}</label><br />
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
bind:value={item.location}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
{#if is_custom_location}
|
||||
<button
|
||||
class="btn btn-primary ml-2"
|
||||
type="button"
|
||||
on:click={() => (item.location = reverseGeocodePlace?.display_name)}
|
||||
>{$t('adventures.set_to_pin')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form on:submit={geocode} class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('adventures.search_for_location')}
|
||||
class="input input-bordered w-full max-w-xs mb-2"
|
||||
id="search"
|
||||
name="search"
|
||||
bind:value={query}
|
||||
/>
|
||||
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button>
|
||||
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
|
||||
>{$t('adventures.clear_map')}</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
{#if places.length > 0}
|
||||
<div class="mt-4 max-w-full">
|
||||
<h3 class="font-bold text-lg mb-4">{$t('adventures.search_results')}</h3>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{#each places as place}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
|
||||
on:click={() => {
|
||||
markers = [
|
||||
{
|
||||
lngLat: { lng: Number(place.lon), lat: Number(place.lat) },
|
||||
location: place.display_name,
|
||||
name: place.name,
|
||||
activity_type: place.type
|
||||
}
|
||||
];
|
||||
}}
|
||||
>
|
||||
{place.display_name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if noPlaces}
|
||||
<p class="text-error text-lg">{$t('adventures.no_results')}</p>
|
||||
{/if}
|
||||
<!-- </div> -->
|
||||
<div>
|
||||
<MapLibre
|
||||
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
|
||||
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
|
||||
standardControls
|
||||
>
|
||||
<!-- MapEvents gives you access to map events even from other components inside the map,
|
||||
where you might not have access to the top-level `MapLibre` component. In this case
|
||||
it would also work to just use on:click on the MapLibre component itself. -->
|
||||
<MapEvents on:click={addMarker} />
|
||||
|
||||
{#each markers as marker}
|
||||
<DefaultMarker lngLat={marker.lngLat} />
|
||||
{/each}
|
||||
</MapLibre>
|
||||
{#if reverseGeocodePlace}
|
||||
<div class="mt-2">
|
||||
<p>
|
||||
{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
</p>
|
||||
<p>
|
||||
{reverseGeocodePlace.region}:
|
||||
{reverseGeocodePlace.region_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
{#if reverseGeocodePlace.city}
|
||||
<p>
|
||||
{reverseGeocodePlace.city}:
|
||||
{reverseGeocodePlace.city_visited
|
||||
? $t('adventures.visited')
|
||||
: $t('adventures.not_visited')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
|
||||
<button type="button" class="btn btn-neutral" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
|
||||
<div role="alert" class="alert alert-info mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>{reverseGeocodePlace.city
|
||||
? reverseGeocodePlace.city + ', '
|
||||
: ''}{reverseGeocodePlace.region},
|
||||
{reverseGeocodePlace.country}
|
||||
{$t('adventures.will_be_marked')}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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();
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
<!-- Card Actions -->
|
||||
<div class="card-actions justify-center mt-6">
|
||||
{#if !sharing}
|
||||
<button class="btn btn-primary" on:click={() => goto(`/user/${user.uuid}`)}>
|
||||
<button class="btn btn-primary" on:click={() => goto(`/profile/${user.username}`)}>
|
||||
View Profile
|
||||
</button>
|
||||
{:else if shared_with && !shared_with.includes(user.uuid)}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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": "全部",
|
||||
|
|
17
frontend/src/routes/admin/+page.server.ts
Normal file
17
frontend/src/routes/admin/+page.server.ts
Normal file
|
@ -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/');
|
||||
};
|
|
@ -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 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if adventure.user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div class="w-8 rounded-full">
|
||||
<img src={adventure.user.profile_pic} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-8 rounded-full">
|
||||
<span class="text-lg"
|
||||
>{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)
|
||||
: ''}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if adventure.user.public_profile}
|
||||
<a href={`/profile/${adventure.user.username}`} class="text-base font-medium">
|
||||
{adventure.user.first_name || adventure.user.username}{' '}
|
||||
{adventure.user.last_name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-base font-medium">
|
||||
{adventure.user.first_name || adventure.user.username}{' '}
|
||||
{adventure.user.last_name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -241,6 +276,7 @@
|
|||
>{adventure.is_public ? 'Public' : 'Private'}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if adventure.location}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
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<Hotel>) {
|
||||
hotelToEdit = event.detail;
|
||||
isShowingHotelModal = true;
|
||||
}
|
||||
|
||||
function saveOrCreateAdventure(event: CustomEvent<Adventure>) {
|
||||
if (adventures.find((adventure) => adventure.id === event.detail.id)) {
|
||||
adventures = adventures.map((adventure) => {
|
||||
|
@ -355,6 +367,22 @@
|
|||
}
|
||||
isShowingTransportationModal = false;
|
||||
}
|
||||
|
||||
function saveOrCreateHotel(event: CustomEvent<Hotel>) {
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isShowingLinkModal}
|
||||
|
@ -376,6 +404,15 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowingHotelModal}
|
||||
<HotelModal
|
||||
{hotelToEdit}
|
||||
on:close={() => (isShowingHotelModal = false)}
|
||||
on:save={saveOrCreateHotel}
|
||||
{collection}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isAdventureModalOpen}
|
||||
<AdventureModal
|
||||
{adventureToEdit}
|
||||
|
@ -501,6 +538,16 @@
|
|||
>
|
||||
{$t('adventures.checklist')}</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={() => {
|
||||
isShowingHotelModal = true;
|
||||
newType = '';
|
||||
hotelToEdit = null;
|
||||
}}
|
||||
>
|
||||
{$t('adventures.hotel')}</button
|
||||
>
|
||||
|
||||
<!-- <button
|
||||
class="btn btn-primary"
|
||||
|
|
|
@ -20,11 +20,14 @@ export const load = (async (event) => {
|
|||
|
||||
let stats = null;
|
||||
|
||||
let res = await event.fetch(`${serverEndpoint}/api/stats/counts/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')}`
|
||||
let res = await event.fetch(
|
||||
`${serverEndpoint}/api/stats/counts/${event.locals.user.username}`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: `sessionid=${event.cookies.get('sessionid')}`
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch user stats');
|
||||
} else {
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, RequestEvent } from '../$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
|
||||
export const load: PageServerLoad = async (event: RequestEvent) => {
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
if (!event.locals.user || !event.cookies.get('sessionid')) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
let sessionId = event.cookies.get('sessionid');
|
||||
let stats = null;
|
||||
|
||||
let res = await event.fetch(`${endpoint}/api/stats/counts/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch user stats');
|
||||
} else {
|
||||
stats = await res.json();
|
||||
}
|
||||
|
||||
return {
|
||||
user: event.locals.user,
|
||||
stats
|
||||
};
|
||||
};
|
|
@ -1,112 +0,0 @@
|
|||
<script lang="ts">
|
||||
export let data;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let stats: {
|
||||
visited_country_count: number;
|
||||
total_regions: number;
|
||||
trips_count: number;
|
||||
adventure_count: number;
|
||||
visited_region_count: number;
|
||||
total_countries: number;
|
||||
visited_city_count: number;
|
||||
total_cities: number;
|
||||
} | null;
|
||||
|
||||
stats = data.stats || null;
|
||||
</script>
|
||||
|
||||
<section class="min-h-screen bg-base-100 py-8 px-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Profile Picture -->
|
||||
{#if data.user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
<img src={data.user.profile_pic} alt="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Name -->
|
||||
{#if data.user && data.user.first_name && data.user.last_name}
|
||||
<h1 class="text-4xl font-bold text-primary mt-4">
|
||||
{data.user.first_name}
|
||||
{data.user.last_name}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-lg text-base-content mt-2">{data.user.username}</p>
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if data.user && data.user.date_joined}
|
||||
<div class="mt-4 flex items-center text-center text-base-content">
|
||||
<p class="text-lg font-medium">{$t('profile.member_since')}</p>
|
||||
<div class="flex items-center ml-2">
|
||||
<iconify-icon icon="mdi:calendar" class="text-2xl text-primary"></iconify-icon>
|
||||
<p class="ml-2 text-lg">
|
||||
{new Date(data.user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
{#if stats}
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('profile.user_stats')}
|
||||
</h2>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-center">{stats.adventure_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.collections')}</div>
|
||||
<div class="stat-value text-center">{stats.trips_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.visited_country_count / stats.total_countries) * 100)}%
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{stats.visited_country_count}/{stats.total_countries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_regions')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}%
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{stats.visited_region_count}/{stats.total_regions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_cities')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{Math.round((stats.visited_city_count / stats.total_cities) * 100)}%
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{stats.visited_city_count}/{stats.total_cities}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile | AdventureLog</title>
|
||||
<meta name="description" content="{data.user.first_name}'s profile on AdventureLog." />
|
||||
</svelte:head>
|
39
frontend/src/routes/profile/[uuid]/+page.server.ts
Normal file
39
frontend/src/routes/profile/[uuid]/+page.server.ts
Normal file
|
@ -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
|
||||
};
|
||||
};
|
184
frontend/src/routes/profile/[uuid]/+page.svelte
Normal file
184
frontend/src/routes/profile/[uuid]/+page.svelte
Normal file
|
@ -0,0 +1,184 @@
|
|||
<script lang="ts">
|
||||
export let data;
|
||||
import AdventureCard from '$lib/components/AdventureCard.svelte';
|
||||
import CollectionCard from '$lib/components/CollectionCard.svelte';
|
||||
import type { Adventure, Collection, User } from '$lib/types.js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let stats: {
|
||||
visited_country_count: number;
|
||||
total_regions: number;
|
||||
trips_count: number;
|
||||
adventure_count: number;
|
||||
visited_region_count: number;
|
||||
total_countries: number;
|
||||
visited_city_count: number;
|
||||
total_cities: number;
|
||||
} | null;
|
||||
|
||||
const user: User = data.user;
|
||||
const adventures: Adventure[] = data.adventures;
|
||||
const collections: Collection[] = data.collections;
|
||||
stats = data.stats || null;
|
||||
</script>
|
||||
|
||||
<section class="min-h-screen bg-base-100 py-8 px-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<!-- Profile Picture -->
|
||||
{#if user.profile_pic}
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
<img src={user.profile_pic} alt="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- show first last initial -->
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="w-24 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2 shadow-md"
|
||||
>
|
||||
{#if user.first_name && user.last_name}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.first_name}+${user.last_name}&size=250`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={`https://eu.ui-avatars.com/api/?name=${user.username}&size=250`}
|
||||
alt="Profile"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Name -->
|
||||
{#if user && user.first_name && user.last_name}
|
||||
<h1 class="text-4xl font-bold text-primary mt-4">
|
||||
{user.first_name}
|
||||
{user.last_name}
|
||||
</h1>
|
||||
{/if}
|
||||
<p class="text-lg text-base-content mt-2">{user.username}</p>
|
||||
|
||||
<!-- Member Since -->
|
||||
{#if user && user.date_joined}
|
||||
<div class="mt-4 flex items-center text-center text-base-content">
|
||||
<p class="text-lg font-medium">{$t('profile.member_since')}</p>
|
||||
<div class="flex items-center ml-2">
|
||||
<iconify-icon icon="mdi:calendar" class="text-2xl text-primary"></iconify-icon>
|
||||
<p class="ml-2 text-lg">
|
||||
{new Date(user.date_joined).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
{#if stats}
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('profile.user_stats')}
|
||||
</h2>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow bg-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-center">{stats.adventure_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('navbar.collections')}</div>
|
||||
<div class="stat-value text-center">{stats.trips_count}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_countries')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_country_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_country_count / stats.total_countries) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_countries}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_regions')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_region_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_region_count / stats.total_regions) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_regions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-title">{$t('profile.visited_cities')}</div>
|
||||
<div class="stat-value text-center">
|
||||
{stats.visited_city_count}
|
||||
</div>
|
||||
<div class="stat-desc text-center">
|
||||
{Math.round((stats.visited_city_count / stats.total_cities) * 100)}% {$t(
|
||||
'adventures.of'
|
||||
)}
|
||||
{stats.total_cities}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Adventures Section -->
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('auth.user_adventures')}
|
||||
</h2>
|
||||
|
||||
{#if adventures && adventures.length === 0}
|
||||
<p class="text-lg text-center text-base-content">
|
||||
{$t('auth.no_public_adventures')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard {adventure} user={null} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Collections Section -->
|
||||
<div class="divider my-8"></div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-center mb-6 text-primary">
|
||||
{$t('auth.user_collections')}
|
||||
</h2>
|
||||
|
||||
{#if collections && collections.length === 0}
|
||||
<p class="text-lg text-center text-base-content">
|
||||
{$t('auth.no_public_collections')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each collections as collection}
|
||||
<CollectionCard {collection} type={''} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<svelte:head>
|
||||
<title>{user.first_name || user.username}'s Profile | AdventureLog</title>
|
||||
<meta name="description" content="User Profile" />
|
||||
</svelte:head>
|
BIN
frontend/static/backgrounds/adventurelog_showcase_6.webp
Normal file
BIN
frontend/static/backgrounds/adventurelog_showcase_6.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 760 KiB |
Loading…
Add table
Add a link
Reference in a new issue