Compare commits
306 commits
Author | SHA1 | Date | |
---|---|---|---|
|
4e96e529f4 | ||
|
cadea118d3 | ||
|
7a17e0e1d8 | ||
|
6516bc56ef | ||
|
a6b39f64d6 | ||
|
36f9022872 | ||
|
3b0ccdb6d3 | ||
|
9964398e25 | ||
|
df24316837 | ||
|
63e8e96d52 | ||
|
08cd3912c7 | ||
|
eef8c92e82 | ||
|
3306b799df | ||
|
8b108c5797 | ||
|
93a489a778 | ||
|
44ea7dff0c | ||
|
7ec4e5d0f5 | ||
|
380fe1364f | ||
|
69631848cf | ||
|
7f285f2b1d | ||
|
4f7d408460 | ||
|
aed76a5689 | ||
|
a556c49147 | ||
|
ea4b6bd715 | ||
|
2fb1548f9f | ||
|
be8ac67161 | ||
|
0636f0ec8c | ||
|
09ffb6cdff | ||
|
930c98a607 | ||
|
cee9345bf1 | ||
|
ced1f94473 | ||
|
da65235277 | ||
|
ab5082317e | ||
|
c2074c1581 | ||
|
977843cbc6 | ||
|
b0e8000cf8 | ||
|
b5931c6c23 | ||
|
151c76dbd1 | ||
|
d4c76f8718 | ||
|
badeac867d | ||
|
a99553ba0d | ||
|
14eb4ca802 | ||
|
7eb96bcc2a | ||
|
24cf3f844d | ||
|
c60ced09c4 | ||
|
05db98f968 | ||
|
3f9a6767bd | ||
|
f6b9802ba7 | ||
|
71ff217323 | ||
|
ef5dca79e5 | ||
|
d9070e68bb | ||
|
f72fb50eb0 | ||
|
b59dcade3b | ||
|
164627b35b | ||
|
3f89fad67f | ||
|
871e265001 | ||
|
2cab28e921 | ||
|
8a46bd7ed3 | ||
|
f36de76501 | ||
|
55f501d939 | ||
|
0037d037cf | ||
|
19baf6ab35 | ||
|
20cdc2405f | ||
|
297eb2916a | ||
|
27a27545ca | ||
|
06a5bb06b3 | ||
|
c0f2d060db | ||
|
2d7b6c85c9 | ||
|
5d0132a2a8 | ||
|
39c664ab1a | ||
|
d91a4fbe98 | ||
|
90624664f4 | ||
|
5f9d0cd207 | ||
|
36a2e59d52 | ||
|
af5be0bc8f | ||
|
16840a6040 | ||
|
ebee6f52e8 | ||
|
9d817a5ce9 | ||
|
6fdfb86297 | ||
|
c0c91c8472 | ||
|
d6ab4e9f64 | ||
|
4f342425ab | ||
|
6c338b8c0f | ||
|
2f7103f5f3 | ||
|
d0c1ecd394 | ||
|
cf108ecd3a | ||
|
50a80a8116 | ||
|
442a7724a0 | ||
|
b336a24401 | ||
|
45e195a84e | ||
|
937db00226 | ||
|
0838a41156 | ||
|
06787bccf6 | ||
|
f95afdc35c | ||
|
c159e176b3 | ||
|
b50447b1a2 | ||
|
92f9bf6908 | ||
|
48df9ee56d | ||
|
64bfda6bf0 | ||
|
8f1c60a440 | ||
|
806efd71bf | ||
|
e89f2a947e | ||
|
724aec1f3a | ||
|
514ee85767 | ||
|
53d370297e | ||
|
ae16c12251 | ||
|
4e8024051c | ||
|
9d69935f22 | ||
|
787ac4a8b3 | ||
|
81006af027 | ||
|
3acfc9f228 | ||
|
8be723b9ad | ||
|
a7128756bd | ||
|
d41d46f15d | ||
|
0adfdfa62f | ||
|
fa52af8ad1 | ||
|
6be4acb196 | ||
|
9df0338c3d | ||
|
069bcfb58a | ||
|
1551fba9ab | ||
|
56b8b55b84 | ||
|
57aa2c9916 | ||
|
37866660d3 | ||
|
d34a9001c0 | ||
|
bcd1f02131 | ||
|
f2246921d4 | ||
|
575669aedf | ||
|
9eaaadc0f2 | ||
|
3f6aa67b3f | ||
|
5e6d5305cc | ||
|
4fb25f63fd | ||
|
40f54529a4 | ||
|
7707ac7769 | ||
|
b25bf4af27 | ||
|
f355ba48e2 | ||
|
e56335d30f | ||
|
c123231bab | ||
|
9e304f81fe | ||
|
1997f164b8 | ||
|
c0b9013576 | ||
|
ce9faa28f8 | ||
|
68ba3c4b4d | ||
|
042d034594 | ||
|
ec2b285d50 | ||
|
03c76adc6d | ||
|
4404064263 | ||
|
7fddca6fb0 | ||
|
809cf98169 | ||
|
d3d74f9f35 | ||
|
e856a57498 | ||
|
0d5792a99a | ||
|
2ccb8f5e0b | ||
|
0d800e8986 | ||
|
3d9f4545a1 | ||
|
0a7db8985d | ||
|
c828f86570 | ||
|
a1062e72cf | ||
|
84cd136401 | ||
|
ac32f9ac5b | ||
|
d52e302e9b | ||
|
14e71626f6 | ||
|
f96b6f5f65 | ||
|
5dfe39468a | ||
|
a991f54d0a | ||
|
274dafc47d | ||
|
5f19670ed9 | ||
|
bd9f3fc494 | ||
|
d87d0e807f | ||
|
8b41a0f5a4 | ||
|
9435ccfa5a | ||
|
890332f4b6 | ||
|
049c229799 | ||
|
f15d7bfd1e | ||
|
7c3c139e61 | ||
|
1ff116ed00 | ||
|
b0e8c025fc | ||
|
feeb682e14 | ||
|
6de737bbf8 | ||
|
6ca674ff7e | ||
|
908d31a4a3 | ||
|
4af80eb584 | ||
|
d9d754a87c | ||
|
abc2d86dcf | ||
|
330fabb3e0 | ||
|
8538aa0b7c | ||
|
c9fa1d55f7 | ||
|
ab189c8aff | ||
|
1323d91e32 | ||
|
b30d6df964 | ||
|
89c4f1058a | ||
|
13bc748d0d | ||
|
c6177c5a05 | ||
|
e9c333642f | ||
|
07c0c36ab8 | ||
|
b712d10d7e | ||
|
a5e44d29e6 | ||
|
a91018d792 | ||
|
9f86688fe9 | ||
|
4ce7ed7045 | ||
|
b74fe90512 | ||
|
73c664e549 | ||
|
5e1f17fe2e | ||
|
f3f75368df | ||
|
ab12a2e7d8 | ||
|
cbb8c6283b | ||
|
0af0218a34 | ||
|
04f9227ae6 | ||
|
3caebd37dd | ||
|
311e2847cb | ||
|
2c50ca0b1a | ||
|
bbad7b890c | ||
|
5c109bbbaf | ||
|
c93d4865ce | ||
|
8d0490fd81 | ||
|
77be046f19 | ||
|
827b150965 | ||
|
7442bd70cd | ||
|
645cc9728b | ||
|
d99ca18e1c | ||
|
39104b3fef | ||
|
5bdadd0f88 | ||
|
f1f1cda799 | ||
|
f31db982ce | ||
|
f6097a2d60 | ||
|
5f7bf52758 | ||
|
911ce67d9f | ||
|
932036bc8b | ||
|
cd494fefee | ||
|
5136122ed9 | ||
|
e40ea028d0 | ||
|
56bbbb0ffb | ||
|
228f79dbc3 | ||
|
e44c153f9b | ||
|
3a8776c000 | ||
|
b8aa96b5b3 | ||
|
be0e56728a | ||
|
85b4db87ec | ||
|
c12f94855d | ||
|
6942f5e1bb | ||
|
7499722867 | ||
|
1ea4022e80 | ||
|
a8502884dc | ||
|
b29461f29d | ||
|
983a038420 | ||
|
233d2be63f | ||
|
64377c0300 | ||
|
0f36f34bfb | ||
|
ea85c5fc5a | ||
|
9a825e56e4 | ||
|
9043ee9565 | ||
|
a11400fa98 | ||
|
44a260b5b6 | ||
|
49f7bf27e8 | ||
|
dd01ada61e | ||
|
a974c8c275 | ||
|
988cdc12e6 | ||
|
548d43b563 | ||
|
23426012af | ||
|
2b031f51ac | ||
|
d7496df100 | ||
|
6f489b2734 | ||
|
a09b3f379f | ||
|
937c3c6a68 | ||
|
43b8275fc1 | ||
|
3b5240dffe | ||
|
d2933854ff | ||
|
0ce101594f | ||
|
b166058d36 | ||
|
15d34c88dc | ||
|
af2778ff61 | ||
|
2982a4044d | ||
|
658764fb58 | ||
|
847193a0ae | ||
|
8531855f46 | ||
|
af8a9acbae | ||
|
8041f67ba1 | ||
|
cd833884e8 | ||
|
47c219affd | ||
|
cde293c4bd | ||
|
04c3402e14 | ||
|
b4c5e22662 | ||
|
7d5750049b | ||
|
16a7772003 | ||
|
731b7e9db1 | ||
|
ca4ef79837 | ||
|
13d3b24ec2 | ||
|
113d41ca30 | ||
|
b71109fd09 | ||
|
a3cd940065 | ||
|
6eebd5b70a | ||
|
44ede92b92 | ||
|
fe25f8e2c8 | ||
|
794df82ec6 | ||
|
db63b6e7d8 | ||
|
4ccfa6e42c | ||
|
f79b06f6b3 | ||
|
1042a3edcc | ||
|
771579ef3d | ||
|
dbd417ca87 | ||
|
6e1fbbfc3a | ||
|
76ee4c73e0 | ||
|
f554bb8777 | ||
|
876c5e83b4 | ||
|
1dc8e10758 | ||
|
9fd2a142cb | ||
|
a899579a02 |
47
.env.example
Normal file
|
@ -0,0 +1,47 @@
|
|||
# 🌐 Frontend
|
||||
PUBLIC_SERVER_URL=http://server:8000 # PLEASE DON'T CHANGE :) - Should be the service name of the backend with port 8000, even if you change the port in the backend service. Only change if you are using a custom more complex setup.
|
||||
ORIGIN=http://localhost:8015
|
||||
BODY_SIZE_LIMIT=Infinity
|
||||
FRONTEND_PORT=8015
|
||||
|
||||
# 🐘 PostgreSQL Database
|
||||
PGHOST=db
|
||||
POSTGRES_DB=database
|
||||
POSTGRES_USER=adventure
|
||||
POSTGRES_PASSWORD=changeme123
|
||||
|
||||
# 🔒 Django Backend
|
||||
SECRET_KEY=changeme123
|
||||
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
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost:8016,http://localhost:8015
|
||||
DEBUG=False
|
||||
FRONTEND_URL=http://localhost:8015 # Used for email generation. This should be the url of the frontend
|
||||
BACKEND_PORT=8016
|
||||
|
||||
# Optional: use Google Maps integration
|
||||
# https://adventurelog.app/docs/configuration/google_maps_integration.html
|
||||
# GOOGLE_MAPS_API_KEY=your_google_maps_api_key
|
||||
|
||||
# Optional: disable registration
|
||||
# https://adventurelog.app/docs/configuration/disable_registration.html
|
||||
DISABLE_REGISTRATION=False
|
||||
# DISABLE_REGISTRATION_MESSAGE=Registration is disabled for this instance of AdventureLog.
|
||||
|
||||
# Optional: Use email
|
||||
# https://adventurelog.app/docs/configuration/email.html
|
||||
# EMAIL_BACKEND=email
|
||||
# EMAIL_HOST=smtp.gmail.com
|
||||
# EMAIL_USE_TLS=True
|
||||
# EMAIL_PORT=587
|
||||
# EMAIL_USE_SSL=False
|
||||
# EMAIL_HOST_USER=user
|
||||
# EMAIL_HOST_PASSWORD=password
|
||||
# DEFAULT_FROM_EMAIL=user@example.com
|
||||
|
||||
# Optional: Use Umami for analytics
|
||||
# https://adventurelog.app/docs/configuration/analytics.html
|
||||
# PUBLIC_UMAMI_SRC=https://cloud.umami.is/script.js # If you are using the hosted version of Umami
|
||||
# PUBLIC_UMAMI_WEBSITE_ID=
|
16
.github/.docker-compose-database.yml
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
db:
|
||||
image: postgis/postgis:15-3.3
|
||||
container_name: adventurelog-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: database
|
||||
POSTGRES_USER: adventure
|
||||
POSTGRES_PASSWORD: changeme123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
1
.github/FUNDING.yml
vendored
|
@ -1 +1,2 @@
|
|||
github: seanmorley15
|
||||
buy_me_a_coffee: seanmorley15
|
||||
|
|
64
.github/workflows/backend-test.yml
vendored
Normal file
|
@ -0,0 +1,64 @@
|
|||
name: Test Backend
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/server/**'
|
||||
- '.github/workflows/backend-test.yml'
|
||||
push:
|
||||
paths:
|
||||
- 'backend/server/**'
|
||||
- '.github/workflows/backend-test.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: set up python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt update -q
|
||||
sudo apt install -y -q \
|
||||
python3-gdal
|
||||
|
||||
- name: start database
|
||||
run: |
|
||||
docker compose -f .github/.docker-compose-database.yml up -d
|
||||
|
||||
- name: install python libreries
|
||||
working-directory: backend/server
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: run server
|
||||
working-directory: backend/server
|
||||
env:
|
||||
PGHOST: "127.0.0.1"
|
||||
PGDATABASE: "database"
|
||||
PGUSER: "adventure"
|
||||
PGPASSWORD: "changeme123"
|
||||
SECRET_KEY: "changeme123"
|
||||
DJANGO_ADMIN_USERNAME: "admin"
|
||||
DJANGO_ADMIN_PASSWORD: "admin"
|
||||
DJANGO_ADMIN_EMAIL: "admin@example.com"
|
||||
PUBLIC_URL: "http://localhost:8000"
|
||||
CSRF_TRUSTED_ORIGINS: "http://localhost:5173,http://localhost:8000"
|
||||
DEBUG: "True"
|
||||
FRONTEND_URL: "http://localhost:5173"
|
||||
run: |
|
||||
python manage.py migrate
|
||||
python manage.py runserver &
|
||||
|
||||
- name: wait for backend to boot
|
||||
run: >
|
||||
curl -fisS --retry 60 --retry-delay 1 --retry-all-errors
|
||||
http://localhost:8000/
|
32
.github/workflows/frontend-test.yml
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
name: Test Frontend
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-test.yml"
|
||||
push:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-test.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: install dependencies
|
||||
working-directory: frontend
|
||||
run: npm i
|
||||
|
||||
- name: build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
# Ignore everything in the .venv folder
|
||||
.venv/
|
||||
.vscode/settings.json
|
||||
.pnpm-store/
|
||||
.pnpm-store/
|
||||
.env
|
||||
|
|
13
.vscode/settings.json
vendored
|
@ -25,5 +25,16 @@
|
|||
"backend/server/backend/lib/python3.12/site-packages/django/contrib/sites/locale",
|
||||
"backend/server/backend/lib/python3.12/site-packages/rest_framework/templates/rest_framework/docs/langs"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.keysInUse": [
|
||||
"navbar.themes.dim",
|
||||
"navbar.themes.northernLights",
|
||||
"navbar.themes.aqua",
|
||||
"navbar.themes.aestheticDark",
|
||||
"navbar.themes.aestheticLight",
|
||||
"navbar.themes.forest",
|
||||
"navbar.themes.night",
|
||||
"navbar.themes.dark",
|
||||
"navbar.themes.light"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -140,7 +140,7 @@ Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software de
|
|||
|
||||
## 💎 Acknowledgements
|
||||
|
||||
- Logo Design by [nordtechtiger](https://github.com/nordtechtiger)
|
||||
- Logo Design by [nordtektiger](https://github.com/nordtektiger)
|
||||
- WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database)
|
||||
|
||||
### Top Supporters 💖
|
||||
|
|
|
@ -1,18 +1,30 @@
|
|||
# Use the official Python slim image as the base image
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
LABEL Developers="Sean Morley"
|
||||
# Metadata labels for the AdventureLog image
|
||||
LABEL maintainer="Sean Morley" \
|
||||
version="v0.10.0" \
|
||||
description="AdventureLog — the ultimate self-hosted travel companion." \
|
||||
org.opencontainers.image.title="AdventureLog" \
|
||||
org.opencontainers.image.description="AdventureLog is a self-hosted travel companion that helps you plan, track, and share your adventures." \
|
||||
org.opencontainers.image.version="v0.10.0" \
|
||||
org.opencontainers.image.authors="Sean Morley" \
|
||||
org.opencontainers.image.url="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/banner.png" \
|
||||
org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \
|
||||
org.opencontainers.image.vendor="Sean Morley" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /code
|
||||
|
||||
# Install system dependencies (Nginx included)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y git postgresql-client gdal-bin libgdal-dev nginx \
|
||||
&& apt-get install -y git postgresql-client gdal-bin libgdal-dev nginx supervisor \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
@ -31,6 +43,9 @@ COPY ./server /code/
|
|||
# Copy Nginx configuration
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy Supervisor configuration
|
||||
COPY ./supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Collect static files
|
||||
RUN python3 manage.py collectstatic --noinput --verbosity 2
|
||||
|
||||
|
@ -41,5 +56,5 @@ RUN chmod +x /code/entrypoint.sh
|
|||
# Expose ports for NGINX and Gunicorn
|
||||
EXPOSE 80 8000
|
||||
|
||||
# Command to start Nginx and Gunicorn
|
||||
CMD ["bash", "-c", "service nginx start && /code/entrypoint.sh"]
|
||||
# Command to start Supervisor (which starts Nginx and Gunicorn)
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
|
|
@ -1,10 +1,32 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Function to check PostgreSQL availability
|
||||
check_postgres() {
|
||||
PGPASSWORD=$PGPASSWORD psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -c '\q' >/dev/null 2>&1
|
||||
# Helper to get the first non-empty environment variable
|
||||
get_env() {
|
||||
for var in "$@"; do
|
||||
value="${!var}"
|
||||
if [ -n "$value" ]; then
|
||||
echo "$value"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_postgres() {
|
||||
local db_host
|
||||
local db_user
|
||||
local db_name
|
||||
local db_pass
|
||||
|
||||
db_host=$(get_env PGHOST)
|
||||
db_user=$(get_env PGUSER POSTGRES_USER)
|
||||
db_name=$(get_env PGDATABASE POSTGRES_DB)
|
||||
db_pass=$(get_env PGPASSWORD POSTGRES_PASSWORD)
|
||||
|
||||
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c '\q' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
|
||||
# Wait for PostgreSQL to become available
|
||||
until check_postgres; do
|
||||
>&2 echo "PostgreSQL is unavailable - sleeping"
|
||||
|
@ -62,5 +84,8 @@ fi
|
|||
|
||||
cat /code/adventurelog.txt
|
||||
|
||||
# Start gunicorn
|
||||
gunicorn main.wsgi:application --bind [::]:8000 --timeout 120 --workers 2
|
||||
# Start Gunicorn in foreground
|
||||
exec gunicorn main.wsgi:application \
|
||||
--bind [::]:8000 \
|
||||
--workers 2 \
|
||||
--timeout 120
|
||||
|
|
|
@ -1,27 +1,20 @@
|
|||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
# The backend is running in the same container, so reference localhost
|
||||
upstream django {
|
||||
server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
proxy_pass http://django; # Forward to the upstream block
|
||||
proxy_set_header Host $host;
|
||||
|
@ -29,17 +22,21 @@ http {
|
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /code/staticfiles/; # Serve static files directly
|
||||
}
|
||||
|
||||
# Serve protected media files with X-Accel-Redirect
|
||||
location /protectedMedia/ {
|
||||
internal; # Only internal requests are allowed
|
||||
alias /code/media/; # This should match Django MEDIA_ROOT
|
||||
try_files $uri =404; # Return a 404 if the file doesn't exist
|
||||
|
||||
# Security headers for all protected files
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,8 @@ EMAIL_BACKEND='console'
|
|||
# EMAIL_HOST_PASSWORD='password'
|
||||
# DEFAULT_FROM_EMAIL='user@example.com'
|
||||
|
||||
# GOOGLE_MAPS_API_KEY='key'
|
||||
|
||||
|
||||
# ------------------- #
|
||||
# For Developers to start a Demo Database
|
||||
|
|
|
@ -8,10 +8,25 @@ from allauth.account.decorators import secure_admin_login
|
|||
admin.autodiscover()
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
@admin.action(description="Trigger geocoding")
|
||||
def trigger_geocoding(modeladmin, request, queryset):
|
||||
count = 0
|
||||
for adventure in queryset:
|
||||
try:
|
||||
adventure.save() # Triggers geocoding logic in your model
|
||||
count += 1
|
||||
except Exception as e:
|
||||
modeladmin.message_user(request, f"Error geocoding {adventure}: {e}", level='error')
|
||||
modeladmin.message_user(request, f"Geocoding triggered for {count} adventures.", level='success')
|
||||
|
||||
|
||||
|
||||
class AdventureAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'get_category', 'get_visit_count', 'user_id', 'is_public')
|
||||
list_filter = ( 'user_id', 'is_public')
|
||||
search_fields = ('name',)
|
||||
readonly_fields = ('city', 'region', 'country')
|
||||
actions = [trigger_geocoding]
|
||||
|
||||
def get_category(self, obj):
|
||||
if obj.category and obj.category.display_name and obj.category.icon:
|
||||
|
@ -114,12 +129,9 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||
search_fields = ('name', 'display_name')
|
||||
|
||||
class CollectionAdmin(admin.ModelAdmin):
|
||||
def adventure_count(self, obj):
|
||||
return obj.adventure_set.count()
|
||||
|
||||
|
||||
adventure_count.short_description = 'Adventure Count'
|
||||
|
||||
list_display = ('name', 'user_id', 'adventure_count', 'is_public')
|
||||
list_display = ('name', 'user_id', 'is_public')
|
||||
|
||||
admin.site.register(CustomUser, CustomUserAdmin)
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
class AdventuresConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'adventures'
|
||||
name = 'adventures'
|
||||
|
||||
def ready(self):
|
||||
import adventures.signals # Import signals when the app is ready
|
273
backend/server/adventures/geocoding.py
Normal file
|
@ -0,0 +1,273 @@
|
|||
import requests
|
||||
import time
|
||||
import socket
|
||||
from worldtravel.models import Region, City, VisitedRegion, VisitedCity
|
||||
from django.conf import settings
|
||||
|
||||
# -----------------
|
||||
# SEARCHING
|
||||
def search_google(query):
|
||||
try:
|
||||
api_key = settings.GOOGLE_MAPS_API_KEY
|
||||
if not api_key:
|
||||
return {"error": "Missing Google Maps API key"}
|
||||
|
||||
# Updated to use the new Places API (New) endpoint
|
||||
url = "https://places.googleapis.com/v1/places:searchText"
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': api_key,
|
||||
'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"textQuery": query,
|
||||
"maxResultCount": 20 # Adjust as needed
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=(2, 5))
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Check if we have places in the response
|
||||
places = data.get("places", [])
|
||||
if not places:
|
||||
return {"error": "No results found"}
|
||||
|
||||
results = []
|
||||
for place in places:
|
||||
location = place.get("location", {})
|
||||
types = place.get("types", [])
|
||||
primary_type = types[0] if types else None
|
||||
category = _extract_google_category(types)
|
||||
addresstype = _infer_addresstype(primary_type)
|
||||
|
||||
importance = None
|
||||
rating = place.get("rating")
|
||||
ratings_total = place.get("userRatingCount")
|
||||
if rating is not None and ratings_total:
|
||||
importance = round(float(rating) * ratings_total / 100, 2)
|
||||
|
||||
# Extract display name from the new API structure
|
||||
display_name_obj = place.get("displayName", {})
|
||||
name = display_name_obj.get("text") if display_name_obj else None
|
||||
|
||||
results.append({
|
||||
"lat": location.get("latitude"),
|
||||
"lon": location.get("longitude"),
|
||||
"name": name,
|
||||
"display_name": place.get("formattedAddress"),
|
||||
"type": primary_type,
|
||||
"category": category,
|
||||
"importance": importance,
|
||||
"addresstype": addresstype,
|
||||
"powered_by": "google",
|
||||
})
|
||||
|
||||
if results:
|
||||
results.sort(key=lambda r: r["importance"] if r["importance"] is not None else 0, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"error": "Network error while contacting Google Maps", "details": str(e)}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": "Unexpected error during Google search", "details": str(e)}
|
||||
|
||||
def _extract_google_category(types):
|
||||
# Basic category inference based on common place types
|
||||
if not types:
|
||||
return None
|
||||
if "restaurant" in types:
|
||||
return "food"
|
||||
if "lodging" in types:
|
||||
return "accommodation"
|
||||
if "park" in types or "natural_feature" in types:
|
||||
return "nature"
|
||||
if "museum" in types or "tourist_attraction" in types:
|
||||
return "attraction"
|
||||
if "locality" in types or "administrative_area_level_1" in types:
|
||||
return "region"
|
||||
return types[0] # fallback to first type
|
||||
|
||||
|
||||
def _infer_addresstype(type_):
|
||||
# Rough mapping of Google place types to OSM-style addresstypes
|
||||
mapping = {
|
||||
"locality": "city",
|
||||
"sublocality": "neighborhood",
|
||||
"administrative_area_level_1": "region",
|
||||
"administrative_area_level_2": "county",
|
||||
"country": "country",
|
||||
"premise": "building",
|
||||
"point_of_interest": "poi",
|
||||
"route": "road",
|
||||
"street_address": "address",
|
||||
}
|
||||
return mapping.get(type_, None)
|
||||
|
||||
|
||||
def search_osm(query):
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
response = requests.get(url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
return [{
|
||||
"lat": item.get("lat"),
|
||||
"lon": item.get("lon"),
|
||||
"name": item.get("name"),
|
||||
"display_name": item.get("display_name"),
|
||||
"type": item.get("type"),
|
||||
"category": item.get("category"),
|
||||
"importance": item.get("importance"),
|
||||
"addresstype": item.get("addresstype"),
|
||||
"powered_by": "nominatim",
|
||||
} for item in data]
|
||||
|
||||
# -----------------
|
||||
# REVERSE GEOCODING
|
||||
# -----------------
|
||||
|
||||
def extractIsoCode(user, data):
|
||||
"""
|
||||
Extract the ISO code from the response data.
|
||||
Returns a dictionary containing the region name, country name, and ISO code if found.
|
||||
"""
|
||||
iso_code = None
|
||||
town_city_or_county = None
|
||||
display_name = None
|
||||
country_code = None
|
||||
city = None
|
||||
visited_city = None
|
||||
location_name = None
|
||||
|
||||
# town = None
|
||||
# city = None
|
||||
# county = None
|
||||
|
||||
if 'name' in data.keys():
|
||||
location_name = data['name']
|
||||
|
||||
if 'address' in data.keys():
|
||||
keys = data['address'].keys()
|
||||
for key in keys:
|
||||
if key.find("ISO") != -1:
|
||||
iso_code = data['address'][key]
|
||||
if 'town' in keys:
|
||||
town_city_or_county = data['address']['town']
|
||||
if 'county' in keys:
|
||||
town_city_or_county = data['address']['county']
|
||||
if 'city' in keys:
|
||||
town_city_or_county = data['address']['city']
|
||||
if not iso_code:
|
||||
return {"error": "No region found"}
|
||||
|
||||
region = Region.objects.filter(id=iso_code).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=user).first()
|
||||
|
||||
region_visited = False
|
||||
city_visited = False
|
||||
country_code = iso_code[:2]
|
||||
|
||||
if region:
|
||||
if town_city_or_county:
|
||||
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
|
||||
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=user).first()
|
||||
|
||||
if visited_region:
|
||||
region_visited = True
|
||||
if visited_city:
|
||||
city_visited = True
|
||||
if region:
|
||||
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "country_id": region.country.country_code, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
|
||||
return {"error": "No region found"}
|
||||
|
||||
def is_host_resolvable(hostname: str) -> bool:
|
||||
try:
|
||||
socket.gethostbyname(hostname)
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
|
||||
def reverse_geocode(lat, lon, user):
|
||||
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
|
||||
return reverse_geocode_google(lat, lon, user)
|
||||
return reverse_geocode_osm(lat, lon, user)
|
||||
|
||||
def reverse_geocode_osm(lat, lon, user):
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
connect_timeout = 1
|
||||
read_timeout = 5
|
||||
|
||||
if not is_host_resolvable("nominatim.openstreetmap.org"):
|
||||
return {"error": "DNS resolution failed"}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return extractIsoCode(user, data)
|
||||
except Exception:
|
||||
return {"error": "An internal error occurred while processing the request"}
|
||||
|
||||
def reverse_geocode_google(lat, lon, user):
|
||||
api_key = settings.GOOGLE_MAPS_API_KEY
|
||||
|
||||
# Updated to use the new Geocoding API endpoint (this one is still supported)
|
||||
# The Geocoding API is separate from Places API and still uses the old format
|
||||
url = "https://maps.googleapis.com/maps/api/geocode/json"
|
||||
params = {"latlng": f"{lat},{lon}", "key": api_key}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") != "OK":
|
||||
return {"error": "Geocoding failed"}
|
||||
|
||||
# Convert Google schema to Nominatim-style for extractIsoCode
|
||||
first_result = data.get("results", [])[0]
|
||||
result_data = {
|
||||
"name": first_result.get("formatted_address"),
|
||||
"address": _parse_google_address_components(first_result.get("address_components", []))
|
||||
}
|
||||
return extractIsoCode(user, result_data)
|
||||
except Exception:
|
||||
return {"error": "An internal error occurred while processing the request"}
|
||||
|
||||
def _parse_google_address_components(components):
|
||||
parsed = {}
|
||||
country_code = None
|
||||
state_code = None
|
||||
|
||||
for comp in components:
|
||||
types = comp.get("types", [])
|
||||
long_name = comp.get("long_name")
|
||||
short_name = comp.get("short_name")
|
||||
|
||||
if "country" in types:
|
||||
parsed["country"] = long_name
|
||||
country_code = short_name
|
||||
parsed["ISO3166-1"] = short_name
|
||||
if "administrative_area_level_1" in types:
|
||||
parsed["state"] = long_name
|
||||
state_code = short_name
|
||||
if "administrative_area_level_2" in types:
|
||||
parsed["county"] = long_name
|
||||
if "locality" in types:
|
||||
parsed["city"] = long_name
|
||||
if "sublocality" in types:
|
||||
parsed["town"] = long_name
|
||||
|
||||
# Build composite ISO 3166-2 code like US-ME
|
||||
if country_code and state_code:
|
||||
parsed["ISO3166-2-lvl1"] = f"{country_code}-{state_code}"
|
||||
|
||||
return parsed
|
|
@ -3,20 +3,15 @@ from django.db.models import Q
|
|||
|
||||
class AdventureManager(models.Manager):
|
||||
def retrieve_adventures(self, user, include_owned=False, include_shared=False, include_public=False):
|
||||
# Initialize the query with an empty Q object
|
||||
query = Q()
|
||||
|
||||
# Add owned adventures to the query if included
|
||||
if include_owned:
|
||||
query |= Q(user_id=user.id)
|
||||
query |= Q(user_id=user)
|
||||
|
||||
# Add shared adventures to the query if included
|
||||
if include_shared:
|
||||
query |= Q(collection__shared_with=user.id)
|
||||
query |= Q(collections__shared_with=user)
|
||||
|
||||
# Add public adventures to the query if included
|
||||
if include_public:
|
||||
query |= Q(is_public=True)
|
||||
|
||||
# Perform the query with the final Q object and remove duplicates
|
||||
return self.filter(query).distinct()
|
||||
|
|
|
@ -29,4 +29,12 @@ class XSessionTokenMiddleware(MiddlewareMixin):
|
|||
class DisableCSRFForSessionTokenMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if 'X-Session-Token' in request.headers:
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
|
||||
class DisableCSRFForMobileLoginSignup(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
is_mobile = request.headers.get('X-Is-Mobile', '').lower() == 'true'
|
||||
is_login_or_signup = request.path in ['/auth/browser/v1/auth/login', '/auth/browser/v1/auth/signup']
|
||||
if is_mobile and is_login_or_signup:
|
||||
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.0.8 on 2025-03-17 21:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0024_alter_attachment_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='visit',
|
||||
name='end_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='visit',
|
||||
name='start_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
18
backend/server/adventures/migrations/0026_visit_timezone.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 5.0.11 on 2025-05-22 22:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0028_lodging_timezone'),
|
||||
('worldtravel', '0015_city_insert_id_country_insert_id_region_insert_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='adventure',
|
||||
name='city',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.city'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='adventure',
|
||||
name='country',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.country'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='adventure',
|
||||
name='region',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='worldtravel.region'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
from django.db import migrations
|
||||
|
||||
def set_end_date_equal_to_start(apps, schema_editor):
|
||||
Visit = apps.get_model('adventures', 'Visit')
|
||||
for visit in Visit.objects.filter(end_date__isnull=True):
|
||||
if visit.start_date:
|
||||
visit.end_date = visit.start_date
|
||||
visit.save()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0029_adventure_city_adventure_country_adventure_region'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_end_date_equal_to_start),
|
||||
]
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-01 16:57
|
||||
|
||||
import adventures.models
|
||||
import django_resized.forms
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0030_set_end_date_equal_start'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='adventureimage',
|
||||
name='immich_id',
|
||||
field=models.CharField(blank=True, max_length=200, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adventureimage',
|
||||
name='image',
|
||||
field=django_resized.forms.ResizedImageField(blank=True, crop=None, force_format='WEBP', keep_meta=True, null=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='adventureimage',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('image__isnull', False), ('immich_id__isnull', True)), models.Q(('image__isnull', True), ('immich_id__isnull', False)), _connector='OR'), name='image_xor_immich_id'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-01 17:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0031_adventureimage_immich_id_alter_adventureimage_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='adventureimage',
|
||||
name='image_xor_immich_id',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-02 02:31
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0032_remove_adventureimage_image_xor_immich_id'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='adventureimage',
|
||||
constraint=models.UniqueConstraint(fields=('immich_id', 'user_id'), name='unique_immich_id_per_user'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-02 02:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0033_adventureimage_unique_immich_id_per_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='adventureimage',
|
||||
name='unique_immich_id_per_user',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,59 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-10 03:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_collection_relationships(apps, schema_editor):
|
||||
"""
|
||||
Migrate existing ForeignKey relationships to ManyToMany relationships
|
||||
"""
|
||||
Adventure = apps.get_model('adventures', 'Adventure')
|
||||
|
||||
# Get all adventures that have a collection assigned
|
||||
adventures_with_collections = Adventure.objects.filter(collection__isnull=False)
|
||||
|
||||
for adventure in adventures_with_collections:
|
||||
# Add the existing collection to the new many-to-many field
|
||||
adventure.collections.add(adventure.collection_id)
|
||||
|
||||
|
||||
def reverse_migrate_collection_relationships(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration - convert first collection back to ForeignKey
|
||||
Note: This will only preserve the first collection if an adventure has multiple
|
||||
"""
|
||||
Adventure = apps.get_model('adventures', 'Adventure')
|
||||
|
||||
for adventure in Adventure.objects.all():
|
||||
first_collection = adventure.collections.first()
|
||||
if first_collection:
|
||||
adventure.collection = first_collection
|
||||
adventure.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('adventures', '0034_remove_adventureimage_unique_immich_id_per_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# First, add the new ManyToMany field
|
||||
migrations.AddField(
|
||||
model_name='adventure',
|
||||
name='collections',
|
||||
field=models.ManyToManyField(blank=True, related_name='adventures', to='adventures.collection'),
|
||||
),
|
||||
|
||||
# Migrate existing data from old field to new field
|
||||
migrations.RunPython(
|
||||
migrate_collection_relationships,
|
||||
reverse_migrate_collection_relationships
|
||||
),
|
||||
|
||||
# Finally, remove the old ForeignKey field
|
||||
migrations.RemoveField(
|
||||
model_name='adventure',
|
||||
name='collection',
|
||||
),
|
||||
]
|
|
@ -5,16 +5,60 @@ import uuid
|
|||
from django.db import models
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from adventures.managers import AdventureManager
|
||||
import threading
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.forms import ValidationError
|
||||
from django_resized import ResizedImageField
|
||||
from worldtravel.models import City, Country, Region, VisitedCity, VisitedRegion
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
def background_geocode_and_assign(adventure_id: str):
|
||||
print(f"[Adventure Geocode Thread] Starting geocode for adventure {adventure_id}")
|
||||
try:
|
||||
adventure = Adventure.objects.get(id=adventure_id)
|
||||
if not (adventure.latitude and adventure.longitude):
|
||||
return
|
||||
|
||||
from adventures.geocoding import reverse_geocode # or wherever you defined it
|
||||
is_visited = adventure.is_visited_status()
|
||||
result = reverse_geocode(adventure.latitude, adventure.longitude, adventure.user_id)
|
||||
|
||||
if 'region_id' in result:
|
||||
region = Region.objects.filter(id=result['region_id']).first()
|
||||
if region:
|
||||
adventure.region = region
|
||||
if is_visited:
|
||||
VisitedRegion.objects.get_or_create(user_id=adventure.user_id, region=region)
|
||||
|
||||
if 'city_id' in result:
|
||||
city = City.objects.filter(id=result['city_id']).first()
|
||||
if city:
|
||||
adventure.city = city
|
||||
if is_visited:
|
||||
VisitedCity.objects.get_or_create(user_id=adventure.user_id, city=city)
|
||||
|
||||
if 'country_id' in result:
|
||||
country = Country.objects.filter(country_code=result['country_id']).first()
|
||||
if country:
|
||||
adventure.country = country
|
||||
|
||||
# Save updated location info
|
||||
# Save updated location info, skip geocode threading
|
||||
adventure.save(update_fields=["region", "city", "country"], _skip_geocode=True)
|
||||
|
||||
# print(f"[Adventure Geocode Thread] Successfully processed {adventure_id}: {adventure.name} - {adventure.latitude}, {adventure.longitude}")
|
||||
|
||||
except Exception as e:
|
||||
# Optional: log or print the error
|
||||
print(f"[Adventure Geocode Thread] Error processing {adventure_id}: {e}")
|
||||
|
||||
def validate_file_extension(value):
|
||||
import os
|
||||
from django.core.exceptions import ValidationError
|
||||
ext = os.path.splitext(value.name)[1] # [0] returns path+filename
|
||||
valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', 'gpx', 'md', 'pdf']
|
||||
valid_extensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.mkv', '.mp3', '.wav', '.flac', '.ogg', '.m4a', '.wma', '.aac', '.opus', '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.lz4', '.lzma', '.lzo', '.z', '.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tar.lz4', '.tar.lzma', '.tar.lzo', '.tar.z', '.gpx', '.md']
|
||||
if not ext.lower() in valid_extensions:
|
||||
raise ValidationError('Unsupported file extension.')
|
||||
|
||||
|
@ -43,6 +87,426 @@ ADVENTURE_TYPES = [
|
|||
('other', 'Other')
|
||||
]
|
||||
|
||||
TIMEZONES = [
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
"Africa/Asmera",
|
||||
"Africa/Bamako",
|
||||
"Africa/Bangui",
|
||||
"Africa/Banjul",
|
||||
"Africa/Bissau",
|
||||
"Africa/Blantyre",
|
||||
"Africa/Brazzaville",
|
||||
"Africa/Bujumbura",
|
||||
"Africa/Cairo",
|
||||
"Africa/Casablanca",
|
||||
"Africa/Ceuta",
|
||||
"Africa/Conakry",
|
||||
"Africa/Dakar",
|
||||
"Africa/Dar_es_Salaam",
|
||||
"Africa/Djibouti",
|
||||
"Africa/Douala",
|
||||
"Africa/El_Aaiun",
|
||||
"Africa/Freetown",
|
||||
"Africa/Gaborone",
|
||||
"Africa/Harare",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Juba",
|
||||
"Africa/Kampala",
|
||||
"Africa/Khartoum",
|
||||
"Africa/Kigali",
|
||||
"Africa/Kinshasa",
|
||||
"Africa/Lagos",
|
||||
"Africa/Libreville",
|
||||
"Africa/Lome",
|
||||
"Africa/Luanda",
|
||||
"Africa/Lubumbashi",
|
||||
"Africa/Lusaka",
|
||||
"Africa/Malabo",
|
||||
"Africa/Maputo",
|
||||
"Africa/Maseru",
|
||||
"Africa/Mbabane",
|
||||
"Africa/Mogadishu",
|
||||
"Africa/Monrovia",
|
||||
"Africa/Nairobi",
|
||||
"Africa/Ndjamena",
|
||||
"Africa/Niamey",
|
||||
"Africa/Nouakchott",
|
||||
"Africa/Ouagadougou",
|
||||
"Africa/Porto-Novo",
|
||||
"Africa/Sao_Tome",
|
||||
"Africa/Tripoli",
|
||||
"Africa/Tunis",
|
||||
"Africa/Windhoek",
|
||||
"America/Adak",
|
||||
"America/Anchorage",
|
||||
"America/Anguilla",
|
||||
"America/Antigua",
|
||||
"America/Araguaina",
|
||||
"America/Argentina/La_Rioja",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Salta",
|
||||
"America/Argentina/San_Juan",
|
||||
"America/Argentina/San_Luis",
|
||||
"America/Argentina/Tucuman",
|
||||
"America/Argentina/Ushuaia",
|
||||
"America/Aruba",
|
||||
"America/Asuncion",
|
||||
"America/Bahia",
|
||||
"America/Bahia_Banderas",
|
||||
"America/Barbados",
|
||||
"America/Belem",
|
||||
"America/Belize",
|
||||
"America/Blanc-Sablon",
|
||||
"America/Boa_Vista",
|
||||
"America/Bogota",
|
||||
"America/Boise",
|
||||
"America/Buenos_Aires",
|
||||
"America/Cambridge_Bay",
|
||||
"America/Campo_Grande",
|
||||
"America/Cancun",
|
||||
"America/Caracas",
|
||||
"America/Catamarca",
|
||||
"America/Cayenne",
|
||||
"America/Cayman",
|
||||
"America/Chicago",
|
||||
"America/Chihuahua",
|
||||
"America/Ciudad_Juarez",
|
||||
"America/Coral_Harbour",
|
||||
"America/Cordoba",
|
||||
"America/Costa_Rica",
|
||||
"America/Creston",
|
||||
"America/Cuiaba",
|
||||
"America/Curacao",
|
||||
"America/Danmarkshavn",
|
||||
"America/Dawson",
|
||||
"America/Dawson_Creek",
|
||||
"America/Denver",
|
||||
"America/Detroit",
|
||||
"America/Dominica",
|
||||
"America/Edmonton",
|
||||
"America/Eirunepe",
|
||||
"America/El_Salvador",
|
||||
"America/Fort_Nelson",
|
||||
"America/Fortaleza",
|
||||
"America/Glace_Bay",
|
||||
"America/Godthab",
|
||||
"America/Goose_Bay",
|
||||
"America/Grand_Turk",
|
||||
"America/Grenada",
|
||||
"America/Guadeloupe",
|
||||
"America/Guatemala",
|
||||
"America/Guayaquil",
|
||||
"America/Guyana",
|
||||
"America/Halifax",
|
||||
"America/Havana",
|
||||
"America/Hermosillo",
|
||||
"America/Indiana/Knox",
|
||||
"America/Indiana/Marengo",
|
||||
"America/Indiana/Petersburg",
|
||||
"America/Indiana/Tell_City",
|
||||
"America/Indiana/Vevay",
|
||||
"America/Indiana/Vincennes",
|
||||
"America/Indiana/Winamac",
|
||||
"America/Indianapolis",
|
||||
"America/Inuvik",
|
||||
"America/Iqaluit",
|
||||
"America/Jamaica",
|
||||
"America/Jujuy",
|
||||
"America/Juneau",
|
||||
"America/Kentucky/Monticello",
|
||||
"America/Kralendijk",
|
||||
"America/La_Paz",
|
||||
"America/Lima",
|
||||
"America/Los_Angeles",
|
||||
"America/Louisville",
|
||||
"America/Lower_Princes",
|
||||
"America/Maceio",
|
||||
"America/Managua",
|
||||
"America/Manaus",
|
||||
"America/Marigot",
|
||||
"America/Martinique",
|
||||
"America/Matamoros",
|
||||
"America/Mazatlan",
|
||||
"America/Mendoza",
|
||||
"America/Menominee",
|
||||
"America/Merida",
|
||||
"America/Metlakatla",
|
||||
"America/Mexico_City",
|
||||
"America/Miquelon",
|
||||
"America/Moncton",
|
||||
"America/Monterrey",
|
||||
"America/Montevideo",
|
||||
"America/Montserrat",
|
||||
"America/Nassau",
|
||||
"America/New_York",
|
||||
"America/Nome",
|
||||
"America/Noronha",
|
||||
"America/North_Dakota/Beulah",
|
||||
"America/North_Dakota/Center",
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/Ojinaga",
|
||||
"America/Panama",
|
||||
"America/Paramaribo",
|
||||
"America/Phoenix",
|
||||
"America/Port-au-Prince",
|
||||
"America/Port_of_Spain",
|
||||
"America/Porto_Velho",
|
||||
"America/Puerto_Rico",
|
||||
"America/Punta_Arenas",
|
||||
"America/Rankin_Inlet",
|
||||
"America/Recife",
|
||||
"America/Regina",
|
||||
"America/Resolute",
|
||||
"America/Rio_Branco",
|
||||
"America/Santarem",
|
||||
"America/Santiago",
|
||||
"America/Santo_Domingo",
|
||||
"America/Sao_Paulo",
|
||||
"America/Scoresbysund",
|
||||
"America/Sitka",
|
||||
"America/St_Barthelemy",
|
||||
"America/St_Johns",
|
||||
"America/St_Kitts",
|
||||
"America/St_Lucia",
|
||||
"America/St_Thomas",
|
||||
"America/St_Vincent",
|
||||
"America/Swift_Current",
|
||||
"America/Tegucigalpa",
|
||||
"America/Thule",
|
||||
"America/Tijuana",
|
||||
"America/Toronto",
|
||||
"America/Tortola",
|
||||
"America/Vancouver",
|
||||
"America/Whitehorse",
|
||||
"America/Winnipeg",
|
||||
"America/Yakutat",
|
||||
"Antarctica/Casey",
|
||||
"Antarctica/Davis",
|
||||
"Antarctica/DumontDUrville",
|
||||
"Antarctica/Macquarie",
|
||||
"Antarctica/Mawson",
|
||||
"Antarctica/McMurdo",
|
||||
"Antarctica/Palmer",
|
||||
"Antarctica/Rothera",
|
||||
"Antarctica/Syowa",
|
||||
"Antarctica/Troll",
|
||||
"Antarctica/Vostok",
|
||||
"Arctic/Longyearbyen",
|
||||
"Asia/Aden",
|
||||
"Asia/Almaty",
|
||||
"Asia/Amman",
|
||||
"Asia/Anadyr",
|
||||
"Asia/Aqtau",
|
||||
"Asia/Aqtobe",
|
||||
"Asia/Ashgabat",
|
||||
"Asia/Atyrau",
|
||||
"Asia/Baghdad",
|
||||
"Asia/Bahrain",
|
||||
"Asia/Baku",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Barnaul",
|
||||
"Asia/Beirut",
|
||||
"Asia/Bishkek",
|
||||
"Asia/Brunei",
|
||||
"Asia/Calcutta",
|
||||
"Asia/Chita",
|
||||
"Asia/Colombo",
|
||||
"Asia/Damascus",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dili",
|
||||
"Asia/Dubai",
|
||||
"Asia/Dushanbe",
|
||||
"Asia/Famagusta",
|
||||
"Asia/Gaza",
|
||||
"Asia/Hebron",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Hovd",
|
||||
"Asia/Irkutsk",
|
||||
"Asia/Jakarta",
|
||||
"Asia/Jayapura",
|
||||
"Asia/Jerusalem",
|
||||
"Asia/Kabul",
|
||||
"Asia/Kamchatka",
|
||||
"Asia/Karachi",
|
||||
"Asia/Katmandu",
|
||||
"Asia/Khandyga",
|
||||
"Asia/Krasnoyarsk",
|
||||
"Asia/Kuala_Lumpur",
|
||||
"Asia/Kuching",
|
||||
"Asia/Kuwait",
|
||||
"Asia/Macau",
|
||||
"Asia/Magadan",
|
||||
"Asia/Makassar",
|
||||
"Asia/Manila",
|
||||
"Asia/Muscat",
|
||||
"Asia/Nicosia",
|
||||
"Asia/Novokuznetsk",
|
||||
"Asia/Novosibirsk",
|
||||
"Asia/Omsk",
|
||||
"Asia/Oral",
|
||||
"Asia/Phnom_Penh",
|
||||
"Asia/Pontianak",
|
||||
"Asia/Pyongyang",
|
||||
"Asia/Qatar",
|
||||
"Asia/Qostanay",
|
||||
"Asia/Qyzylorda",
|
||||
"Asia/Rangoon",
|
||||
"Asia/Riyadh",
|
||||
"Asia/Saigon",
|
||||
"Asia/Sakhalin",
|
||||
"Asia/Samarkand",
|
||||
"Asia/Seoul",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Srednekolymsk",
|
||||
"Asia/Taipei",
|
||||
"Asia/Tashkent",
|
||||
"Asia/Tbilisi",
|
||||
"Asia/Tehran",
|
||||
"Asia/Thimphu",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Tomsk",
|
||||
"Asia/Ulaanbaatar",
|
||||
"Asia/Urumqi",
|
||||
"Asia/Ust-Nera",
|
||||
"Asia/Vientiane",
|
||||
"Asia/Vladivostok",
|
||||
"Asia/Yakutsk",
|
||||
"Asia/Yekaterinburg",
|
||||
"Asia/Yerevan",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/Bermuda",
|
||||
"Atlantic/Canary",
|
||||
"Atlantic/Cape_Verde",
|
||||
"Atlantic/Faeroe",
|
||||
"Atlantic/Madeira",
|
||||
"Atlantic/Reykjavik",
|
||||
"Atlantic/South_Georgia",
|
||||
"Atlantic/St_Helena",
|
||||
"Atlantic/Stanley",
|
||||
"Australia/Adelaide",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Broken_Hill",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Hobart",
|
||||
"Australia/Lindeman",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Melbourne",
|
||||
"Australia/Perth",
|
||||
"Australia/Sydney",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Andorra",
|
||||
"Europe/Astrakhan",
|
||||
"Europe/Athens",
|
||||
"Europe/Belgrade",
|
||||
"Europe/Berlin",
|
||||
"Europe/Bratislava",
|
||||
"Europe/Brussels",
|
||||
"Europe/Bucharest",
|
||||
"Europe/Budapest",
|
||||
"Europe/Busingen",
|
||||
"Europe/Chisinau",
|
||||
"Europe/Copenhagen",
|
||||
"Europe/Dublin",
|
||||
"Europe/Gibraltar",
|
||||
"Europe/Guernsey",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Isle_of_Man",
|
||||
"Europe/Istanbul",
|
||||
"Europe/Jersey",
|
||||
"Europe/Kaliningrad",
|
||||
"Europe/Kiev",
|
||||
"Europe/Kirov",
|
||||
"Europe/Lisbon",
|
||||
"Europe/Ljubljana",
|
||||
"Europe/London",
|
||||
"Europe/Luxembourg",
|
||||
"Europe/Madrid",
|
||||
"Europe/Malta",
|
||||
"Europe/Mariehamn",
|
||||
"Europe/Minsk",
|
||||
"Europe/Monaco",
|
||||
"Europe/Moscow",
|
||||
"Europe/Oslo",
|
||||
"Europe/Paris",
|
||||
"Europe/Podgorica",
|
||||
"Europe/Prague",
|
||||
"Europe/Riga",
|
||||
"Europe/Rome",
|
||||
"Europe/Samara",
|
||||
"Europe/San_Marino",
|
||||
"Europe/Sarajevo",
|
||||
"Europe/Saratov",
|
||||
"Europe/Simferopol",
|
||||
"Europe/Skopje",
|
||||
"Europe/Sofia",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Tallinn",
|
||||
"Europe/Tirane",
|
||||
"Europe/Ulyanovsk",
|
||||
"Europe/Vaduz",
|
||||
"Europe/Vatican",
|
||||
"Europe/Vienna",
|
||||
"Europe/Vilnius",
|
||||
"Europe/Volgograd",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Zagreb",
|
||||
"Europe/Zurich",
|
||||
"Indian/Antananarivo",
|
||||
"Indian/Chagos",
|
||||
"Indian/Christmas",
|
||||
"Indian/Cocos",
|
||||
"Indian/Comoro",
|
||||
"Indian/Kerguelen",
|
||||
"Indian/Mahe",
|
||||
"Indian/Maldives",
|
||||
"Indian/Mauritius",
|
||||
"Indian/Mayotte",
|
||||
"Indian/Reunion",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Bougainville",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Easter",
|
||||
"Pacific/Efate",
|
||||
"Pacific/Enderbury",
|
||||
"Pacific/Fakaofo",
|
||||
"Pacific/Fiji",
|
||||
"Pacific/Funafuti",
|
||||
"Pacific/Galapagos",
|
||||
"Pacific/Gambier",
|
||||
"Pacific/Guadalcanal",
|
||||
"Pacific/Guam",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Kosrae",
|
||||
"Pacific/Kwajalein",
|
||||
"Pacific/Majuro",
|
||||
"Pacific/Marquesas",
|
||||
"Pacific/Midway",
|
||||
"Pacific/Nauru",
|
||||
"Pacific/Niue",
|
||||
"Pacific/Norfolk",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"Pacific/Palau",
|
||||
"Pacific/Pitcairn",
|
||||
"Pacific/Ponape",
|
||||
"Pacific/Port_Moresby",
|
||||
"Pacific/Rarotonga",
|
||||
"Pacific/Saipan",
|
||||
"Pacific/Tahiti",
|
||||
"Pacific/Tarawa",
|
||||
"Pacific/Tongatapu",
|
||||
"Pacific/Truk",
|
||||
"Pacific/Wake",
|
||||
"Pacific/Wallis"
|
||||
]
|
||||
|
||||
LODGING_TYPES = [
|
||||
('hotel', 'Hotel'),
|
||||
('hostel', 'Hostel'),
|
||||
|
@ -76,8 +540,9 @@ User = get_user_model()
|
|||
class Visit(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
adventure = models.ForeignKey('Adventure', on_delete=models.CASCADE, related_name='visits')
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
end_date = models.DateField(null=True, blank=True)
|
||||
start_date = models.DateTimeField(null=True, blank=True)
|
||||
end_date = models.DateTimeField(null=True, blank=True)
|
||||
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
|
||||
notes = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
@ -104,50 +569,92 @@ class Adventure(models.Model):
|
|||
rating = models.FloatField(blank=True, null=True)
|
||||
link = models.URLField(blank=True, null=True, max_length=2083)
|
||||
is_public = models.BooleanField(default=False)
|
||||
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
collection = models.ForeignKey('Collection', on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
city = models.ForeignKey(City, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
region = models.ForeignKey(Region, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
country = models.ForeignKey(Country, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
# Changed from ForeignKey to ManyToManyField
|
||||
collections = models.ManyToManyField('Collection', blank=True, related_name='adventures')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = AdventureManager()
|
||||
|
||||
# DEPRECATED FIELDS - TO BE REMOVED IN FUTURE VERSIONS
|
||||
# Migrations performed in this version will remove these fields
|
||||
# image = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='images/')
|
||||
# date = models.DateField(blank=True, null=True)
|
||||
# end_date = models.DateField(blank=True, null=True)
|
||||
# type = models.CharField(max_length=100, choices=ADVENTURE_TYPES, default='general')
|
||||
def is_visited_status(self):
|
||||
current_date = timezone.now().date()
|
||||
for visit in self.visits.all():
|
||||
start_date = visit.start_date.date() if isinstance(visit.start_date, timezone.datetime) else visit.start_date
|
||||
end_date = visit.end_date.date() if isinstance(visit.end_date, timezone.datetime) else visit.end_date
|
||||
if start_date and end_date and (start_date <= current_date):
|
||||
return True
|
||||
elif start_date and not end_date and (start_date <= current_date):
|
||||
return True
|
||||
return False
|
||||
|
||||
def clean(self):
|
||||
if self.collection:
|
||||
if self.collection.is_public and not self.is_public:
|
||||
raise ValidationError('Adventures associated with a public collection must be public. Collection: ' + self.trip.name + ' Adventure: ' + self.name)
|
||||
if self.user_id != self.collection.user_id:
|
||||
raise ValidationError('Adventures must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Adventure owner: ' + self.user_id.username)
|
||||
def clean(self, skip_shared_validation=False):
|
||||
"""
|
||||
Validate model constraints.
|
||||
skip_shared_validation: Skip validation when called by shared users
|
||||
"""
|
||||
# Skip validation if this is a shared user update
|
||||
if skip_shared_validation:
|
||||
return
|
||||
|
||||
# Check collections after the instance is saved (in save method or separate validation)
|
||||
if self.pk: # Only check if the instance has been saved
|
||||
for collection in self.collections.all():
|
||||
if collection.is_public and not self.is_public:
|
||||
raise ValidationError(f'Adventures associated with a public collection must be public. Collection: {collection.name} Adventure: {self.name}')
|
||||
|
||||
# Only enforce same-user constraint for non-shared collections
|
||||
if self.user_id != collection.user_id:
|
||||
# Check if this is a shared collection scenario
|
||||
# Allow if the adventure owner has access to the collection through sharing
|
||||
if not collection.shared_with.filter(uuid=self.user_id.uuid).exists():
|
||||
raise ValidationError(f'Adventures must be associated with collections owned by the same user or shared collections. Collection owner: {collection.user_id.username} Adventure owner: {self.user_id.username}')
|
||||
|
||||
if self.category:
|
||||
if self.user_id != self.category.user_id:
|
||||
raise ValidationError('Adventures must be associated with categories owned by the same user. Category owner: ' + self.category.user_id.username + ' Adventure owner: ' + self.user_id.username)
|
||||
raise ValidationError(f'Adventures must be associated with categories owned by the same user. Category owner: {self.category.user_id.username} Adventure owner: {self.user_id.username}')
|
||||
|
||||
def save(self, force_insert: bool = False, force_update: bool = False, using: str | None = None, update_fields: Iterable[str] | None = None) -> None:
|
||||
"""
|
||||
Saves the current instance. If the instance is being inserted for the first time, it will be created in the database.
|
||||
If it already exists, it will be updated.
|
||||
"""
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, _skip_geocode=False, _skip_shared_validation=False):
|
||||
if force_insert and force_update:
|
||||
raise ValueError("Cannot force both insert and updating in model saving.")
|
||||
|
||||
if not self.category:
|
||||
category, created = Category.objects.get_or_create(
|
||||
user_id=self.user_id,
|
||||
name='general',
|
||||
defaults={
|
||||
'display_name': 'General',
|
||||
'icon': '🌍'
|
||||
}
|
||||
)
|
||||
category, _ = Category.objects.get_or_create(
|
||||
user_id=self.user_id,
|
||||
name='general',
|
||||
defaults={'display_name': 'General', 'icon': '🌍'}
|
||||
)
|
||||
self.category = category
|
||||
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
result = super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
# Validate collections after saving (since M2M relationships require saved instance)
|
||||
if self.pk:
|
||||
try:
|
||||
self.clean(skip_shared_validation=_skip_shared_validation)
|
||||
except ValidationError as e:
|
||||
# If validation fails, you might want to handle this differently
|
||||
# For now, we'll re-raise the error
|
||||
raise e
|
||||
|
||||
# ⛔ Skip threading if called from geocode background thread
|
||||
if _skip_geocode:
|
||||
return result
|
||||
|
||||
if self.latitude and self.longitude:
|
||||
thread = threading.Thread(target=background_geocode_and_assign, args=(str(self.id),))
|
||||
thread.daemon = True # Allows the thread to exit when the main program ends
|
||||
thread.start()
|
||||
|
||||
return result
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -168,13 +675,13 @@ class Collection(models.Model):
|
|||
shared_with = models.ManyToManyField(User, related_name='shared_with', blank=True)
|
||||
link = models.URLField(blank=True, null=True, max_length=2083)
|
||||
|
||||
|
||||
# if connected adventures are private and collection is public, raise an error
|
||||
def clean(self):
|
||||
if self.is_public and self.pk: # Only check if the instance has a primary key
|
||||
for adventure in self.adventure_set.all():
|
||||
# Updated to use the new related_name 'adventures'
|
||||
for adventure in self.adventures.all():
|
||||
if not adventure.is_public:
|
||||
raise ValidationError('Public collections cannot be associated with private adventures. Collection: ' + self.name + ' Adventure: ' + adventure.name)
|
||||
raise ValidationError(f'Public collections cannot be associated with private adventures. Collection: {self.name} Adventure: {adventure.name}')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -191,6 +698,8 @@ class Transportation(models.Model):
|
|||
link = models.URLField(blank=True, null=True)
|
||||
date = models.DateTimeField(blank=True, null=True)
|
||||
end_date = models.DateTimeField(blank=True, null=True)
|
||||
start_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
|
||||
end_timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True)
|
||||
flight_number = models.CharField(max_length=100, blank=True, null=True)
|
||||
from_location = models.CharField(max_length=200, blank=True, null=True)
|
||||
origin_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
|
@ -296,18 +805,41 @@ class PathAndRename:
|
|||
|
||||
class AdventureImage(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
user_id = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE, default=default_user_id)
|
||||
user_id = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user_id)
|
||||
image = ResizedImageField(
|
||||
force_format="WEBP",
|
||||
quality=75,
|
||||
upload_to=PathAndRename('images/') # Use the callable class here
|
||||
upload_to=PathAndRename('images/'),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
immich_id = models.CharField(max_length=200, null=True, blank=True)
|
||||
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# One of image or immich_id must be set, but not both
|
||||
has_image = bool(self.image and str(self.image).strip())
|
||||
has_immich_id = bool(self.immich_id and str(self.immich_id).strip())
|
||||
|
||||
if has_image and has_immich_id:
|
||||
raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.")
|
||||
if not has_image and not has_immich_id:
|
||||
raise ValidationError("Must provide either an image file or an Immich ID.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Clean empty strings to None for proper database storage
|
||||
if not self.image:
|
||||
self.image = None
|
||||
if not self.immich_id or not str(self.immich_id).strip():
|
||||
self.immich_id = None
|
||||
|
||||
self.full_clean() # This calls clean() method
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.image.url
|
||||
return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}"
|
||||
|
||||
class Attachment(models.Model):
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
|
@ -352,6 +884,7 @@ class Lodging(models.Model):
|
|||
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)
|
||||
timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=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)
|
||||
|
@ -363,8 +896,8 @@ class Lodging(models.Model):
|
|||
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.check_in and self.check_out and self.check_in > self.check_out:
|
||||
raise ValidationError('The start date must be before the end date. Start date: ' + str(self.check_in) + ' End date: ' + str(self.check_out))
|
||||
|
||||
if self.collection:
|
||||
if self.collection.is_public and not self.is_public:
|
||||
|
|
|
@ -2,78 +2,99 @@ from rest_framework import permissions
|
|||
|
||||
class IsOwnerOrReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to only allow owners of an object to edit it.
|
||||
Owners can edit, others have read-only access.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Read permissions are allowed to any request,
|
||||
# so we'll always allow GET, HEAD or OPTIONS requests.
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Write permissions are only allowed to the owner of the object.
|
||||
# obj.user_id is FK to User, compare with request.user
|
||||
return obj.user_id == request.user
|
||||
|
||||
|
||||
class IsPublicReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to only allow read-only access to public objects,
|
||||
and write access to the owner of the object.
|
||||
Read-only if public or owner, write only for owner.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Read permissions are allowed if the object is public
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return obj.is_public or obj.user_id == request.user
|
||||
|
||||
# Write permissions are only allowed to the owner of the object
|
||||
return obj.user_id == request.user
|
||||
|
||||
|
||||
|
||||
class CollectionShared(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to only allow read-only access to public objects,
|
||||
and write access to the owner of the object.
|
||||
Allow full access if user is in shared_with of collection(s) or owner,
|
||||
read-only if public or shared_with,
|
||||
write only if owner or shared_with.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
# Anonymous: only read public
|
||||
return request.method in permissions.SAFE_METHODS and obj.is_public
|
||||
|
||||
# Read permissions are allowed if the object is shared with the user
|
||||
if obj.shared_with and obj.shared_with.filter(id=request.user.id).exists():
|
||||
return True
|
||||
|
||||
# Write permissions are allowed if the object is shared with the user
|
||||
if request.method not in permissions.SAFE_METHODS and obj.shared_with.filter(id=request.user.id).exists():
|
||||
return True
|
||||
# Check if user is in shared_with of any collections related to the obj
|
||||
# If obj is a Collection itself:
|
||||
if hasattr(obj, 'shared_with'):
|
||||
if obj.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
# Read permissions are allowed if the object is public
|
||||
# If obj is an Adventure (has collections M2M)
|
||||
if hasattr(obj, 'collections'):
|
||||
# Check if user is in shared_with of any related collection
|
||||
shared_collections = obj.collections.filter(shared_with=user)
|
||||
if shared_collections.exists():
|
||||
return True
|
||||
|
||||
# Read permission if public or owner
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return obj.is_public or obj.user_id == request.user
|
||||
return obj.is_public or obj.user_id == user
|
||||
|
||||
# Write permission only if owner or shared user via collections
|
||||
if obj.user_id == user:
|
||||
return True
|
||||
|
||||
if hasattr(obj, 'collections'):
|
||||
if obj.collections.filter(shared_with=user).exists():
|
||||
return True
|
||||
|
||||
# Default deny
|
||||
return False
|
||||
|
||||
# Write permissions are only allowed to the owner of the object
|
||||
return obj.user_id == request.user
|
||||
|
||||
class IsOwnerOrSharedWithFullAccess(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission to allow:
|
||||
- Full access for shared users
|
||||
- Full access for owners
|
||||
- Read-only access for others on safe methods
|
||||
Full access for owners and users shared via collections,
|
||||
read-only for others if public.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
# Allow GET only for a public object
|
||||
if request.method in permissions.SAFE_METHODS and obj.is_public:
|
||||
return True
|
||||
# Check if the object has a collection
|
||||
if hasattr(obj, 'collection') and obj.collection:
|
||||
# Allow all actions for shared users
|
||||
if request.user in obj.collection.shared_with.all():
|
||||
return True
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return request.method in permissions.SAFE_METHODS and obj.is_public
|
||||
|
||||
# Always allow GET, HEAD, or OPTIONS requests (safe methods)
|
||||
# If safe method (read), allow if:
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
if obj.is_public:
|
||||
return True
|
||||
if obj.user_id == user:
|
||||
return True
|
||||
# If user in shared_with of any collection related to obj
|
||||
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
||||
return True
|
||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
# For write methods, allow if owner or shared user
|
||||
if obj.user_id == user:
|
||||
return True
|
||||
if hasattr(obj, 'collections') and obj.collections.filter(shared_with=user).exists():
|
||||
return True
|
||||
if hasattr(obj, 'collection') and obj.collection and obj.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
if hasattr(obj, 'shared_with') and obj.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
|
||||
# Allow all actions for the owner
|
||||
return obj.user_id == request.user
|
||||
return False
|
||||
|
|
|
@ -4,22 +4,38 @@ from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note,
|
|||
from rest_framework import serializers
|
||||
from main.utils import CustomModelSerializer
|
||||
from users.serializers import CustomUserDetailsSerializer
|
||||
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
|
||||
from geopy.distance import geodesic
|
||||
from integrations.models import ImmichIntegration
|
||||
|
||||
|
||||
class AdventureImageSerializer(CustomModelSerializer):
|
||||
class Meta:
|
||||
model = AdventureImage
|
||||
fields = ['id', 'image', 'adventure', 'is_primary', 'user_id']
|
||||
fields = ['id', 'image', 'adventure', 'is_primary', 'user_id', 'immich_id']
|
||||
read_only_fields = ['id', 'user_id']
|
||||
|
||||
def to_representation(self, instance):
|
||||
# If immich_id is set, check for user integration once
|
||||
integration = None
|
||||
if instance.immich_id:
|
||||
integration = ImmichIntegration.objects.filter(user=instance.user_id).first()
|
||||
if not integration:
|
||||
return None # Skip if Immich image but no integration
|
||||
|
||||
# Base representation
|
||||
representation = super().to_representation(instance)
|
||||
if instance.image:
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
#print(public_url)
|
||||
# remove any ' from the url
|
||||
public_url = public_url.replace("'", "")
|
||||
|
||||
# Prepare public URL once
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "")
|
||||
|
||||
if instance.immich_id:
|
||||
# Use Immich integration URL
|
||||
representation['image'] = f"{public_url}/api/integrations/immich/{integration.id}/get/{instance.immich_id}"
|
||||
elif instance.image:
|
||||
# Use local image URL
|
||||
representation['image'] = f"{public_url}/media/{instance.image.name}"
|
||||
|
||||
return representation
|
||||
|
||||
class AttachmentSerializer(CustomModelSerializer):
|
||||
|
@ -72,26 +88,52 @@ class VisitSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = Visit
|
||||
fields = ['id', 'start_date', 'end_date', 'notes']
|
||||
fields = ['id', 'start_date', 'end_date', 'timezone', 'notes']
|
||||
read_only_fields = ['id']
|
||||
|
||||
class AdventureSerializer(CustomModelSerializer):
|
||||
images = AdventureImageSerializer(many=True, read_only=True)
|
||||
images = serializers.SerializerMethodField()
|
||||
visits = VisitSerializer(many=True, read_only=False, required=False)
|
||||
attachments = AttachmentSerializer(many=True, read_only=True)
|
||||
category = CategorySerializer(read_only=False, required=False)
|
||||
is_visited = serializers.SerializerMethodField()
|
||||
user = serializers.SerializerMethodField()
|
||||
country = CountrySerializer(read_only=True)
|
||||
region = RegionSerializer(read_only=True)
|
||||
city = CitySerializer(read_only=True)
|
||||
collections = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=Collection.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
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', 'user'
|
||||
'is_public', 'collections', 'created_at', 'updated_at', 'images', 'link', 'longitude',
|
||||
'latitude', 'visits', 'is_visited', 'category', 'attachments', 'user', 'city', 'country', 'region'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'is_visited', 'user']
|
||||
|
||||
def get_images(self, obj):
|
||||
serializer = AdventureImageSerializer(obj.images.all(), many=True, context=self.context)
|
||||
# Filter out None values from the serialized data
|
||||
return [image for image in serializer.data if image is not None]
|
||||
|
||||
def validate_collections(self, collections):
|
||||
"""Validate that collections belong to the same user"""
|
||||
if not collections:
|
||||
return collections
|
||||
|
||||
user = self.context['request'].user
|
||||
for collection in collections:
|
||||
if collection.user_id != user and not collection.shared_with.filter(id=user.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
f"Collection '{collection.name}' does not belong to the current user."
|
||||
)
|
||||
return collections
|
||||
|
||||
def validate_category(self, category_data):
|
||||
if isinstance(category_data, Category):
|
||||
return category_data
|
||||
|
@ -103,7 +145,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return existing_category
|
||||
category_data['name'] = name
|
||||
return category_data
|
||||
|
||||
|
||||
def get_or_create_category(self, category_data):
|
||||
user = self.context['request'].user
|
||||
|
||||
|
@ -113,7 +155,7 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
if isinstance(category_data, dict):
|
||||
name = category_data.get('name', '').lower()
|
||||
display_name = category_data.get('display_name', name)
|
||||
icon = category_data.get('icon', '<EFBFBD>')
|
||||
icon = category_data.get('icon', '🌍')
|
||||
else:
|
||||
name = category_data.name.lower()
|
||||
display_name = category_data.display_name
|
||||
|
@ -134,26 +176,30 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
return CustomUserDetailsSerializer(user).data
|
||||
|
||||
def get_is_visited(self, obj):
|
||||
current_date = timezone.now().date()
|
||||
for visit in obj.visits.all():
|
||||
if visit.start_date and visit.end_date and (visit.start_date <= current_date):
|
||||
return True
|
||||
elif visit.start_date and not visit.end_date and (visit.start_date <= current_date):
|
||||
return True
|
||||
return False
|
||||
return obj.is_visited_status()
|
||||
|
||||
def create(self, validated_data):
|
||||
visits_data = validated_data.pop('visits', None)
|
||||
category_data = validated_data.pop('category', None)
|
||||
collections_data = validated_data.pop('collections', [])
|
||||
|
||||
print(category_data)
|
||||
adventure = Adventure.objects.create(**validated_data)
|
||||
|
||||
# Handle visits
|
||||
for visit_data in visits_data:
|
||||
Visit.objects.create(adventure=adventure, **visit_data)
|
||||
|
||||
# Handle category
|
||||
if category_data:
|
||||
category = self.get_or_create_category(category_data)
|
||||
adventure.category = category
|
||||
adventure.save()
|
||||
|
||||
# Handle collections - set after adventure is saved
|
||||
if collections_data:
|
||||
adventure.collections.set(collections_data)
|
||||
|
||||
adventure.save()
|
||||
|
||||
return adventure
|
||||
|
||||
|
@ -162,14 +208,25 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
visits_data = validated_data.pop('visits', [])
|
||||
category_data = validated_data.pop('category', None)
|
||||
|
||||
collections_data = validated_data.pop('collections', None)
|
||||
|
||||
# Update regular fields
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
if category_data:
|
||||
# Handle category - ONLY allow the adventure owner to change categories
|
||||
user = self.context['request'].user
|
||||
if category_data and instance.user_id == user:
|
||||
# Only the owner can set categories
|
||||
category = self.get_or_create_category(category_data)
|
||||
instance.category = category
|
||||
instance.save()
|
||||
# If not the owner, ignore category changes
|
||||
|
||||
# Handle collections - only update if collections were provided
|
||||
if collections_data is not None:
|
||||
instance.collections.set(collections_data)
|
||||
|
||||
# Handle visits
|
||||
if has_visits:
|
||||
current_visits = instance.visits.all()
|
||||
current_visit_ids = set(current_visits.values_list('id', flat=True))
|
||||
|
@ -190,18 +247,37 @@ class AdventureSerializer(CustomModelSerializer):
|
|||
visits_to_delete = current_visit_ids - updated_visit_ids
|
||||
instance.visits.filter(id__in=visits_to_delete).delete()
|
||||
|
||||
# call save on the adventure to update the updated_at field and trigger any geocoding
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
class TransportationSerializer(CustomModelSerializer):
|
||||
distance = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Transportation
|
||||
fields = [
|
||||
'id', 'user_id', 'type', 'name', 'description', 'rating',
|
||||
'link', 'date', 'flight_number', 'from_location', 'to_location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date', 'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude'
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
|
||||
'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude',
|
||||
'start_timezone', 'end_timezone', 'distance' # ✅ Add distance here
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id', 'distance']
|
||||
|
||||
def get_distance(self, obj):
|
||||
if (
|
||||
obj.origin_latitude and obj.origin_longitude and
|
||||
obj.destination_latitude and obj.destination_longitude
|
||||
):
|
||||
try:
|
||||
origin = (float(obj.origin_latitude), float(obj.origin_longitude))
|
||||
destination = (float(obj.destination_latitude), float(obj.destination_longitude))
|
||||
return round(geodesic(origin, destination).km, 2)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
class LodgingSerializer(CustomModelSerializer):
|
||||
|
||||
|
@ -210,7 +286,7 @@ class LodgingSerializer(CustomModelSerializer):
|
|||
fields = [
|
||||
'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
|
||||
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
|
||||
'collection', 'created_at', 'updated_at', 'type'
|
||||
'collection', 'created_at', 'updated_at', 'type', 'timezone'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
|
||||
|
||||
|
@ -234,6 +310,7 @@ class ChecklistItemSerializer(CustomModelSerializer):
|
|||
|
||||
class ChecklistSerializer(CustomModelSerializer):
|
||||
items = ChecklistItemSerializer(many=True, source='checklistitem_set')
|
||||
|
||||
class Meta:
|
||||
model = Checklist
|
||||
fields = [
|
||||
|
@ -244,8 +321,16 @@ class ChecklistSerializer(CustomModelSerializer):
|
|||
def create(self, validated_data):
|
||||
items_data = validated_data.pop('checklistitem_set')
|
||||
checklist = Checklist.objects.create(**validated_data)
|
||||
|
||||
for item_data in items_data:
|
||||
ChecklistItem.objects.create(checklist=checklist, **item_data)
|
||||
# Remove user_id from item_data to avoid constraint issues
|
||||
item_data.pop('user_id', None)
|
||||
# Set user_id from the parent checklist
|
||||
ChecklistItem.objects.create(
|
||||
checklist=checklist,
|
||||
user_id=checklist.user_id,
|
||||
**item_data
|
||||
)
|
||||
return checklist
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
@ -263,6 +348,9 @@ class ChecklistSerializer(CustomModelSerializer):
|
|||
# Update or create items
|
||||
updated_item_ids = set()
|
||||
for item_data in items_data:
|
||||
# Remove user_id from item_data to avoid constraint issues
|
||||
item_data.pop('user_id', None)
|
||||
|
||||
item_id = item_data.get('id')
|
||||
if item_id:
|
||||
if item_id in current_item_ids:
|
||||
|
@ -273,10 +361,18 @@ class ChecklistSerializer(CustomModelSerializer):
|
|||
updated_item_ids.add(item_id)
|
||||
else:
|
||||
# If ID is provided but doesn't exist, create new item
|
||||
ChecklistItem.objects.create(checklist=instance, **item_data)
|
||||
ChecklistItem.objects.create(
|
||||
checklist=instance,
|
||||
user_id=instance.user_id,
|
||||
**item_data
|
||||
)
|
||||
else:
|
||||
# If no ID is provided, create new item
|
||||
ChecklistItem.objects.create(checklist=instance, **item_data)
|
||||
ChecklistItem.objects.create(
|
||||
checklist=instance,
|
||||
user_id=instance.user_id,
|
||||
**item_data
|
||||
)
|
||||
|
||||
# Delete items that are not in the updated data
|
||||
items_to_delete = current_item_ids - updated_item_ids
|
||||
|
@ -292,11 +388,10 @@ class ChecklistSerializer(CustomModelSerializer):
|
|||
raise serializers.ValidationError(
|
||||
'Checklists associated with a public collection must be public.'
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
class CollectionSerializer(CustomModelSerializer):
|
||||
adventures = AdventureSerializer(many=True, read_only=True, source='adventure_set')
|
||||
adventures = AdventureSerializer(many=True, read_only=True)
|
||||
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')
|
||||
|
|
23
backend/server/adventures/signals.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from adventures.models import Adventure
|
||||
|
||||
@receiver(m2m_changed, sender=Adventure.collections.through)
|
||||
def update_adventure_publicity(sender, instance, action, **kwargs):
|
||||
"""
|
||||
Signal handler to update adventure publicity when collections are added/removed
|
||||
"""
|
||||
# Only process when collections are added or removed
|
||||
if action in ('post_add', 'post_remove', 'post_clear'):
|
||||
collections = instance.collections.all()
|
||||
|
||||
if collections.exists():
|
||||
# If any collection is public, make the adventure public
|
||||
has_public_collection = collections.filter(is_public=True).exists()
|
||||
|
||||
if has_public_collection and not instance.is_public:
|
||||
instance.is_public = True
|
||||
instance.save(update_fields=['is_public'])
|
||||
elif not has_public_collection and instance.is_public:
|
||||
instance.is_public = False
|
||||
instance.save(update_fields=['is_public'])
|
|
@ -15,11 +15,10 @@ router.register(r'images', AdventureImageViewSet, basename='images')
|
|||
router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode')
|
||||
router.register(r'categories', CategoryViewSet, basename='categories')
|
||||
router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar')
|
||||
router.register(r'overpass', OverpassViewSet, basename='overpass')
|
||||
router.register(r'search', GlobalSearchView, basename='search')
|
||||
router.register(r'attachments', AttachmentViewSet, basename='attachments')
|
||||
router.register(r'lodging', LodgingViewSet, basename='lodging')
|
||||
|
||||
router.register(r'recommendations', RecommendationsViewSet, basename='recommendations')
|
||||
|
||||
urlpatterns = [
|
||||
# Include the router under the 'api/' prefix
|
||||
|
|
|
@ -15,9 +15,12 @@ def checkFilePermission(fileId, user, mediaType):
|
|||
return True
|
||||
elif adventure.user_id == user:
|
||||
return True
|
||||
elif adventure.collection:
|
||||
if adventure.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
elif adventure.collections.exists():
|
||||
# Check if the user is in any collection's shared_with list
|
||||
for collection in adventure.collections.all():
|
||||
if collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
except AdventureImage.DoesNotExist:
|
||||
|
@ -27,14 +30,18 @@ def checkFilePermission(fileId, user, mediaType):
|
|||
# Construct the full relative path to match the database field
|
||||
attachment_path = f"attachments/{fileId}"
|
||||
# Fetch the Attachment object
|
||||
attachment = Attachment.objects.get(file=attachment_path).adventure
|
||||
if attachment.is_public:
|
||||
attachment = Attachment.objects.get(file=attachment_path)
|
||||
adventure = attachment.adventure
|
||||
if adventure.is_public:
|
||||
return True
|
||||
elif attachment.user_id == user:
|
||||
elif adventure.user_id == user:
|
||||
return True
|
||||
elif attachment.collection:
|
||||
if attachment.collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
elif adventure.collections.exists():
|
||||
# Check if the user is in any collection's shared_with list
|
||||
for collection in adventure.collections.all():
|
||||
if collection.shared_with.filter(id=user.id).exists():
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
except Attachment.DoesNotExist:
|
||||
|
|
|
@ -7,10 +7,10 @@ from .collection_view import *
|
|||
from .generate_description_view import *
|
||||
from .ics_calendar_view import *
|
||||
from .note_view import *
|
||||
from .overpass_view import *
|
||||
from .reverse_geocode_view import *
|
||||
from .stats_view import *
|
||||
from .transportation_view import *
|
||||
from .global_search_view import *
|
||||
from .attachment_view import *
|
||||
from .lodging_view import *
|
||||
from .lodging_view import *
|
||||
from .recommendations_view import *
|
|
@ -3,9 +3,12 @@ from rest_framework.decorators import action
|
|||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from django.core.files.base import ContentFile
|
||||
from adventures.models import Adventure, AdventureImage
|
||||
from adventures.serializers import AdventureImageSerializer
|
||||
from integrations.models import ImmichIntegration
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
class AdventureImageViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AdventureImageSerializer
|
||||
|
@ -48,14 +51,92 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
|||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
# Check if the adventure has a collection
|
||||
if adventure.collection:
|
||||
# Check if the user is in the collection's shared_with list
|
||||
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
|
||||
# Check if the adventure has any collections
|
||||
if adventure.collections.exists():
|
||||
# Check if the user is in the shared_with list of any of the adventure's collections
|
||||
user_has_access = False
|
||||
for collection in adventure.collections.all():
|
||||
if collection.shared_with.filter(id=request.user.id).exists():
|
||||
user_has_access = True
|
||||
break
|
||||
|
||||
if not user_has_access:
|
||||
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Handle Immich ID for shared users by downloading the image
|
||||
if (request.user != adventure.user_id and
|
||||
'immich_id' in request.data and
|
||||
request.data.get('immich_id')):
|
||||
|
||||
immich_id = request.data.get('immich_id')
|
||||
|
||||
# Get the shared user's Immich integration
|
||||
try:
|
||||
user_integration = ImmichIntegration.objects.get(user_id=request.user)
|
||||
except ImmichIntegration.DoesNotExist:
|
||||
return Response({
|
||||
"error": "No Immich integration found for your account. Please set up Immich integration first.",
|
||||
"code": "immich_integration_not_found"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Download the image from the shared user's Immich server
|
||||
try:
|
||||
immich_response = requests.get(
|
||||
f'{user_integration.server_url}/assets/{immich_id}/thumbnail?size=preview',
|
||||
headers={'x-api-key': user_integration.api_key},
|
||||
timeout=10
|
||||
)
|
||||
immich_response.raise_for_status()
|
||||
|
||||
# Create a temporary file with the downloaded content
|
||||
content_type = immich_response.headers.get('Content-Type', 'image/jpeg')
|
||||
if not content_type.startswith('image/'):
|
||||
return Response({
|
||||
"error": "Invalid content type returned from Immich server.",
|
||||
"code": "invalid_content_type"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif'
|
||||
}
|
||||
file_ext = ext_map.get(content_type, '.jpg')
|
||||
filename = f"immich_{immich_id}{file_ext}"
|
||||
|
||||
# Create a Django ContentFile from the downloaded image
|
||||
image_file = ContentFile(immich_response.content, name=filename)
|
||||
|
||||
# Modify request data to use the downloaded image instead of immich_id
|
||||
request_data = request.data.copy()
|
||||
request_data.pop('immich_id', None) # Remove immich_id
|
||||
request_data['image'] = image_file # Add the image file
|
||||
|
||||
# Create the serializer with the modified data
|
||||
serializer = self.get_serializer(data=request_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Save with the downloaded image
|
||||
adventure = serializer.validated_data['adventure']
|
||||
serializer.save(user_id=adventure.user_id, image=image_file)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
return Response({
|
||||
"error": f"Failed to fetch image from Immich server",
|
||||
"code": "immich_fetch_failed"
|
||||
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
except Exception:
|
||||
return Response({
|
||||
"error": f"Unexpected error processing Immich image",
|
||||
"code": "immich_processing_error"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
|
@ -110,15 +191,25 @@ class AdventureImageViewSet(viewsets.ModelViewSet):
|
|||
except ValueError:
|
||||
return Response({"error": "Invalid adventure ID"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Updated queryset to include images from adventures the user owns OR has shared access to
|
||||
queryset = AdventureImage.objects.filter(
|
||||
Q(adventure__id=adventure_uuid) & Q(user_id=request.user)
|
||||
)
|
||||
Q(adventure__id=adventure_uuid) & (
|
||||
Q(adventure__user_id=request.user) | # User owns the adventure
|
||||
Q(adventure__collections__shared_with=request.user) # User has shared access via collection
|
||||
)
|
||||
).distinct()
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_queryset(self):
|
||||
return AdventureImage.objects.filter(user_id=self.request.user)
|
||||
# Updated to include images from adventures the user owns OR has shared access to
|
||||
return AdventureImage.objects.filter(
|
||||
Q(adventure__user_id=self.request.user) | # User owns the adventure
|
||||
Q(adventure__collections__shared_with=self.request.user) # User has shared access via collection
|
||||
).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user_id=self.request.user)
|
||||
# Always set the image owner to the adventure owner, not the current user
|
||||
adventure = serializer.validated_data['adventure']
|
||||
serializer.save(user_id=adventure.user_id)
|
|
@ -3,70 +3,44 @@ from django.db import transaction
|
|||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q, Max
|
||||
from django.db.models.functions import Lower
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
import requests
|
||||
|
||||
from adventures.models import Adventure, Category, Transportation, Lodging
|
||||
from adventures.permissions import IsOwnerOrSharedWithFullAccess
|
||||
from adventures.serializers import AdventureSerializer, TransportationSerializer, LodgingSerializer
|
||||
from adventures.utils import pagination
|
||||
|
||||
|
||||
class AdventureViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Adventure objects with support for filtering, sorting,
|
||||
and sharing functionality.
|
||||
"""
|
||||
serializer_class = AdventureSerializer
|
||||
permission_classes = [IsOwnerOrSharedWithFullAccess]
|
||||
pagination_class = pagination.StandardResultsSetPagination
|
||||
|
||||
def apply_sorting(self, queryset):
|
||||
order_by = self.request.query_params.get('order_by', 'updated_at')
|
||||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
include_collections = self.request.query_params.get('include_collections', 'true')
|
||||
|
||||
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'name'
|
||||
|
||||
if order_direction not in ['asc', 'desc']:
|
||||
order_direction = 'asc'
|
||||
|
||||
if order_by == 'date':
|
||||
queryset = queryset.annotate(latest_visit=Max('visits__start_date')).filter(latest_visit__isnull=False)
|
||||
ordering = 'latest_visit'
|
||||
elif order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
elif order_by == 'rating':
|
||||
queryset = queryset.filter(rating__isnull=False)
|
||||
ordering = 'rating'
|
||||
else:
|
||||
ordering = order_by
|
||||
|
||||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
|
||||
if order_by == 'updated_at':
|
||||
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
|
||||
|
||||
if include_collections == 'false':
|
||||
queryset = queryset.filter(collection=None)
|
||||
|
||||
return queryset.order_by(ordering)
|
||||
# ==================== QUERYSET & PERMISSIONS ====================
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Returns the queryset for the AdventureViewSet. Unauthenticated users can only
|
||||
retrieve public adventures, while authenticated users can access their own,
|
||||
shared, and public adventures depending on the action.
|
||||
Returns queryset based on user authentication and action type.
|
||||
Public actions allow unauthenticated access to public adventures.
|
||||
"""
|
||||
user = self.request.user
|
||||
public_allowed_actions = {'retrieve', 'additional_info'}
|
||||
|
||||
if not user.is_authenticated:
|
||||
# Unauthenticated users can only access public adventures for retrieval
|
||||
if self.action == 'retrieve':
|
||||
return Adventure.objects.retrieve_adventures(user, include_public=True).order_by('-updated_at')
|
||||
if self.action in public_allowed_actions:
|
||||
return Adventure.objects.retrieve_adventures(
|
||||
user, include_public=True
|
||||
).order_by('-updated_at')
|
||||
return Adventure.objects.none()
|
||||
|
||||
# Authenticated users: Handle retrieval separately
|
||||
include_public = self.action == 'retrieve'
|
||||
include_public = self.action in public_allowed_actions
|
||||
return Adventure.objects.retrieve_adventures(
|
||||
user,
|
||||
include_public=include_public,
|
||||
|
@ -74,144 +48,294 @@ class AdventureViewSet(viewsets.ModelViewSet):
|
|||
include_shared=True
|
||||
).order_by('-updated_at')
|
||||
|
||||
# ==================== SORTING & FILTERING ====================
|
||||
|
||||
def apply_sorting(self, queryset):
|
||||
"""Apply sorting and collection filtering to queryset."""
|
||||
order_by = self.request.query_params.get('order_by', 'updated_at')
|
||||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
include_collections = self.request.query_params.get('include_collections', 'true')
|
||||
|
||||
# Validate parameters
|
||||
valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'name'
|
||||
|
||||
if order_direction not in ['asc', 'desc']:
|
||||
order_direction = 'asc'
|
||||
|
||||
# Apply sorting logic
|
||||
queryset = self._apply_ordering(queryset, order_by, order_direction)
|
||||
|
||||
# Filter adventures without collections if requested
|
||||
if include_collections == 'false':
|
||||
queryset = queryset.filter(collections__isnull=True)
|
||||
|
||||
return queryset
|
||||
|
||||
def _apply_ordering(self, queryset, order_by, order_direction):
|
||||
"""Apply ordering to queryset based on field type."""
|
||||
if order_by == 'date':
|
||||
queryset = queryset.annotate(
|
||||
latest_visit=Max('visits__start_date')
|
||||
).filter(latest_visit__isnull=False)
|
||||
ordering = 'latest_visit'
|
||||
elif order_by == 'name':
|
||||
queryset = queryset.annotate(lower_name=Lower('name'))
|
||||
ordering = 'lower_name'
|
||||
elif order_by == 'rating':
|
||||
queryset = queryset.filter(rating__isnull=False)
|
||||
ordering = 'rating'
|
||||
elif order_by == 'updated_at':
|
||||
# Special handling for updated_at (reverse default order)
|
||||
ordering = '-updated_at' if order_direction == 'asc' else 'updated_at'
|
||||
return queryset.order_by(ordering)
|
||||
else:
|
||||
ordering = order_by
|
||||
|
||||
# Apply direction
|
||||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
|
||||
return queryset.order_by(ordering)
|
||||
|
||||
# ==================== CRUD OPERATIONS ====================
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
"""Create adventure with collection validation and ownership logic."""
|
||||
collections = serializer.validated_data.get('collections', [])
|
||||
|
||||
# Validate permissions for all collections
|
||||
self._validate_collection_permissions(collections)
|
||||
|
||||
# Determine what user to assign as owner
|
||||
user_to_assign = self.request.user
|
||||
|
||||
if collections:
|
||||
# Use the current user as owner since ManyToMany allows multiple collection owners
|
||||
user_to_assign = self.request.user
|
||||
|
||||
serializer.save(user_id=user_to_assign)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
adventure = serializer.save()
|
||||
if adventure.collection:
|
||||
adventure.is_public = adventure.collection.is_public
|
||||
adventure.save()
|
||||
"""Update adventure."""
|
||||
# Just save the adventure - the signal will handle publicity updates
|
||||
serializer.save()
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Handle adventure updates with collection permission validation."""
|
||||
instance = self.get_object()
|
||||
partial = kwargs.pop('partial', False)
|
||||
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Validate collection permissions if collections are being updated
|
||||
if 'collections' in serializer.validated_data:
|
||||
self._validate_collection_update_permissions(
|
||||
instance, serializer.validated_data['collections']
|
||||
)
|
||||
else:
|
||||
# Remove collections from validated_data if not provided
|
||||
serializer.validated_data.pop('collections', None)
|
||||
|
||||
self.perform_update(serializer)
|
||||
return Response(serializer.data)
|
||||
|
||||
# ==================== CUSTOM ACTIONS ====================
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def filtered(self, request):
|
||||
"""Filter adventures by category types and visit status."""
|
||||
types = request.query_params.get('types', '').split(',')
|
||||
is_visited = request.query_params.get('is_visited', 'all')
|
||||
|
||||
|
||||
# Handle 'all' types
|
||||
if 'all' in types:
|
||||
types = Category.objects.filter(user_id=request.user).values_list('name', flat=True)
|
||||
types = Category.objects.filter(
|
||||
user_id=request.user
|
||||
).values_list('name', flat=True)
|
||||
else:
|
||||
# Validate provided types
|
||||
if not types or not all(
|
||||
Category.objects.filter(user_id=request.user, name=type).exists() for type in types
|
||||
Category.objects.filter(user_id=request.user, name=type_name).exists()
|
||||
for type_name in types
|
||||
):
|
||||
return Response({"error": "Invalid category or no types provided"}, status=400)
|
||||
return Response(
|
||||
{"error": "Invalid category or no types provided"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Build base queryset
|
||||
queryset = Adventure.objects.filter(
|
||||
category__in=Category.objects.filter(name__in=types, user_id=request.user),
|
||||
user_id=request.user.id
|
||||
)
|
||||
|
||||
is_visited_param = request.query_params.get('is_visited')
|
||||
if is_visited_param is not None:
|
||||
# Convert is_visited_param to a boolean
|
||||
if is_visited_param.lower() == 'true':
|
||||
is_visited_bool = True
|
||||
elif is_visited_param.lower() == 'false':
|
||||
is_visited_bool = False
|
||||
else:
|
||||
is_visited_bool = None
|
||||
|
||||
# Filter logic: "visited" means at least one visit with start_date <= today
|
||||
now = timezone.now().date()
|
||||
if is_visited_bool is True:
|
||||
queryset = queryset.filter(visits__start_date__lte=now).distinct()
|
||||
elif is_visited_bool is False:
|
||||
queryset = queryset.exclude(visits__start_date__lte=now).distinct()
|
||||
|
||||
# Apply visit status filtering
|
||||
queryset = self._apply_visit_filtering(queryset, request)
|
||||
queryset = self.apply_sorting(queryset)
|
||||
|
||||
return self.paginate_and_respond(queryset, request)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def all(self, request):
|
||||
"""Get all adventures (public and owned) with optional collection filtering."""
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
|
||||
include_collections = request.query_params.get('include_collections', 'false') == 'true'
|
||||
queryset = Adventure.objects.filter(
|
||||
Q(is_public=True) | Q(user_id=request.user.id),
|
||||
collection=None if not include_collections else Q()
|
||||
)
|
||||
|
||||
# Build queryset with collection filtering
|
||||
base_filter = Q(user_id=request.user.id)
|
||||
|
||||
if include_collections:
|
||||
queryset = Adventure.objects.filter(base_filter)
|
||||
else:
|
||||
queryset = Adventure.objects.filter(base_filter, collections__isnull=True)
|
||||
|
||||
queryset = self.apply_sorting(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@action(detail=True, methods=['get'], url_path='additional-info')
|
||||
def additional_info(self, request, pk=None):
|
||||
"""Get adventure with additional sunrise/sunset information."""
|
||||
adventure = self.get_object()
|
||||
user = request.user
|
||||
|
||||
new_collection = serializer.validated_data.get('collection')
|
||||
if new_collection and new_collection!=instance.collection:
|
||||
if new_collection.user_id != request.user or instance.user_id != request.user:
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif new_collection is None and instance.collection and instance.collection.user_id != request.user:
|
||||
raise PermissionDenied("You cannot remove the collection as you are not the owner.")
|
||||
# Validate access permissions
|
||||
if not self._has_adventure_access(adventure, user):
|
||||
return Response(
|
||||
{"error": "User does not have permission to access this adventure"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
self.perform_update(serializer)
|
||||
return Response(serializer.data)
|
||||
# Get base adventure data
|
||||
serializer = self.get_serializer(adventure)
|
||||
response_data = serializer.data
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
collection = serializer.validated_data.get('collection')
|
||||
# Add sunrise/sunset data
|
||||
response_data['sun_times'] = self._get_sun_times(adventure, response_data.get('visits', []))
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
if collection and not (collection.user_id == self.request.user or collection.shared_with.filter(id=self.request.user.id).exists()):
|
||||
raise PermissionDenied("You do not have permission to use this collection.")
|
||||
elif collection:
|
||||
serializer.save(user_id=collection.user_id, is_public=collection.is_public)
|
||||
return
|
||||
# ==================== HELPER METHODS ====================
|
||||
|
||||
serializer.save(user_id=self.request.user, is_public=collection.is_public if collection else False)
|
||||
def _validate_collection_permissions(self, collections):
|
||||
"""Validate user has permission to use all provided collections. Only the owner or shared users can use collections."""
|
||||
for collection in collections:
|
||||
if not (collection.user_id == self.request.user or
|
||||
collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
|
||||
raise PermissionDenied(
|
||||
f"You do not have permission to use collection '{collection.name}'."
|
||||
)
|
||||
|
||||
def _validate_collection_update_permissions(self, instance, new_collections):
|
||||
"""Validate permissions for collection updates (add/remove)."""
|
||||
# Check permissions for new collections being added
|
||||
for collection in new_collections:
|
||||
if (collection.user_id != self.request.user and
|
||||
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
|
||||
raise PermissionDenied(
|
||||
f"You do not have permission to use collection '{collection.name}'."
|
||||
)
|
||||
|
||||
# Check permissions for collections being removed
|
||||
current_collections = set(instance.collections.all())
|
||||
new_collections_set = set(new_collections)
|
||||
collections_to_remove = current_collections - new_collections_set
|
||||
|
||||
for collection in collections_to_remove:
|
||||
if (collection.user_id != self.request.user and
|
||||
not collection.shared_with.filter(uuid=self.request.user.uuid).exists()):
|
||||
raise PermissionDenied(
|
||||
f"You cannot remove the adventure from collection '{collection.name}' "
|
||||
f"as you don't have permission."
|
||||
)
|
||||
|
||||
def _apply_visit_filtering(self, queryset, request):
|
||||
"""Apply visit status filtering to queryset."""
|
||||
is_visited_param = request.query_params.get('is_visited')
|
||||
if is_visited_param is None:
|
||||
return queryset
|
||||
|
||||
# Convert parameter to boolean
|
||||
if is_visited_param.lower() == 'true':
|
||||
is_visited_bool = True
|
||||
elif is_visited_param.lower() == 'false':
|
||||
is_visited_bool = False
|
||||
else:
|
||||
return queryset
|
||||
|
||||
# Apply visit filtering
|
||||
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()
|
||||
|
||||
return queryset
|
||||
|
||||
def _has_adventure_access(self, adventure, user):
|
||||
"""Check if user has access to adventure."""
|
||||
# Allow if public
|
||||
if adventure.is_public:
|
||||
return True
|
||||
|
||||
# Check ownership
|
||||
if user.is_authenticated and adventure.user_id == user:
|
||||
return True
|
||||
|
||||
# Check shared collection access
|
||||
if user.is_authenticated:
|
||||
for collection in adventure.collections.all():
|
||||
if collection.shared_with.filter(uuid=user.uuid).exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_sun_times(self, adventure, visits):
|
||||
"""Get sunrise/sunset times for adventure visits."""
|
||||
sun_times = []
|
||||
|
||||
for visit in visits:
|
||||
date = visit.get('start_date')
|
||||
if not (date and adventure.longitude and adventure.latitude):
|
||||
continue
|
||||
|
||||
api_url = (
|
||||
f'https://api.sunrisesunset.io/json?'
|
||||
f'lat={adventure.latitude}&lng={adventure.longitude}&date={date}'
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(api_url)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
results = data.get('results', {})
|
||||
|
||||
if results.get('sunrise') and results.get('sunset'):
|
||||
sun_times.append({
|
||||
"date": date,
|
||||
"visit_id": visit.get('id'),
|
||||
"sunrise": results.get('sunrise'),
|
||||
"sunset": results.get('sunset')
|
||||
})
|
||||
except requests.RequestException:
|
||||
# Skip this visit if API call fails
|
||||
continue
|
||||
|
||||
return sun_times
|
||||
|
||||
def paginate_and_respond(self, queryset, request):
|
||||
"""Paginate queryset and return response."""
|
||||
paginator = self.pagination_class()
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return paginator.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
# @action(detail=True, methods=['post'])
|
||||
# def convert(self, request, pk=None):
|
||||
# """
|
||||
# Convert an Adventure instance into a Transportation or Lodging instance.
|
||||
# Expects a JSON body with "target_type": "transportation" or "lodging".
|
||||
# """
|
||||
# adventure = self.get_object()
|
||||
# target_type = request.data.get("target_type", "").lower()
|
||||
|
||||
# if target_type not in ["transportation", "lodging"]:
|
||||
# return Response(
|
||||
# {"error": "Invalid target type. Must be 'transportation' or 'lodging'."},
|
||||
# status=400
|
||||
# )
|
||||
# if not adventure.collection:
|
||||
# return Response(
|
||||
# {"error": "Adventure must be part of a collection to be converted."},
|
||||
# status=400
|
||||
# )
|
||||
|
||||
# # Define the overlapping fields that both the Adventure and target models share.
|
||||
# overlapping_fields = ["name", "description", "is_public", 'collection']
|
||||
|
||||
# # Gather the overlapping data from the adventure instance.
|
||||
# conversion_data = {}
|
||||
# for field in overlapping_fields:
|
||||
# if hasattr(adventure, field):
|
||||
# conversion_data[field] = getattr(adventure, field)
|
||||
|
||||
# # Make sure to include the user reference
|
||||
# conversion_data["user_id"] = adventure.user_id
|
||||
|
||||
# # Convert the adventure instance within an atomic transaction.
|
||||
# with transaction.atomic():
|
||||
# if target_type == "transportation":
|
||||
# new_instance = Transportation.objects.create(**conversion_data)
|
||||
# serializer = TransportationSerializer(new_instance)
|
||||
# else: # target_type == "lodging"
|
||||
# new_instance = Lodging.objects.create(**conversion_data)
|
||||
# serializer = LodgingSerializer(new_instance)
|
||||
|
||||
# # Optionally, delete the original adventure to avoid duplicates.
|
||||
# adventure.delete()
|
||||
|
||||
# return Response(serializer.data)
|
||||
return Response(serializer.data)
|
|
@ -26,10 +26,16 @@ class AttachmentViewSet(viewsets.ModelViewSet):
|
|||
return Response({"error": "Adventure not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if adventure.user_id != request.user:
|
||||
# Check if the adventure has a collection
|
||||
if adventure.collection:
|
||||
# Check if the user is in the collection's shared_with list
|
||||
if not adventure.collection.shared_with.filter(id=request.user.id).exists():
|
||||
# Check if the adventure has any collections
|
||||
if adventure.collections.exists():
|
||||
# Check if the user is in the shared_with list of any of the adventure's collections
|
||||
user_has_access = False
|
||||
for collection in adventure.collections.all():
|
||||
if collection.shared_with.filter(id=request.user.id).exists():
|
||||
user_has_access = True
|
||||
break
|
||||
|
||||
if not user_has_access:
|
||||
return Response({"error": "User does not have permission to access this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
return Response({"error": "User does not own this adventure"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
@ -37,4 +43,14 @@ class AttachmentViewSet(viewsets.ModelViewSet):
|
|||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user_id=self.request.user)
|
||||
adventure_id = self.request.data.get('adventure')
|
||||
adventure = Adventure.objects.get(id=adventure_id)
|
||||
|
||||
# If the adventure belongs to collections, set the owner to the collection owner
|
||||
if adventure.collections.exists():
|
||||
# Get the first collection's owner (assuming all collections have the same owner)
|
||||
collection = adventure.collections.first()
|
||||
serializer.save(user_id=collection.user_id)
|
||||
else:
|
||||
# Otherwise, set the owner to the request user
|
||||
serializer.save(user_id=self.request.user)
|
|
@ -4,7 +4,7 @@ from django.db import transaction
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from adventures.models import Collection, Adventure, Transportation, Note
|
||||
from adventures.models import Collection, Adventure, Transportation, Note, Checklist
|
||||
from adventures.permissions import CollectionShared
|
||||
from adventures.serializers import CollectionSerializer
|
||||
from users.models import CustomUser as User
|
||||
|
@ -22,7 +22,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
order_by = self.request.query_params.get('order_by', 'name')
|
||||
order_direction = self.request.query_params.get('order_direction', 'asc')
|
||||
|
||||
valid_order_by = ['name', 'upated_at']
|
||||
valid_order_by = ['name', 'updated_at', 'start_date']
|
||||
if order_by not in valid_order_by:
|
||||
order_by = 'updated_at'
|
||||
|
||||
|
@ -35,6 +35,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
ordering = 'lower_name'
|
||||
if order_direction == 'desc':
|
||||
ordering = f'-{ordering}'
|
||||
elif order_by == 'start_date':
|
||||
ordering = 'start_date'
|
||||
if order_direction == 'asc':
|
||||
ordering = 'start_date'
|
||||
else:
|
||||
ordering = '-start_date'
|
||||
else:
|
||||
order_by == 'updated_at'
|
||||
ordering = 'updated_at'
|
||||
|
@ -49,7 +55,7 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
# make sure the user is authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return Response({"error": "User is not authenticated"}, status=400)
|
||||
queryset = Collection.objects.filter(user_id=request.user.id)
|
||||
queryset = Collection.objects.filter(user_id=request.user.id, is_archived=False)
|
||||
queryset = self.apply_sorting(queryset)
|
||||
collections = self.paginate_and_respond(queryset, request)
|
||||
return collections
|
||||
|
@ -100,23 +106,40 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
if 'is_public' in serializer.validated_data:
|
||||
new_public_status = serializer.validated_data['is_public']
|
||||
|
||||
# if is_publuc has changed and the user is not the owner of the collection return an error
|
||||
# if is_public has changed and the user is not the owner of the collection return an error
|
||||
if new_public_status != instance.is_public and instance.user_id != request.user:
|
||||
print(f"User {request.user.id} does not own the collection {instance.id} that is owned by {instance.user_id}")
|
||||
return Response({"error": "User does not own the collection"}, status=400)
|
||||
|
||||
# Update associated adventures to match the collection's is_public status
|
||||
Adventure.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
# Get all adventures in this collection
|
||||
adventures_in_collection = Adventure.objects.filter(collections=instance)
|
||||
|
||||
if new_public_status:
|
||||
# If collection becomes public, make all adventures public
|
||||
adventures_in_collection.update(is_public=True)
|
||||
else:
|
||||
# If collection becomes private, check each adventure
|
||||
# Only set an adventure to private if ALL of its collections are private
|
||||
# Collect adventures that do NOT belong to any other public collection (excluding the current one)
|
||||
adventure_ids_to_set_private = []
|
||||
|
||||
# do the same for transportations
|
||||
for adventure in adventures_in_collection:
|
||||
has_public_collection = adventure.collections.filter(is_public=True).exclude(id=instance.id).exists()
|
||||
if not has_public_collection:
|
||||
adventure_ids_to_set_private.append(adventure.id)
|
||||
|
||||
# Bulk update those adventures
|
||||
Adventure.objects.filter(id__in=adventure_ids_to_set_private).update(is_public=False)
|
||||
|
||||
# Update transportations, notes, and checklists related to this collection
|
||||
# These still use direct ForeignKey relationships
|
||||
Transportation.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
|
||||
# do the same for notes
|
||||
Note.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
Checklist.objects.filter(collection=instance).update(is_public=new_public_status)
|
||||
|
||||
# Log the action (optional)
|
||||
action = "public" if new_public_status else "private"
|
||||
print(f"Collection {instance.id} and its adventures were set to {action}")
|
||||
print(f"Collection {instance.id} and its related objects were set to {action}")
|
||||
|
||||
self.perform_update(serializer)
|
||||
|
||||
|
@ -203,7 +226,6 @@ class CollectionViewSet(viewsets.ModelViewSet):
|
|||
(Q(user_id=self.request.user.id) | Q(shared_with=self.request.user)) & Q(is_archived=False)
|
||||
).distinct()
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# This is ok because you cannot share a collection when creating it
|
||||
serializer.save(user_id=self.request.user)
|
||||
|
|
|
@ -1,183 +0,0 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
import requests
|
||||
|
||||
class OverpassViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
BASE_URL = "https://overpass-api.de/api/interpreter"
|
||||
HEADERS = {'User-Agent': 'AdventureLog Server'}
|
||||
|
||||
def make_overpass_query(self, query):
|
||||
"""
|
||||
Sends a query to the Overpass API and returns the response data.
|
||||
Args:
|
||||
query (str): The Overpass QL query string.
|
||||
Returns:
|
||||
dict: Parsed JSON response from the Overpass API.
|
||||
Raises:
|
||||
Response: DRF Response object with an error message in case of failure.
|
||||
"""
|
||||
url = f"{self.BASE_URL}?data={query}"
|
||||
try:
|
||||
response = requests.get(url, headers=self.HEADERS)
|
||||
response.raise_for_status() # Raise an exception for HTTP errors
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException:
|
||||
return Response({"error": "Failed to connect to Overpass API"}, status=500)
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from Overpass API"}, status=400)
|
||||
|
||||
def parse_overpass_response(self, data, request):
|
||||
"""
|
||||
Parses the JSON response from the Overpass API and extracts relevant data,
|
||||
turning it into an adventure-structured object.
|
||||
|
||||
Args:
|
||||
response (dict): The JSON response from the Overpass API.
|
||||
|
||||
Returns:
|
||||
list: A list of adventure objects with structured data.
|
||||
"""
|
||||
# Extract elements (nodes/ways/relations) from the response
|
||||
nodes = data.get('elements', [])
|
||||
adventures = []
|
||||
|
||||
# include all entries, even the ones that do not have lat long
|
||||
all = request.query_params.get('all', False)
|
||||
|
||||
for node in nodes:
|
||||
# Ensure we are working with a "node" type (can also handle "way" or "relation" if needed)
|
||||
if node.get('type') not in ['node', 'way', 'relation']:
|
||||
continue
|
||||
|
||||
# Extract tags and general data
|
||||
tags = node.get('tags', {})
|
||||
adventure = {
|
||||
"id": node.get('id'), # Include the unique OSM ID
|
||||
"type": node.get('type'), # Type of element (node, way, relation)
|
||||
"name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name'
|
||||
"description": tags.get('description', None), # Additional descriptive information
|
||||
"latitude": node.get('lat', None), # Use None for consistency with missing values
|
||||
"longitude": node.get('lon', None),
|
||||
"address": {
|
||||
"city": tags.get('addr:city', None),
|
||||
"housenumber": tags.get('addr:housenumber', None),
|
||||
"postcode": tags.get('addr:postcode', None),
|
||||
"state": tags.get('addr:state', None),
|
||||
"street": tags.get('addr:street', None),
|
||||
"country": tags.get('addr:country', None), # Add 'country' if available
|
||||
"suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity
|
||||
},
|
||||
"feature_id": tags.get('gnis:feature_id', None),
|
||||
"tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None),
|
||||
"contact": {
|
||||
"phone": tags.get('phone', None),
|
||||
"email": tags.get('contact:email', None),
|
||||
"website": tags.get('website', None),
|
||||
"facebook": tags.get('contact:facebook', None), # Social media links
|
||||
"twitter": tags.get('contact:twitter', None),
|
||||
},
|
||||
# "tags": tags, # Include all raw tags for future use
|
||||
}
|
||||
|
||||
# Filter out adventures with no name, latitude, or longitude
|
||||
if (adventure["name"] and
|
||||
adventure["latitude"] is not None and -90 <= adventure["latitude"] <= 90 and
|
||||
adventure["longitude"] is not None and -180 <= adventure["longitude"] <= 180) or all:
|
||||
adventures.append(adventure)
|
||||
|
||||
return adventures
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def query(self, request):
|
||||
"""
|
||||
Radius-based search for tourism-related locations around given coordinates.
|
||||
"""
|
||||
lat = request.query_params.get('lat')
|
||||
lon = request.query_params.get('lon')
|
||||
radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters
|
||||
|
||||
valid_categories = ['lodging', 'food', 'tourism']
|
||||
category = request.query_params.get('category', 'all')
|
||||
if category not in valid_categories:
|
||||
return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400)
|
||||
|
||||
if category == 'tourism':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"];
|
||||
node(around:{radius},{lat},{lon})["leisure"];
|
||||
node(around:{radius},{lat},{lon})["historic"];
|
||||
node(around:{radius},{lat},{lon})["sport"];
|
||||
node(around:{radius},{lat},{lon})["natural"];
|
||||
node(around:{radius},{lat},{lon})["attraction"];
|
||||
node(around:{radius},{lat},{lon})["museum"];
|
||||
node(around:{radius},{lat},{lon})["zoo"];
|
||||
node(around:{radius},{lat},{lon})["aquarium"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
if category == 'lodging':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"="hotel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="motel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="guest_house"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="hostel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="camp_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="caravan_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="chalet"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="alpine_hut"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="apartment"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
if category == 'food':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["amenity"="restaurant"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="cafe"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="fast_food"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="pub"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bar"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="food_court"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="ice_cream"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bakery"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="confectionery"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
|
||||
# Validate required parameters
|
||||
if not lat or not lon:
|
||||
return Response(
|
||||
{"error": "Latitude and longitude parameters are required."}, status=400
|
||||
)
|
||||
|
||||
data = self.make_overpass_query(query)
|
||||
adventures = self.parse_overpass_response(data, request)
|
||||
return Response(adventures)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='search')
|
||||
def search(self, request):
|
||||
"""
|
||||
Name-based search for nodes with the specified name.
|
||||
"""
|
||||
name = request.query_params.get('name')
|
||||
|
||||
# Validate required parameter
|
||||
if not name:
|
||||
return Response({"error": "Name parameter is required."}, status=400)
|
||||
|
||||
# Construct Overpass API query
|
||||
query = f'[out:json];node["name"~"{name}",i];out;'
|
||||
data = self.make_overpass_query(query)
|
||||
|
||||
adventures = self.parse_overpass_response(data, request)
|
||||
return Response(adventures)
|
258
backend/server/adventures/views/recommendations_view.py
Normal file
|
@ -0,0 +1,258 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.conf import settings
|
||||
import requests
|
||||
from geopy.distance import geodesic
|
||||
import time
|
||||
|
||||
|
||||
class RecommendationsViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
BASE_URL = "https://overpass-api.de/api/interpreter"
|
||||
HEADERS = {'User-Agent': 'AdventureLog Server'}
|
||||
|
||||
def parse_google_places(self, places, origin):
|
||||
adventures = []
|
||||
|
||||
for place in places:
|
||||
location = place.get('location', {})
|
||||
types = place.get('types', [])
|
||||
|
||||
# Updated for new API response structure
|
||||
formatted_address = place.get("formattedAddress") or place.get("shortFormattedAddress")
|
||||
display_name = place.get("displayName", {})
|
||||
name = display_name.get("text") if isinstance(display_name, dict) else display_name
|
||||
|
||||
lat = location.get('latitude')
|
||||
lon = location.get('longitude')
|
||||
|
||||
if not name or not lat or not lon:
|
||||
continue
|
||||
|
||||
distance_km = geodesic(origin, (lat, lon)).km
|
||||
|
||||
adventure = {
|
||||
"id": place.get('id'),
|
||||
"type": 'place',
|
||||
"name": name,
|
||||
"description": place.get('businessStatus', None),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"address": formatted_address,
|
||||
"tag": types[0] if types else None,
|
||||
"distance_km": round(distance_km, 2),
|
||||
}
|
||||
|
||||
adventures.append(adventure)
|
||||
|
||||
# Sort by distance ascending
|
||||
adventures.sort(key=lambda x: x["distance_km"])
|
||||
|
||||
return adventures
|
||||
|
||||
def parse_overpass_response(self, data, request):
|
||||
nodes = data.get('elements', [])
|
||||
adventures = []
|
||||
all = request.query_params.get('all', False)
|
||||
|
||||
origin = None
|
||||
try:
|
||||
origin = (
|
||||
float(request.query_params.get('lat')),
|
||||
float(request.query_params.get('lon'))
|
||||
)
|
||||
except(ValueError, TypeError):
|
||||
origin = None
|
||||
|
||||
for node in nodes:
|
||||
if node.get('type') not in ['node', 'way', 'relation']:
|
||||
continue
|
||||
|
||||
tags = node.get('tags', {})
|
||||
lat = node.get('lat')
|
||||
lon = node.get('lon')
|
||||
name = tags.get('name', tags.get('official_name', ''))
|
||||
|
||||
if not name or lat is None or lon is None:
|
||||
if not all:
|
||||
continue
|
||||
|
||||
# Flatten address
|
||||
address_parts = [tags.get(f'addr:{k}') for k in ['housenumber', 'street', 'suburb', 'city', 'state', 'postcode', 'country']]
|
||||
formatted_address = ", ".join(filter(None, address_parts)) or name
|
||||
|
||||
# Calculate distance if possible
|
||||
distance_km = None
|
||||
if origin:
|
||||
distance_km = round(geodesic(origin, (lat, lon)).km, 2)
|
||||
|
||||
# Unified format
|
||||
adventure = {
|
||||
"id": f"osm:{node.get('id')}",
|
||||
"type": "place",
|
||||
"name": name,
|
||||
"description": tags.get('description'),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"address": formatted_address,
|
||||
"tag": next((tags.get(key) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None),
|
||||
"distance_km": distance_km,
|
||||
"powered_by": "osm"
|
||||
}
|
||||
|
||||
adventures.append(adventure)
|
||||
|
||||
# Sort by distance if available
|
||||
if origin:
|
||||
adventures.sort(key=lambda x: x.get("distance_km") or float("inf"))
|
||||
|
||||
return adventures
|
||||
|
||||
|
||||
def query_overpass(self, lat, lon, radius, category, request):
|
||||
if category == 'tourism':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"];
|
||||
node(around:{radius},{lat},{lon})["leisure"];
|
||||
node(around:{radius},{lat},{lon})["historic"];
|
||||
node(around:{radius},{lat},{lon})["sport"];
|
||||
node(around:{radius},{lat},{lon})["natural"];
|
||||
node(around:{radius},{lat},{lon})["attraction"];
|
||||
node(around:{radius},{lat},{lon})["museum"];
|
||||
node(around:{radius},{lat},{lon})["zoo"];
|
||||
node(around:{radius},{lat},{lon})["aquarium"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
elif category == 'lodging':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["tourism"="hotel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="motel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="guest_house"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="hostel"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="camp_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="caravan_site"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="chalet"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="alpine_hut"];
|
||||
node(around:{radius},{lat},{lon})["tourism"="apartment"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
elif category == 'food':
|
||||
query = f"""
|
||||
[out:json];
|
||||
(
|
||||
node(around:{radius},{lat},{lon})["amenity"="restaurant"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="cafe"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="fast_food"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="pub"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bar"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="food_court"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="ice_cream"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="bakery"];
|
||||
node(around:{radius},{lat},{lon})["amenity"="confectionery"];
|
||||
);
|
||||
out;
|
||||
"""
|
||||
else:
|
||||
return Response({"error": "Invalid category."}, status=400)
|
||||
|
||||
overpass_url = f"{self.BASE_URL}?data={query}"
|
||||
try:
|
||||
response = requests.get(overpass_url, headers=self.HEADERS)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
print("Overpass API error:", e)
|
||||
return Response({"error": "Failed to retrieve data from Overpass API."}, status=500)
|
||||
|
||||
adventures = self.parse_overpass_response(data, request)
|
||||
return Response(adventures)
|
||||
|
||||
def query_google_nearby(self, lat, lon, radius, category, request):
|
||||
"""Query Google Places API (New) for nearby places"""
|
||||
api_key = settings.GOOGLE_MAPS_API_KEY
|
||||
|
||||
# Updated to use new Places API endpoint
|
||||
url = "https://places.googleapis.com/v1/places:searchNearby"
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': api_key,
|
||||
'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount,places.businessStatus,places.id'
|
||||
}
|
||||
|
||||
# Map categories to place types for the new API
|
||||
type_mapping = {
|
||||
'lodging': 'lodging',
|
||||
'food': 'restaurant',
|
||||
'tourism': 'tourist_attraction',
|
||||
}
|
||||
|
||||
payload = {
|
||||
"includedTypes": [type_mapping[category]],
|
||||
"maxResultCount": 20,
|
||||
"locationRestriction": {
|
||||
"circle": {
|
||||
"center": {
|
||||
"latitude": float(lat),
|
||||
"longitude": float(lon)
|
||||
},
|
||||
"radius": float(radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
places = data.get('places', [])
|
||||
origin = (float(lat), float(lon))
|
||||
adventures = self.parse_google_places(places, origin)
|
||||
|
||||
return Response(adventures)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Google Places API error: {e}")
|
||||
# Fallback to Overpass API
|
||||
return self.query_overpass(lat, lon, radius, category, request)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error with Google Places API: {e}")
|
||||
# Fallback to Overpass API
|
||||
return self.query_overpass(lat, lon, radius, category, request)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def query(self, request):
|
||||
lat = request.query_params.get('lat')
|
||||
lon = request.query_params.get('lon')
|
||||
radius = request.query_params.get('radius', '1000')
|
||||
category = request.query_params.get('category', 'all')
|
||||
|
||||
if not lat or not lon:
|
||||
return Response({"error": "Latitude and longitude parameters are required."}, status=400)
|
||||
|
||||
valid_categories = {
|
||||
'lodging': 'lodging',
|
||||
'food': 'restaurant',
|
||||
'tourism': 'tourist_attraction',
|
||||
}
|
||||
|
||||
if category not in valid_categories:
|
||||
return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400)
|
||||
|
||||
api_key = getattr(settings, 'GOOGLE_MAPS_API_KEY', None)
|
||||
|
||||
# Fallback to Overpass if no API key configured
|
||||
if not api_key:
|
||||
return self.query_overpass(lat, lon, radius, category, request)
|
||||
|
||||
# Use the new Google Places API
|
||||
return self.query_google_nearby(lat, lon, radius, category, request)
|
|
@ -6,77 +6,44 @@ from worldtravel.models import Region, City, VisitedRegion, VisitedCity
|
|||
from adventures.models import Adventure
|
||||
from adventures.serializers import AdventureSerializer
|
||||
import requests
|
||||
from adventures.geocoding import reverse_geocode
|
||||
from adventures.geocoding import extractIsoCode
|
||||
from django.conf import settings
|
||||
from adventures.geocoding import search_google, search_osm
|
||||
|
||||
class ReverseGeocodeViewSet(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def extractIsoCode(self, data):
|
||||
"""
|
||||
Extract the ISO code from the response data.
|
||||
Returns a dictionary containing the region name, country name, and ISO code if found.
|
||||
"""
|
||||
iso_code = None
|
||||
town_city_or_county = None
|
||||
display_name = None
|
||||
country_code = None
|
||||
city = None
|
||||
visited_city = None
|
||||
location_name = None
|
||||
|
||||
# town = None
|
||||
# city = None
|
||||
# county = None
|
||||
|
||||
if 'name' in data.keys():
|
||||
location_name = data['name']
|
||||
|
||||
if 'address' in data.keys():
|
||||
keys = data['address'].keys()
|
||||
for key in keys:
|
||||
if key.find("ISO") != -1:
|
||||
iso_code = data['address'][key]
|
||||
if 'town' in keys:
|
||||
town_city_or_county = data['address']['town']
|
||||
if 'county' in keys:
|
||||
town_city_or_county = data['address']['county']
|
||||
if 'city' in keys:
|
||||
town_city_or_county = data['address']['city']
|
||||
if not iso_code:
|
||||
return {"error": "No region found"}
|
||||
|
||||
region = Region.objects.filter(id=iso_code).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||
|
||||
region_visited = False
|
||||
city_visited = False
|
||||
country_code = iso_code[:2]
|
||||
|
||||
if region:
|
||||
if town_city_or_county:
|
||||
display_name = f"{town_city_or_county}, {region.name}, {country_code}"
|
||||
city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
|
||||
if visited_region:
|
||||
region_visited = True
|
||||
if visited_city:
|
||||
city_visited = True
|
||||
if region:
|
||||
return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, 'location_name': location_name}
|
||||
return {"error": "No region found"}
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def reverse_geocode(self, request):
|
||||
lat = request.query_params.get('lat', '')
|
||||
lon = request.query_params.get('lon', '')
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
response = requests.get(url, headers=headers)
|
||||
if not lat or not lon:
|
||||
return Response({"error": "Latitude and longitude are required"}, status=400)
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||
return Response(self.extractIsoCode(data))
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except ValueError:
|
||||
return Response({"error": "Invalid latitude or longitude"}, status=400)
|
||||
data = reverse_geocode(lat, lon, self.request.user)
|
||||
if 'error' in data:
|
||||
return Response({"error": "An internal error occurred while processing the request"}, status=400)
|
||||
return Response(data)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def search(self, request):
|
||||
query = request.query_params.get('query', '')
|
||||
if not query:
|
||||
return Response({"error": "Query parameter is required"}, status=400)
|
||||
|
||||
try:
|
||||
if getattr(settings, 'GOOGLE_MAPS_API_KEY', None):
|
||||
results = search_google(query)
|
||||
else:
|
||||
results = search_osm(query)
|
||||
return Response(results)
|
||||
except Exception:
|
||||
return Response({"error": "An internal error occurred while processing the request"}, status=500)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def mark_visited_region(self, request):
|
||||
|
@ -93,16 +60,15 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
|
|||
lon = adventure.longitude
|
||||
if not lat or not lon:
|
||||
continue
|
||||
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}"
|
||||
headers = {'User-Agent': 'AdventureLog Server'}
|
||||
response = requests.get(url, headers=headers)
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return Response({"error": "Invalid response from geocoding service"}, status=400)
|
||||
extracted_region = self.extractIsoCode(data)
|
||||
if 'error' not in extracted_region:
|
||||
region = Region.objects.filter(id=extracted_region['region_id']).first()
|
||||
|
||||
# Use the existing reverse_geocode function which handles both Google and OSM
|
||||
data = reverse_geocode(lat, lon, self.request.user)
|
||||
if 'error' in data:
|
||||
continue
|
||||
|
||||
# data already contains region_id and city_id
|
||||
if 'region_id' in data and data['region_id'] is not None:
|
||||
region = Region.objects.filter(id=data['region_id']).first()
|
||||
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
|
||||
if not visited_region:
|
||||
visited_region = VisitedRegion(region=region, user_id=self.request.user)
|
||||
|
@ -110,12 +76,12 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
|
|||
new_region_count += 1
|
||||
new_regions[region.id] = region.name
|
||||
|
||||
if extracted_region['city_id'] is not None:
|
||||
city = City.objects.filter(id=extracted_region['city_id']).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
if not visited_city:
|
||||
visited_city = VisitedCity(city=city, user_id=self.request.user)
|
||||
visited_city.save()
|
||||
new_city_count += 1
|
||||
new_cities[city.id] = city.name
|
||||
if 'city_id' in data and data['city_id'] is not None:
|
||||
city = City.objects.filter(id=data['city_id']).first()
|
||||
visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
|
||||
if not visited_city:
|
||||
visited_city = VisitedCity(city=city, user_id=self.request.user)
|
||||
visited_city.save()
|
||||
new_city_count += 1
|
||||
new_cities[city.id] = city.name
|
||||
return Response({"new_regions": new_region_count, "regions": new_regions, "new_cities": new_city_count, "cities": new_cities})
|
|
@ -14,7 +14,7 @@ class StatsViewSet(viewsets.ViewSet):
|
|||
"""
|
||||
A simple ViewSet for listing the stats of a user.
|
||||
"""
|
||||
@action(detail=False, methods=['get'], url_path='counts/(?P<username>[\w.@+-]+)')
|
||||
@action(detail=False, methods=['get'], url_path=r'counts/(?P<username>[\w.@+-]+)')
|
||||
def counts(self, request, username):
|
||||
if request.user.username == username:
|
||||
user = get_object_or_404(User, username=username)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-01 21:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='immichintegration',
|
||||
name='copy_locally',
|
||||
field=models.BooleanField(default=True, help_text='Copy image to local storage, instead of just linking to the remote URL.'),
|
||||
),
|
||||
]
|
|
@ -9,6 +9,7 @@ class ImmichIntegration(models.Model):
|
|||
api_key = models.CharField(max_length=255)
|
||||
user = models.ForeignKey(
|
||||
User, on_delete=models.CASCADE)
|
||||
copy_locally = models.BooleanField(default=True, help_text="Copy image to local storage, instead of just linking to the remote URL.")
|
||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import os
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import viewsets, status
|
||||
|
||||
from .serializers import ImmichIntegrationSerializer
|
||||
from .models import ImmichIntegration
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
import requests
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django.conf import settings
|
||||
from adventures.models import AdventureImage
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IntegrationView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
@ -16,15 +22,16 @@ class IntegrationView(viewsets.ViewSet):
|
|||
RESTful GET method for listing all integrations.
|
||||
"""
|
||||
immich_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
google_map_integration = settings.GOOGLE_MAPS_API_KEY != ''
|
||||
|
||||
return Response(
|
||||
{
|
||||
'immich': immich_integrations.exists()
|
||||
'immich': immich_integrations.exists(),
|
||||
'google_maps': google_map_integration
|
||||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 25
|
||||
page_size_query_param = 'page_size'
|
||||
|
@ -33,13 +40,24 @@ class StandardResultsSetPagination(PageNumberPagination):
|
|||
class ImmichIntegrationView(viewsets.ViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def check_integration(self, request):
|
||||
"""
|
||||
Checks if the user has an active Immich integration.
|
||||
Returns:
|
||||
- None if the integration exists.
|
||||
- The integration object if it exists.
|
||||
- A Response with an error message if the integration is missing.
|
||||
"""
|
||||
if not request.user.is_authenticated:
|
||||
return Response(
|
||||
{
|
||||
'message': 'You need to be authenticated to use this feature.',
|
||||
'error': True,
|
||||
'code': 'immich.authentication_required'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if not user_integrations.exists():
|
||||
return Response(
|
||||
|
@ -50,7 +68,8 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return ImmichIntegration.objects.first()
|
||||
|
||||
return user_integrations.first()
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='search')
|
||||
def search(self, request):
|
||||
|
@ -61,7 +80,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
|
||||
query = request.query_params.get('query', '')
|
||||
date = request.query_params.get('date', '')
|
||||
|
||||
|
@ -74,12 +93,30 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
arguments = {}
|
||||
if query:
|
||||
arguments['query'] = query
|
||||
if date:
|
||||
arguments['takenBefore'] = date
|
||||
# Create date range for the entire selected day
|
||||
from datetime import datetime, timedelta
|
||||
try:
|
||||
# Parse the date and create start/end of day
|
||||
selected_date = datetime.strptime(date, '%Y-%m-%d')
|
||||
start_of_day = selected_date.strftime('%Y-%m-%d')
|
||||
end_of_day = (selected_date + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
|
||||
arguments['takenAfter'] = start_of_day
|
||||
arguments['takenBefore'] = end_of_day
|
||||
except ValueError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Invalid date format. Use YYYY-MM-DD.',
|
||||
'error': True,
|
||||
'code': 'immich.invalid_date_format'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
|
@ -99,14 +136,14 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
if 'assets' in res and 'items' in res['assets']:
|
||||
paginator = self.pagination_class()
|
||||
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
|
||||
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
|
||||
public_url = public_url.replace("'", "")
|
||||
for item in res['assets']['items']:
|
||||
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}'
|
||||
item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
|
||||
result_page = paginator.paginate_queryset(res['assets']['items'], request)
|
||||
return paginator.get_paginated_response(result_page)
|
||||
else:
|
||||
|
@ -118,44 +155,6 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)')
|
||||
def get(self, request, imageid=None):
|
||||
"""
|
||||
RESTful GET method for retrieving a specific Immich image by ID.
|
||||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
if not imageid:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Image ID is required.',
|
||||
'error': True,
|
||||
'code': 'immich.imageid_required'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
|
||||
try:
|
||||
immich_fetch = requests.get(f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', headers={
|
||||
'x-api-key': integration.api_key
|
||||
})
|
||||
# should return the image file
|
||||
from django.http import HttpResponse
|
||||
return HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK)
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response(
|
||||
{
|
||||
'message': 'The Immich server is currently down or unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def albums(self, request):
|
||||
|
@ -187,7 +186,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
res,
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='albums/(?P<albumid>[^/.]+)')
|
||||
def album(self, request, albumid=None):
|
||||
"""
|
||||
|
@ -195,6 +194,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
"""
|
||||
# Check for integration before proceeding
|
||||
integration = self.check_integration(request)
|
||||
print(integration.user)
|
||||
if isinstance(integration, Response):
|
||||
return integration
|
||||
|
||||
|
@ -230,7 +230,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
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"]}'
|
||||
item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
|
||||
result_page = paginator.paginate_queryset(res['assets'], request)
|
||||
return paginator.get_paginated_response(result_page)
|
||||
else:
|
||||
|
@ -243,6 +243,117 @@ class ImmichIntegrationView(viewsets.ViewSet):
|
|||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
url_path='(?P<integration_id>[^/.]+)/get/(?P<imageid>[^/.]+)',
|
||||
permission_classes=[]
|
||||
)
|
||||
def get_by_integration(self, request, integration_id=None, imageid=None):
|
||||
"""
|
||||
GET an Immich image using the integration and asset ID.
|
||||
Access levels (in order of priority):
|
||||
1. Public adventures: accessible by anyone
|
||||
2. Private adventures in public collections: accessible by anyone
|
||||
3. Private adventures in private collections shared with user: accessible by shared users
|
||||
4. Private adventures: accessible only to the owner
|
||||
5. No AdventureImage: owner can still view via integration
|
||||
"""
|
||||
if not imageid or not integration_id:
|
||||
return Response({
|
||||
'message': 'Image ID and Integration ID are required.',
|
||||
'error': True,
|
||||
'code': 'immich.missing_params'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Lookup integration and user
|
||||
integration = get_object_or_404(ImmichIntegration, id=integration_id)
|
||||
owner_id = integration.user_id
|
||||
|
||||
# Try to find the image entry with collections and sharing information
|
||||
image_entry = (
|
||||
AdventureImage.objects
|
||||
.filter(immich_id=imageid, user_id=owner_id)
|
||||
.select_related('adventure')
|
||||
.prefetch_related('adventure__collections', 'adventure__collections__shared_with')
|
||||
.order_by('-adventure__is_public') # Public adventures first
|
||||
.first()
|
||||
)
|
||||
|
||||
# Access control
|
||||
if image_entry:
|
||||
adventure = image_entry.adventure
|
||||
collections = adventure.collections.all()
|
||||
|
||||
# Determine access level
|
||||
is_authorized = False
|
||||
|
||||
# Level 1: Public adventure (highest priority)
|
||||
if adventure.is_public:
|
||||
is_authorized = True
|
||||
|
||||
# Level 2: Private adventure in any public collection
|
||||
elif any(collection.is_public for collection in collections):
|
||||
is_authorized = True
|
||||
|
||||
# Level 3: Owner access
|
||||
elif request.user.is_authenticated and request.user.id == owner_id:
|
||||
is_authorized = True
|
||||
|
||||
# Level 4: Shared collection access - check if user has access to any collection
|
||||
elif (request.user.is_authenticated and
|
||||
any(collection.shared_with.filter(id=request.user.id).exists()
|
||||
for collection in collections)):
|
||||
is_authorized = True
|
||||
|
||||
if not is_authorized:
|
||||
return Response({
|
||||
'message': 'This image belongs to a private adventure and you are not authorized.',
|
||||
'error': True,
|
||||
'code': 'immich.permission_denied'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
# No AdventureImage exists; allow only the integration owner
|
||||
if not request.user.is_authenticated or request.user.id != owner_id:
|
||||
return Response({
|
||||
'message': 'Image is not linked to any adventure and you are not the owner.',
|
||||
'error': True,
|
||||
'code': 'immich.not_found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Fetch from Immich
|
||||
try:
|
||||
immich_response = requests.get(
|
||||
f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview',
|
||||
headers={'x-api-key': integration.api_key},
|
||||
timeout=5
|
||||
)
|
||||
content_type = immich_response.headers.get('Content-Type', 'image/jpeg')
|
||||
if not content_type.startswith('image/'):
|
||||
return Response({
|
||||
'message': 'Invalid content type returned from Immich.',
|
||||
'error': True,
|
||||
'code': 'immich.invalid_content'
|
||||
}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
response = HttpResponse(immich_response.content, content_type=content_type, status=200)
|
||||
response['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600'
|
||||
return response
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response({
|
||||
'message': 'The Immich server is unreachable.',
|
||||
'error': True,
|
||||
'code': 'immich.server_down'
|
||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return Response({
|
||||
'message': 'The Immich server request timed out.',
|
||||
'error': True,
|
||||
'code': 'immich.timeout'
|
||||
}, status=status.HTTP_504_GATEWAY_TIMEOUT)
|
||||
|
||||
class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = ImmichIntegrationSerializer
|
||||
|
@ -251,11 +362,78 @@ class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
|||
def get_queryset(self):
|
||||
return ImmichIntegration.objects.filter(user=self.request.user)
|
||||
|
||||
def _validate_immich_connection(self, server_url, api_key):
|
||||
"""
|
||||
Validate connection to Immich server before saving integration.
|
||||
Returns tuple: (is_valid, corrected_server_url, error_message)
|
||||
"""
|
||||
if not server_url or not api_key:
|
||||
return False, server_url, "Server URL and API key are required"
|
||||
|
||||
# Ensure server_url has proper format
|
||||
if not server_url.startswith(('http://', 'https://')):
|
||||
server_url = f"https://{server_url}"
|
||||
|
||||
# Remove trailing slash if present
|
||||
original_server_url = server_url.rstrip('/')
|
||||
|
||||
# Try both with and without /api prefix
|
||||
test_configs = [
|
||||
(original_server_url, f"{original_server_url}/users/me"),
|
||||
(f"{original_server_url}/api", f"{original_server_url}/api/users/me")
|
||||
]
|
||||
|
||||
headers = {
|
||||
'X-API-Key': api_key,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
for corrected_url, test_endpoint in test_configs:
|
||||
try:
|
||||
response = requests.get(
|
||||
test_endpoint,
|
||||
headers=headers,
|
||||
timeout=10, # 10 second timeout
|
||||
verify=True # SSL verification
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
json_response = response.json()
|
||||
# Validate expected Immich user response structure
|
||||
required_fields = ['id', 'email', 'name', 'isAdmin', 'createdAt']
|
||||
if all(field in json_response for field in required_fields):
|
||||
return True, corrected_url, None
|
||||
else:
|
||||
continue # Try next endpoint
|
||||
except (ValueError, KeyError):
|
||||
continue # Try next endpoint
|
||||
elif response.status_code == 401:
|
||||
return False, original_server_url, "Invalid API key or unauthorized access"
|
||||
elif response.status_code == 403:
|
||||
return False, original_server_url, "Access forbidden - check API key permissions"
|
||||
# Continue to next endpoint for 404 errors
|
||||
|
||||
except requests.exceptions.ConnectTimeout:
|
||||
return False, original_server_url, "Connection timeout - server may be unreachable"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, original_server_url, "Cannot connect to server - check URL and network connectivity"
|
||||
except requests.exceptions.SSLError:
|
||||
return False, original_server_url, "SSL certificate error - check server certificate"
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"RequestException during Immich connection validation: {str(e)}")
|
||||
return False, original_server_url, "Connection failed due to a network error."
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Immich connection validation: {str(e)}")
|
||||
return False, original_server_url, "An unexpected error occurred while validating the connection."
|
||||
|
||||
# If we get here, none of the endpoints worked
|
||||
return False, original_server_url, "Immich server endpoint not found - check server URL"
|
||||
|
||||
def create(self, request):
|
||||
"""
|
||||
RESTful POST method for creating a new Immich integration.
|
||||
"""
|
||||
|
||||
# Check if the user already has an integration
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if user_integrations.exists():
|
||||
|
@ -270,11 +448,76 @@ class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
|||
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user)
|
||||
# Validate Immich server connection before saving
|
||||
server_url = serializer.validated_data.get('server_url')
|
||||
api_key = serializer.validated_data.get('api_key')
|
||||
|
||||
is_valid, corrected_server_url, error_message = self._validate_immich_connection(server_url, api_key)
|
||||
|
||||
if not is_valid:
|
||||
return Response(
|
||||
{
|
||||
'message': f'Cannot connect to Immich server: {error_message}',
|
||||
'error': True,
|
||||
'code': 'immich.connection_failed',
|
||||
'details': error_message
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# If validation passes, save the integration with the corrected URL
|
||||
serializer.save(user=request.user, server_url=corrected_server_url)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def update(self, request, pk=None):
|
||||
"""
|
||||
RESTful PUT method for updating an existing Immich integration.
|
||||
"""
|
||||
integration = ImmichIntegration.objects.filter(user=request.user, id=pk).first()
|
||||
if not integration:
|
||||
return Response(
|
||||
{
|
||||
'message': 'Integration not found.',
|
||||
'error': True,
|
||||
'code': 'immich.integration_not_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = self.serializer_class(integration, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
# Validate Immich server connection before updating
|
||||
server_url = serializer.validated_data.get('server_url', integration.server_url)
|
||||
api_key = serializer.validated_data.get('api_key', integration.api_key)
|
||||
|
||||
is_valid, corrected_server_url, error_message = self._validate_immich_connection(server_url, api_key)
|
||||
|
||||
if not is_valid:
|
||||
return Response(
|
||||
{
|
||||
'message': f'Cannot connect to Immich server: {error_message}',
|
||||
'error': True,
|
||||
'code': 'immich.connection_failed',
|
||||
'details': error_message
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# If validation passes, save the integration with the corrected URL
|
||||
serializer.save(server_url=corrected_server_url)
|
||||
return Response(
|
||||
serializer.data,
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
|
@ -301,10 +544,9 @@ class ImmichIntegrationViewSet(viewsets.ModelViewSet):
|
|||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
# If the user has an integration, we only want to return that integration
|
||||
|
||||
user_integrations = ImmichIntegration.objects.filter(user=request.user)
|
||||
if user_integrations.exists():
|
||||
integration = user_integrations.first()
|
||||
|
|
|
@ -27,7 +27,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
|||
SECRET_KEY = getenv('SECRET_KEY')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = getenv('DEBUG', 'True') == 'True'
|
||||
DEBUG = getenv('DEBUG', 'true').lower() == 'true'
|
||||
|
||||
# ALLOWED_HOSTS = [
|
||||
# 'localhost',
|
||||
|
@ -71,6 +71,7 @@ MIDDLEWARE = (
|
|||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'adventures.middleware.XSessionTokenMiddleware',
|
||||
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
|
||||
'adventures.middleware.DisableCSRFForMobileLoginSignup',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
|
@ -101,22 +102,30 @@ ROOT_URLCONF = 'main.urls'
|
|||
# Database
|
||||
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
|
||||
|
||||
# Using legacy PG environment variables for compatibility with existing setups
|
||||
|
||||
def env(*keys, default=None):
|
||||
"""Return the first non-empty environment variable from a list of keys."""
|
||||
for key in keys:
|
||||
value = os.getenv(key)
|
||||
if value:
|
||||
return value
|
||||
return default
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.contrib.gis.db.backends.postgis',
|
||||
'NAME': getenv('PGDATABASE'),
|
||||
'USER': getenv('PGUSER'),
|
||||
'PASSWORD': getenv('PGPASSWORD'),
|
||||
'HOST': getenv('PGHOST'),
|
||||
'PORT': getenv('PGPORT', 5432),
|
||||
'NAME': env('PGDATABASE', 'POSTGRES_DB'),
|
||||
'USER': env('PGUSER', 'POSTGRES_USER'),
|
||||
'PASSWORD': env('PGPASSWORD', 'POSTGRES_PASSWORD'),
|
||||
'HOST': env('PGHOST', default='localhost'),
|
||||
'PORT': int(env('PGPORT', default='5432')),
|
||||
'OPTIONS': {
|
||||
'sslmode': 'prefer', # Prefer SSL, but allow non-SSL connections
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.7/topics/i18n/
|
||||
|
||||
|
@ -138,6 +147,8 @@ SESSION_COOKIE_SAMESITE = 'Lax'
|
|||
SESSION_COOKIE_NAME = 'sessionid'
|
||||
|
||||
SESSION_COOKIE_SECURE = FRONTEND_URL.startswith('https')
|
||||
CSRF_COOKIE_SECURE = FRONTEND_URL.startswith('https')
|
||||
|
||||
|
||||
hostname = urlparse(FRONTEND_URL).hostname
|
||||
is_ip_address = hostname.replace('.', '').isdigit()
|
||||
|
@ -201,7 +212,7 @@ TEMPLATES = [
|
|||
|
||||
# Authentication settings
|
||||
|
||||
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'False') == 'True'
|
||||
DISABLE_REGISTRATION = getenv('DISABLE_REGISTRATION', 'false').lower() == 'true'
|
||||
DISABLE_REGISTRATION_MESSAGE = getenv('DISABLE_REGISTRATION_MESSAGE', 'Registration is disabled. Please contact the administrator if you need an account.')
|
||||
|
||||
AUTH_USER_MODEL = 'users.CustomUser'
|
||||
|
@ -229,6 +240,8 @@ HEADLESS_FRONTEND_URLS = {
|
|||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'users.backends.NoPasswordAuthBackend',
|
||||
# 'allauth.account.auth_backends.AuthenticationBackend',
|
||||
# 'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
@ -242,9 +255,9 @@ if getenv('EMAIL_BACKEND', 'console') == 'console':
|
|||
else:
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = getenv('EMAIL_HOST')
|
||||
EMAIL_USE_TLS = getenv('EMAIL_USE_TLS', 'True') == 'True'
|
||||
EMAIL_USE_TLS = getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
|
||||
EMAIL_PORT = getenv('EMAIL_PORT', 587)
|
||||
EMAIL_USE_SSL = getenv('EMAIL_USE_SSL', 'False') == 'True'
|
||||
EMAIL_USE_SSL = getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||
EMAIL_HOST_USER = getenv('EMAIL_HOST_USER')
|
||||
EMAIL_HOST_PASSWORD = getenv('EMAIL_HOST_PASSWORD')
|
||||
DEFAULT_FROM_EMAIL = getenv('DEFAULT_FROM_EMAIL')
|
||||
|
@ -312,4 +325,6 @@ LOGGING = {
|
|||
# ADVENTURELOG_CDN_URL = getenv('ADVENTURELOG_CDN_URL', 'https://cdn.adventurelog.app')
|
||||
|
||||
# https://github.com/dr5hn/countries-states-cities-database/tags
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.5'
|
||||
COUNTRY_REGION_JSON_VERSION = 'v2.6'
|
||||
|
||||
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
|
|
@ -1,16 +1,16 @@
|
|||
Django==5.0.11
|
||||
Django==5.2.1
|
||||
djangorestframework>=3.15.2
|
||||
django-allauth==0.63.3
|
||||
drf-yasg==1.21.4
|
||||
django-cors-headers==4.4.0
|
||||
coreapi==2.3.3
|
||||
python-dotenv
|
||||
psycopg2-binary
|
||||
Pillow
|
||||
whitenoise
|
||||
django-resized
|
||||
django-geojson
|
||||
setuptools
|
||||
python-dotenv==1.1.0
|
||||
psycopg2-binary==2.9.10
|
||||
pillow==11.2.1
|
||||
whitenoise==6.9.0
|
||||
django-resized==1.0.3
|
||||
django-geojson==4.2.0
|
||||
setuptools==79.0.1
|
||||
gunicorn==23.0.0
|
||||
qrcode==8.0
|
||||
slippers==0.6.2
|
||||
|
@ -21,4 +21,6 @@ icalendar==6.1.0
|
|||
ijson==3.3.0
|
||||
tqdm==4.67.1
|
||||
overpy==0.7
|
||||
publicsuffix2==2.20191221
|
||||
publicsuffix2==2.20191221
|
||||
geopy==2.4.1
|
||||
psutil==6.1.1
|
|
@ -1,16 +1,44 @@
|
|||
from django.contrib.auth.backends import ModelBackend
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
from allauth.account.auth_backends import AuthenticationBackend as AllauthBackend
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class NoPasswordAuthBackend(ModelBackend):
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
print("NoPasswordAuthBackend")
|
||||
# First, attempt normal authentication
|
||||
user = super().authenticate(request, username=username, password=password, **kwargs)
|
||||
if user is None:
|
||||
# Handle allauth-specific authentication (like email login)
|
||||
allauth_backend = AllauthBackend()
|
||||
allauth_user = allauth_backend.authenticate(request, username=username, password=password, **kwargs)
|
||||
|
||||
# If allauth handled it, check our password disable logic
|
||||
if allauth_user:
|
||||
has_social_accounts = SocialAccount.objects.filter(user=allauth_user).exists()
|
||||
if has_social_accounts and getattr(allauth_user, 'disable_password', False):
|
||||
return None
|
||||
if self.user_can_authenticate(allauth_user):
|
||||
return allauth_user
|
||||
return None
|
||||
|
||||
if SocialAccount.objects.filter(user=user).exists() and user.disable_password:
|
||||
# If yes, disable login via password
|
||||
# Fallback to regular username/password authentication
|
||||
if username is None or password is None:
|
||||
return None
|
||||
|
||||
return user
|
||||
try:
|
||||
# Get the user first
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
|
||||
# Check if this user has social accounts and password is disabled
|
||||
has_social_accounts = SocialAccount.objects.filter(user=user).exists()
|
||||
|
||||
# If user has social accounts and disable_password is True, deny password login
|
||||
if has_social_accounts and getattr(user, 'disable_password', False):
|
||||
return None
|
||||
|
||||
# Otherwise, proceed with normal password authentication
|
||||
if user.check_password(password) and self.user_can_authenticate(user):
|
||||
return user
|
||||
|
||||
return None
|
|
@ -165,7 +165,7 @@ class EnabledSocialProvidersView(APIView):
|
|||
providers = []
|
||||
for provider in social_providers:
|
||||
if provider.provider == 'openid_connect':
|
||||
new_provider = f'oidc/{provider.client_id}'
|
||||
new_provider = f'oidc/{provider.provider_id}'
|
||||
else:
|
||||
new_provider = provider.provider
|
||||
providers.append({
|
||||
|
@ -204,4 +204,4 @@ class DisablePasswordAuthenticationView(APIView):
|
|||
user.disable_password = False
|
||||
user.save()
|
||||
return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from adventures.models import Adventure
|
||||
import time
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Bulk geocode all adventures by triggering save on each one'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
adventures = Adventure.objects.all()
|
||||
total = adventures.count()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Starting bulk geocoding of {total} adventures'))
|
||||
|
||||
for i, adventure in enumerate(adventures):
|
||||
try:
|
||||
self.stdout.write(f'Processing adventure {i+1}/{total}: {adventure}')
|
||||
adventure.save() # This should trigger any geocoding in the save method
|
||||
self.stdout.write(self.style.SUCCESS(f'Successfully processed adventure {i+1}/{total}'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Error processing adventure {i+1}/{total}: {adventure} - {e}'))
|
||||
|
||||
# Sleep for 2 seconds between each save
|
||||
if i < total - 1: # Don't sleep after the last one
|
||||
time.sleep(2)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Finished processing all adventures'))
|
|
@ -3,8 +3,11 @@ from django.core.management.base import BaseCommand
|
|||
import requests
|
||||
from worldtravel.models import Country, Region, City
|
||||
from django.db import transaction
|
||||
from tqdm import tqdm
|
||||
import ijson
|
||||
import gc
|
||||
import tempfile
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
@ -36,55 +39,112 @@ def saveCountryFlag(country_code):
|
|||
print(f'Error downloading flag for {country_code}')
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Imports the world travel data'
|
||||
help = 'Imports the world travel data with minimal memory usage'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
|
||||
parser.add_argument('--batch-size', type=int, default=500, help='Batch size for database operations')
|
||||
|
||||
@contextmanager
|
||||
def _temp_db(self):
|
||||
"""Create a temporary SQLite database for intermediate storage"""
|
||||
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
|
||||
temp_db_path = f.name
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.execute('''CREATE TABLE temp_countries (
|
||||
country_code TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
subregion TEXT,
|
||||
capital TEXT,
|
||||
longitude REAL,
|
||||
latitude REAL
|
||||
)''')
|
||||
|
||||
conn.execute('''CREATE TABLE temp_regions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
country_code TEXT,
|
||||
longitude REAL,
|
||||
latitude REAL
|
||||
)''')
|
||||
|
||||
conn.execute('''CREATE TABLE temp_cities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
region_id TEXT,
|
||||
longitude REAL,
|
||||
latitude REAL
|
||||
)''')
|
||||
|
||||
conn.commit()
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
os.unlink(temp_db_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def handle(self, **options):
|
||||
force = options['force']
|
||||
batch_size = 100
|
||||
batch_size = options['batch_size']
|
||||
countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
|
||||
|
||||
# Download or validate JSON file
|
||||
if not os.path.exists(countries_json_path) or force:
|
||||
self.stdout.write('Downloading JSON file...')
|
||||
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'))
|
||||
self.stdout.write(self.style.SUCCESS('JSON file downloaded successfully'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json'))
|
||||
self.stdout.write(self.style.ERROR('Error downloading JSON file'))
|
||||
return
|
||||
elif not os.path.isfile(countries_json_path):
|
||||
self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file'))
|
||||
self.stdout.write(self.style.ERROR('JSON file is not a file'))
|
||||
return
|
||||
elif os.path.getsize(countries_json_path) == 0:
|
||||
self.stdout.write(self.style.ERROR('countries+regions+states.json is empty'))
|
||||
elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
|
||||
self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.'))
|
||||
self.stdout.write(self.style.ERROR('JSON file is empty'))
|
||||
return
|
||||
elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
|
||||
self.stdout.write(self.style.WARNING('Some data is missing. Re-importing all data.'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS('Latest data already imported.'))
|
||||
return
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Starting ultra-memory-efficient import process...'))
|
||||
|
||||
# Use temporary SQLite database for intermediate storage
|
||||
with self._temp_db() as temp_conn:
|
||||
self.stdout.write('Step 1: Parsing JSON and storing in temporary database...')
|
||||
self._parse_and_store_temp(countries_json_path, temp_conn)
|
||||
|
||||
with open(countries_json_path, 'r') as f:
|
||||
f = open(countries_json_path, 'rb')
|
||||
self.stdout.write('Step 2: Processing countries...')
|
||||
self._process_countries_from_temp(temp_conn, batch_size)
|
||||
|
||||
self.stdout.write('Step 3: Processing regions...')
|
||||
self._process_regions_from_temp(temp_conn, batch_size)
|
||||
|
||||
self.stdout.write('Step 4: Processing cities...')
|
||||
self._process_cities_from_temp(temp_conn, batch_size)
|
||||
|
||||
self.stdout.write('Step 5: Cleaning up obsolete records...')
|
||||
self._cleanup_obsolete_records(temp_conn)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('All data imported successfully with minimal memory usage'))
|
||||
|
||||
def _parse_and_store_temp(self, json_path, temp_conn):
|
||||
"""Parse JSON once and store in temporary SQLite database"""
|
||||
country_count = 0
|
||||
region_count = 0
|
||||
city_count = 0
|
||||
|
||||
with open(json_path, 'rb') as f:
|
||||
parser = ijson.items(f, 'item')
|
||||
|
||||
with transaction.atomic():
|
||||
existing_countries = {country.country_code: country for country in Country.objects.all()}
|
||||
existing_regions = {region.id: region for region in Region.objects.all()}
|
||||
existing_cities = {city.id: city for city in City.objects.all()}
|
||||
|
||||
countries_to_create = []
|
||||
regions_to_create = []
|
||||
countries_to_update = []
|
||||
regions_to_update = []
|
||||
cities_to_create = []
|
||||
cities_to_update = []
|
||||
|
||||
processed_country_codes = set()
|
||||
processed_region_ids = set()
|
||||
processed_city_ids = set()
|
||||
|
||||
|
||||
for country in parser:
|
||||
country_code = country['iso2']
|
||||
country_name = country['name']
|
||||
|
@ -93,137 +153,365 @@ class Command(BaseCommand):
|
|||
longitude = round(float(country['longitude']), 6) if country['longitude'] else None
|
||||
latitude = round(float(country['latitude']), 6) if country['latitude'] else None
|
||||
|
||||
processed_country_codes.add(country_code)
|
||||
# Store country
|
||||
temp_conn.execute('''INSERT OR REPLACE INTO temp_countries
|
||||
(country_code, name, subregion, capital, longitude, latitude)
|
||||
VALUES (?, ?, ?, ?, ?, ?)''',
|
||||
(country_code, country_name, country_subregion, country_capital, longitude, latitude))
|
||||
|
||||
country_count += 1
|
||||
|
||||
# Download flag (do this during parsing to avoid extra pass)
|
||||
saveCountryFlag(country_code)
|
||||
|
||||
# Process regions/states
|
||||
if country['states']:
|
||||
for state in country['states']:
|
||||
state_id = f"{country_code}-{state['state_code']}"
|
||||
state_name = state['name']
|
||||
state_lat = round(float(state['latitude']), 6) if state['latitude'] else None
|
||||
state_lng = round(float(state['longitude']), 6) if state['longitude'] else None
|
||||
|
||||
temp_conn.execute('''INSERT OR REPLACE INTO temp_regions
|
||||
(id, name, country_code, longitude, latitude)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(state_id, state_name, country_code, state_lng, state_lat))
|
||||
|
||||
region_count += 1
|
||||
|
||||
# Process cities
|
||||
if 'cities' in state and state['cities']:
|
||||
for city in state['cities']:
|
||||
city_id = f"{state_id}-{city['id']}"
|
||||
city_name = city['name']
|
||||
city_lat = round(float(city['latitude']), 6) if city['latitude'] else None
|
||||
city_lng = round(float(city['longitude']), 6) if city['longitude'] else None
|
||||
|
||||
temp_conn.execute('''INSERT OR REPLACE INTO temp_cities
|
||||
(id, name, region_id, longitude, latitude)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(city_id, city_name, state_id, city_lng, city_lat))
|
||||
|
||||
city_count += 1
|
||||
else:
|
||||
# Country without states - create default region
|
||||
state_id = f"{country_code}-00"
|
||||
temp_conn.execute('''INSERT OR REPLACE INTO temp_regions
|
||||
(id, name, country_code, longitude, latitude)
|
||||
VALUES (?, ?, ?, ?, ?)''',
|
||||
(state_id, country_name, country_code, None, None))
|
||||
region_count += 1
|
||||
|
||||
# Commit periodically to avoid memory buildup
|
||||
if country_count % 100 == 0:
|
||||
temp_conn.commit()
|
||||
self.stdout.write(f' Parsed {country_count} countries, {region_count} regions, {city_count} cities...')
|
||||
|
||||
temp_conn.commit()
|
||||
self.stdout.write(f'✓ Parsing complete: {country_count} countries, {region_count} regions, {city_count} cities')
|
||||
|
||||
def _process_countries_from_temp(self, temp_conn, batch_size):
|
||||
"""Process countries from temporary database"""
|
||||
cursor = temp_conn.execute('SELECT country_code, name, subregion, capital, longitude, latitude FROM temp_countries')
|
||||
|
||||
countries_to_create = []
|
||||
countries_to_update = []
|
||||
processed = 0
|
||||
|
||||
while True:
|
||||
rows = cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
# Batch check for existing countries
|
||||
country_codes_in_batch = [row[0] for row in rows]
|
||||
existing_countries = {
|
||||
c.country_code: c for c in
|
||||
Country.objects.filter(country_code__in=country_codes_in_batch)
|
||||
.only('country_code', 'name', 'subregion', 'capital', 'longitude', 'latitude')
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
country_code, name, subregion, capital, longitude, latitude = row
|
||||
|
||||
if country_code in existing_countries:
|
||||
# Update existing
|
||||
country_obj = existing_countries[country_code]
|
||||
country_obj.name = country_name
|
||||
country_obj.subregion = country_subregion
|
||||
country_obj.capital = country_capital
|
||||
country_obj.name = name
|
||||
country_obj.subregion = subregion
|
||||
country_obj.capital = capital
|
||||
country_obj.longitude = longitude
|
||||
country_obj.latitude = latitude
|
||||
countries_to_update.append(country_obj)
|
||||
else:
|
||||
country_obj = Country(
|
||||
name=country_name,
|
||||
countries_to_create.append(Country(
|
||||
country_code=country_code,
|
||||
subregion=country_subregion,
|
||||
capital=country_capital,
|
||||
name=name,
|
||||
subregion=subregion,
|
||||
capital=capital,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
))
|
||||
|
||||
processed += 1
|
||||
|
||||
# Flush batches
|
||||
if countries_to_create:
|
||||
with transaction.atomic():
|
||||
Country.objects.bulk_create(countries_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
countries_to_create.clear()
|
||||
|
||||
if countries_to_update:
|
||||
with transaction.atomic():
|
||||
Country.objects.bulk_update(
|
||||
countries_to_update,
|
||||
['name', 'subregion', 'capital', 'longitude', 'latitude'],
|
||||
batch_size=batch_size
|
||||
)
|
||||
countries_to_create.append(country_obj)
|
||||
|
||||
saveCountryFlag(country_code)
|
||||
|
||||
if country['states']:
|
||||
for state in country['states']:
|
||||
name = state['name']
|
||||
state_id = f"{country_code}-{state['state_code']}"
|
||||
latitude = round(float(state['latitude']), 6) if state['latitude'] else None
|
||||
longitude = round(float(state['longitude']), 6) if state['longitude'] else None
|
||||
|
||||
# Check for duplicate regions
|
||||
if state_id in processed_region_ids:
|
||||
# self.stdout.write(self.style.ERROR(f'State {state_id} already processed'))
|
||||
continue
|
||||
|
||||
processed_region_ids.add(state_id)
|
||||
|
||||
if state_id in existing_regions:
|
||||
region_obj = existing_regions[state_id]
|
||||
region_obj.name = name
|
||||
region_obj.country = country_obj
|
||||
region_obj.longitude = longitude
|
||||
region_obj.latitude = latitude
|
||||
regions_to_update.append(region_obj)
|
||||
else:
|
||||
region_obj = Region(
|
||||
id=state_id,
|
||||
name=name,
|
||||
country=country_obj,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
)
|
||||
regions_to_create.append(region_obj)
|
||||
# self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
|
||||
|
||||
if 'cities' in state and len(state['cities']) > 0:
|
||||
for city in state['cities']:
|
||||
city_id = f"{state_id}-{city['id']}"
|
||||
city_name = city['name']
|
||||
latitude = round(float(city['latitude']), 6) if city['latitude'] else None
|
||||
longitude = round(float(city['longitude']), 6) if city['longitude'] else None
|
||||
|
||||
# Check for duplicate cities
|
||||
if city_id in processed_city_ids:
|
||||
# self.stdout.write(self.style.ERROR(f'City {city_id} already processed'))
|
||||
continue
|
||||
|
||||
processed_city_ids.add(city_id)
|
||||
|
||||
if city_id in existing_cities:
|
||||
city_obj = existing_cities[city_id]
|
||||
city_obj.name = city_name
|
||||
city_obj.region = region_obj
|
||||
city_obj.longitude = longitude
|
||||
city_obj.latitude = latitude
|
||||
cities_to_update.append(city_obj)
|
||||
else:
|
||||
city_obj = City(
|
||||
id=city_id,
|
||||
name=city_name,
|
||||
region=region_obj,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
)
|
||||
cities_to_create.append(city_obj)
|
||||
# self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared'))
|
||||
countries_to_update.clear()
|
||||
|
||||
if processed % 1000 == 0:
|
||||
self.stdout.write(f' Processed {processed} countries...')
|
||||
gc.collect()
|
||||
|
||||
# Final flush
|
||||
if countries_to_create:
|
||||
with transaction.atomic():
|
||||
Country.objects.bulk_create(countries_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
if countries_to_update:
|
||||
with transaction.atomic():
|
||||
Country.objects.bulk_update(
|
||||
countries_to_update,
|
||||
['name', 'subregion', 'capital', 'longitude', 'latitude'],
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
self.stdout.write(f'✓ Countries complete: {processed} processed')
|
||||
|
||||
def _process_regions_from_temp(self, temp_conn, batch_size):
|
||||
"""Process regions from temporary database"""
|
||||
# Get country mapping once
|
||||
country_map = {c.country_code: c for c in Country.objects.only('id', 'country_code')}
|
||||
|
||||
cursor = temp_conn.execute('SELECT id, name, country_code, longitude, latitude FROM temp_regions')
|
||||
|
||||
regions_to_create = []
|
||||
regions_to_update = []
|
||||
processed = 0
|
||||
|
||||
while True:
|
||||
rows = cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
# Batch check for existing regions
|
||||
region_ids_in_batch = [row[0] for row in rows]
|
||||
existing_regions = {
|
||||
r.id: r for r in
|
||||
Region.objects.filter(id__in=region_ids_in_batch)
|
||||
.select_related('country')
|
||||
.only('id', 'name', 'country', 'longitude', 'latitude')
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
region_id, name, country_code, longitude, latitude = row
|
||||
country_obj = country_map.get(country_code)
|
||||
|
||||
if not country_obj:
|
||||
continue
|
||||
|
||||
if region_id in existing_regions:
|
||||
# Update existing
|
||||
region_obj = existing_regions[region_id]
|
||||
region_obj.name = name
|
||||
region_obj.country = country_obj
|
||||
region_obj.longitude = longitude
|
||||
region_obj.latitude = latitude
|
||||
regions_to_update.append(region_obj)
|
||||
else:
|
||||
state_id = f"{country_code}-00"
|
||||
processed_region_ids.add(state_id)
|
||||
if state_id in existing_regions:
|
||||
region_obj = existing_regions[state_id]
|
||||
region_obj.name = country_name
|
||||
region_obj.country = country_obj
|
||||
regions_to_update.append(region_obj)
|
||||
else:
|
||||
region_obj = Region(
|
||||
id=state_id,
|
||||
name=country_name,
|
||||
country=country_obj
|
||||
)
|
||||
regions_to_create.append(region_obj)
|
||||
# self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
|
||||
for i in tqdm(range(0, len(countries_to_create), batch_size), desc="Processing countries"):
|
||||
batch = countries_to_create[i:i + batch_size]
|
||||
Country.objects.bulk_create(batch)
|
||||
regions_to_create.append(Region(
|
||||
id=region_id,
|
||||
name=name,
|
||||
country=country_obj,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
))
|
||||
|
||||
processed += 1
|
||||
|
||||
# Flush batches
|
||||
if regions_to_create:
|
||||
with transaction.atomic():
|
||||
Region.objects.bulk_create(regions_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
regions_to_create.clear()
|
||||
|
||||
if regions_to_update:
|
||||
with transaction.atomic():
|
||||
Region.objects.bulk_update(
|
||||
regions_to_update,
|
||||
['name', 'country', 'longitude', 'latitude'],
|
||||
batch_size=batch_size
|
||||
)
|
||||
regions_to_update.clear()
|
||||
|
||||
if processed % 2000 == 0:
|
||||
self.stdout.write(f' Processed {processed} regions...')
|
||||
gc.collect()
|
||||
|
||||
# Final flush
|
||||
if regions_to_create:
|
||||
with transaction.atomic():
|
||||
Region.objects.bulk_create(regions_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
if regions_to_update:
|
||||
with transaction.atomic():
|
||||
Region.objects.bulk_update(
|
||||
regions_to_update,
|
||||
['name', 'country', 'longitude', 'latitude'],
|
||||
batch_size=batch_size
|
||||
)
|
||||
|
||||
self.stdout.write(f'✓ Regions complete: {processed} processed')
|
||||
|
||||
for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"):
|
||||
batch = regions_to_create[i:i + batch_size]
|
||||
Region.objects.bulk_create(batch)
|
||||
def _process_cities_from_temp(self, temp_conn, batch_size):
|
||||
"""Process cities from temporary database with optimized existence checking"""
|
||||
# Get region mapping once
|
||||
region_map = {r.id: r for r in Region.objects.only('id')}
|
||||
|
||||
cursor = temp_conn.execute('SELECT id, name, region_id, longitude, latitude FROM temp_cities')
|
||||
|
||||
cities_to_create = []
|
||||
cities_to_update = []
|
||||
processed = 0
|
||||
|
||||
while True:
|
||||
rows = cursor.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
# Fast existence check - only get IDs, no objects
|
||||
city_ids_in_batch = [row[0] for row in rows]
|
||||
existing_city_ids = set(
|
||||
City.objects.filter(id__in=city_ids_in_batch)
|
||||
.values_list('id', flat=True)
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
city_id, name, region_id, longitude, latitude = row
|
||||
region_obj = region_map.get(region_id)
|
||||
|
||||
if not region_obj:
|
||||
continue
|
||||
|
||||
if city_id in existing_city_ids:
|
||||
# For updates, just store the data - we'll do bulk update by raw SQL
|
||||
cities_to_update.append({
|
||||
'id': city_id,
|
||||
'name': name,
|
||||
'region_id': region_obj.id,
|
||||
'longitude': longitude,
|
||||
'latitude': latitude
|
||||
})
|
||||
else:
|
||||
cities_to_create.append(City(
|
||||
id=city_id,
|
||||
name=name,
|
||||
region=region_obj,
|
||||
longitude=longitude,
|
||||
latitude=latitude
|
||||
))
|
||||
|
||||
processed += 1
|
||||
|
||||
# Flush create batch (this is already fast)
|
||||
if cities_to_create:
|
||||
with transaction.atomic():
|
||||
City.objects.bulk_create(cities_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
cities_to_create.clear()
|
||||
|
||||
# Flush update batch with raw SQL for speed
|
||||
if cities_to_update:
|
||||
self._bulk_update_cities_raw(cities_to_update)
|
||||
cities_to_update.clear()
|
||||
|
||||
if processed % 5000 == 0:
|
||||
self.stdout.write(f' Processed {processed} cities...')
|
||||
gc.collect()
|
||||
|
||||
# Final flush
|
||||
if cities_to_create:
|
||||
with transaction.atomic():
|
||||
City.objects.bulk_create(cities_to_create, batch_size=batch_size, ignore_conflicts=True)
|
||||
if cities_to_update:
|
||||
self._bulk_update_cities_raw(cities_to_update)
|
||||
|
||||
self.stdout.write(f'✓ Cities complete: {processed} processed')
|
||||
|
||||
for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"):
|
||||
batch = cities_to_create[i:i + batch_size]
|
||||
City.objects.bulk_create(batch)
|
||||
def _bulk_update_cities_raw(self, cities_data):
|
||||
"""Fast bulk update using raw SQL"""
|
||||
if not cities_data:
|
||||
return
|
||||
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Build the SQL for bulk update
|
||||
# Using CASE statements for efficient bulk updates
|
||||
when_clauses_name = []
|
||||
when_clauses_region = []
|
||||
when_clauses_lng = []
|
||||
when_clauses_lat = []
|
||||
city_ids = []
|
||||
|
||||
for city in cities_data:
|
||||
city_id = city['id']
|
||||
city_ids.append(city_id)
|
||||
when_clauses_name.append(f"WHEN id = %s THEN %s")
|
||||
when_clauses_region.append(f"WHEN id = %s THEN %s")
|
||||
when_clauses_lng.append(f"WHEN id = %s THEN %s")
|
||||
when_clauses_lat.append(f"WHEN id = %s THEN %s")
|
||||
|
||||
# Build parameters list
|
||||
params = []
|
||||
for city in cities_data:
|
||||
params.extend([city['id'], city['name']]) # for name
|
||||
for city in cities_data:
|
||||
params.extend([city['id'], city['region_id']]) # for region_id
|
||||
for city in cities_data:
|
||||
params.extend([city['id'], city['longitude']]) # for longitude
|
||||
for city in cities_data:
|
||||
params.extend([city['id'], city['latitude']]) # for latitude
|
||||
params.extend(city_ids) # for WHERE clause
|
||||
|
||||
# Execute the bulk update
|
||||
sql = f"""
|
||||
UPDATE worldtravel_city
|
||||
SET
|
||||
name = CASE {' '.join(when_clauses_name)} END,
|
||||
region_id = CASE {' '.join(when_clauses_region)} END,
|
||||
longitude = CASE {' '.join(when_clauses_lng)} END,
|
||||
latitude = CASE {' '.join(when_clauses_lat)} END
|
||||
WHERE id IN ({','.join(['%s'] * len(city_ids))})
|
||||
"""
|
||||
|
||||
cursor.execute(sql, params)
|
||||
|
||||
# Process updates in batches
|
||||
for i in range(0, len(countries_to_update), batch_size):
|
||||
batch = countries_to_update[i:i + batch_size]
|
||||
for i in tqdm(range(0, len(countries_to_update), batch_size), desc="Updating countries"):
|
||||
batch = countries_to_update[i:i + batch_size]
|
||||
Country.objects.bulk_update(batch, ['name', 'subregion', 'capital', 'longitude', 'latitude'])
|
||||
|
||||
for i in tqdm(range(0, len(regions_to_update), batch_size), desc="Updating regions"):
|
||||
batch = regions_to_update[i:i + batch_size]
|
||||
Region.objects.bulk_update(batch, ['name', 'country', 'longitude', 'latitude'])
|
||||
|
||||
for i in tqdm(range(0, len(cities_to_update), batch_size), desc="Updating cities"):
|
||||
batch = cities_to_update[i:i + batch_size]
|
||||
City.objects.bulk_update(batch, ['name', 'region', 'longitude', 'latitude'])
|
||||
Country.objects.exclude(country_code__in=processed_country_codes).delete()
|
||||
Region.objects.exclude(id__in=processed_region_ids).delete()
|
||||
City.objects.exclude(id__in=processed_city_ids).delete()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('All data imported successfully'))
|
||||
def _cleanup_obsolete_records(self, temp_conn):
|
||||
"""Clean up obsolete records using temporary database"""
|
||||
# Get IDs from temp database to avoid loading large lists into memory
|
||||
temp_country_codes = {row[0] for row in temp_conn.execute('SELECT country_code FROM temp_countries')}
|
||||
temp_region_ids = {row[0] for row in temp_conn.execute('SELECT id FROM temp_regions')}
|
||||
temp_city_ids = {row[0] for row in temp_conn.execute('SELECT id FROM temp_cities')}
|
||||
|
||||
with transaction.atomic():
|
||||
countries_deleted = Country.objects.exclude(country_code__in=temp_country_codes).count()
|
||||
regions_deleted = Region.objects.exclude(id__in=temp_region_ids).count()
|
||||
cities_deleted = City.objects.exclude(id__in=temp_city_ids).count()
|
||||
|
||||
Country.objects.exclude(country_code__in=temp_country_codes).delete()
|
||||
Region.objects.exclude(id__in=temp_region_ids).delete()
|
||||
City.objects.exclude(id__in=temp_city_ids).delete()
|
||||
|
||||
if countries_deleted > 0 or regions_deleted > 0 or cities_deleted > 0:
|
||||
self.stdout.write(f'✓ Deleted {countries_deleted} obsolete countries, {regions_deleted} regions, {cities_deleted} cities')
|
||||
else:
|
||||
self.stdout.write('✓ No obsolete records found to delete')
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 5.2.1 on 2025-06-14 17:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('worldtravel', '0015_city_insert_id_country_insert_id_region_insert_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='city',
|
||||
name='insert_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='country',
|
||||
name='insert_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='region',
|
||||
name='insert_id',
|
||||
),
|
||||
]
|
|
@ -17,7 +17,6 @@ class Country(models.Model):
|
|||
capital = models.CharField(max_length=100, blank=True, null=True)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Country"
|
||||
|
@ -32,7 +31,6 @@ class Region(models.Model):
|
|||
country = models.ForeignKey(Country, on_delete=models.CASCADE)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -43,7 +41,6 @@ class City(models.Model):
|
|||
region = models.ForeignKey(Region, on_delete=models.CASCADE)
|
||||
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
insert_id = models.UUIDField(unique=False, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Cities"
|
||||
|
|
|
@ -22,8 +22,11 @@ class CountrySerializer(serializers.ModelSerializer):
|
|||
|
||||
def get_num_visits(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and hasattr(request, 'user'):
|
||||
return VisitedRegion.objects.filter(region__country=obj, user_id=request.user).count()
|
||||
user = getattr(request, 'user', None)
|
||||
|
||||
if user and user.is_authenticated:
|
||||
return VisitedRegion.objects.filter(region__country=obj, user_id=user).count()
|
||||
|
||||
return 0
|
||||
|
||||
class Meta:
|
||||
|
|
16
backend/supervisord.conf
Normal file
|
@ -0,0 +1,16 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
|
||||
[program:gunicorn]
|
||||
command=/code/entrypoint.sh
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stderr_logfile=/dev/stderr
|
||||
stdout_logfile_maxbytes = 0
|
||||
stderr_logfile_maxbytes = 0
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 259 KiB |
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 809 KiB |
Before Width: | Height: | Size: 848 KiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 656 KiB After Width: | Height: | Size: 637 KiB |
Before Width: | Height: | Size: 735 KiB After Width: | Height: | Size: 434 KiB |
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 326 KiB |
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 224 KiB |
|
@ -4,23 +4,17 @@ services:
|
|||
image: ghcr.io/seanmorley15/adventurelog-frontend:latest
|
||||
container_name: adventurelog-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=http://server:8000 # Should be the service name of the backend with port 8000, even if you change the port in the backend service
|
||||
- ORIGIN=http://localhost:8015
|
||||
- BODY_SIZE_LIMIT=Infinity
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8015:3000"
|
||||
- "${FRONTEND_PORT:-8015}:3000"
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
db:
|
||||
image: postgis/postgis:15-3.3
|
||||
image: postgis/postgis:16-3.5
|
||||
container_name: adventurelog-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: database
|
||||
POSTGRES_USER: adventure
|
||||
POSTGRES_PASSWORD: changeme123
|
||||
env_file: .env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
|
||||
|
@ -29,21 +23,9 @@ services:
|
|||
image: ghcr.io/seanmorley15/adventurelog-backend:latest
|
||||
container_name: adventurelog-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PGHOST=db
|
||||
- PGDATABASE=database
|
||||
- PGUSER=adventure
|
||||
- PGPASSWORD=changeme123
|
||||
- SECRET_KEY=changeme123
|
||||
- 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
|
||||
- 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
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8016:80"
|
||||
- "${BACKEND_PORT:-8016}:80"
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { defineConfig } from "vitepress";
|
||||
|
||||
const inProd = process.env.NODE_ENV === "production";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
head: [
|
||||
|
@ -15,6 +13,14 @@ export default defineConfig({
|
|||
"data-website-id": "a7552764-5a1d-4fe7-80c2-5331e1a53cb6",
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
"link",
|
||||
{
|
||||
rel: "me",
|
||||
href: "https://mastodon.social/@adventurelog",
|
||||
},
|
||||
],
|
||||
],
|
||||
ignoreDeadLinks: "localhostLinks",
|
||||
title: "AdventureLog",
|
||||
|
@ -25,6 +31,67 @@ export default defineConfig({
|
|||
hostname: "https://adventurelog.app",
|
||||
},
|
||||
|
||||
transformPageData(pageData) {
|
||||
if (pageData.relativePath === "index.md") {
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "AdventureLog",
|
||||
url: "https://adventurelog.app",
|
||||
applicationCategory: "TravelApplication",
|
||||
operatingSystem: "Web, Docker, Linux",
|
||||
description:
|
||||
"AdventureLog is a self-hosted platform for tracking and planning travel experiences. Built for modern explorers, it offers trip planning, journaling, tracking and location mapping in one privacy-respecting package.",
|
||||
creator: {
|
||||
"@type": "Person",
|
||||
name: "Sean Morley",
|
||||
url: "https://seanmorley.com",
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0.00",
|
||||
priceCurrency: "USD",
|
||||
description: "Open-source version available for self-hosting.",
|
||||
},
|
||||
softwareVersion: "v0.10.0",
|
||||
license:
|
||||
"https://github.com/seanmorley15/adventurelog/blob/main/LICENSE",
|
||||
screenshot:
|
||||
"https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/adventures.png",
|
||||
downloadUrl: "https://github.com/seanmorley15/adventurelog",
|
||||
sameAs: ["https://github.com/seanmorley15/adventurelog"],
|
||||
keywords: [
|
||||
"self-hosted travel log",
|
||||
"open source trip planner",
|
||||
"travel journaling app",
|
||||
"docker travel diary",
|
||||
"map-based travel tracker",
|
||||
"privacy-focused travel app",
|
||||
"adventure log software",
|
||||
"travel experience tracker",
|
||||
"self-hosted travel app",
|
||||
"open source travel software",
|
||||
"trip planning tool",
|
||||
"travel itinerary manager",
|
||||
"location-based travel app",
|
||||
"travel experience sharing",
|
||||
"travel log application",
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
frontmatter: {
|
||||
...pageData.frontmatter,
|
||||
head: [
|
||||
["script", { type: "application/ld+json" }, JSON.stringify(jsonLd)],
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
|
@ -62,6 +129,7 @@ export default defineConfig({
|
|||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Getting Started", link: "/docs/install/getting_started" },
|
||||
{ text: "Quick Start Script ⏲️", link: "/docs/install/quick_start" },
|
||||
{ text: "Docker 🐋", link: "/docs/install/docker" },
|
||||
{ text: "Proxmox LXC 🐧", link: "/docs/install/proxmox_lxc" },
|
||||
{ text: "Synology NAS ☁️", link: "/docs/install/synology_nas" },
|
||||
|
@ -80,10 +148,21 @@ export default defineConfig({
|
|||
link: "/docs/install/nginx_proxy_manager",
|
||||
},
|
||||
{ text: "Traefik", link: "/docs/install/traefik" },
|
||||
{ text: "Caddy", link: "/docs/install/caddy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Usage",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "How to use AdventureLog",
|
||||
link: "/docs/usage/usage",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Configuration",
|
||||
collapsed: false,
|
||||
|
@ -92,6 +171,10 @@ export default defineConfig({
|
|||
text: "Immich Integration",
|
||||
link: "/docs/configuration/immich_integration",
|
||||
},
|
||||
{
|
||||
text: "Google Maps Integration",
|
||||
link: "/docs/configuration/google_maps_integration",
|
||||
},
|
||||
{
|
||||
text: "Social Auth and OIDC",
|
||||
link: "/docs/configuration/social_auth",
|
||||
|
@ -108,6 +191,10 @@ export default defineConfig({
|
|||
text: "GitHub",
|
||||
link: "/docs/configuration/social_auth/github",
|
||||
},
|
||||
{
|
||||
text: "Authelia",
|
||||
link: "https://www.authelia.com/integration/openid-connect/adventure-log/",
|
||||
},
|
||||
{
|
||||
text: "Open ID Connect",
|
||||
link: "/docs/configuration/social_auth/oidc",
|
||||
|
@ -134,6 +221,10 @@ export default defineConfig({
|
|||
text: "No Images Displaying",
|
||||
link: "/docs/troubleshooting/no_images",
|
||||
},
|
||||
{
|
||||
text: "Login and Registration Unresponsive",
|
||||
link: "/docs/troubleshooting/login_unresponsive",
|
||||
},
|
||||
{
|
||||
text: "Failed to Start Nginx",
|
||||
link: "/docs/troubleshooting/nginx_failed",
|
||||
|
@ -158,6 +249,14 @@ export default defineConfig({
|
|||
text: "Changelogs",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "v0.10.0",
|
||||
link: "/docs/changelogs/v0-10-0",
|
||||
},
|
||||
{
|
||||
text: "v0.9.0",
|
||||
link: "/docs/changelogs/v0-9-0",
|
||||
},
|
||||
{
|
||||
text: "v0.8.0",
|
||||
link: "/docs/changelogs/v0-8-0",
|
||||
|
@ -180,6 +279,7 @@ export default defineConfig({
|
|||
{ icon: "buymeacoffee", link: "https://buymeacoffee.com/seanmorley15" },
|
||||
{ icon: "x", link: "https://x.com/AdventureLogApp" },
|
||||
{ icon: "mastodon", link: "https://mastodon.social/@adventurelog" },
|
||||
{ icon: "instagram", link: "https://www.instagram.com/adventurelogapp" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
123
documentation/docs/changelogs/v0-10-0.md
Normal file
|
@ -0,0 +1,123 @@
|
|||
# AdventureLog v0.10.0 - Trip Maps, Google Maps Integration & Quick Deploy Script
|
||||
|
||||
Released 06-10-2025
|
||||
|
||||
Hi everyone,
|
||||
|
||||
I’m pleased to share **AdventureLog v0.10.0**, a focused update that brings timezone-aware planning, smoother maps, and simpler deployment. This release refines many of the core features you’ve been using and addresses feedback from the community. Thank you for your contributions, suggestions, and ongoing support!
|
||||
|
||||
## 🧭 Time-Aware Travel Planning
|
||||
|
||||
**Timezone-Aware Visits & Timeline Logic**
|
||||
|
||||
- Exact start/end times with timezones for each visit, so your itinerary matches when and where events actually happen.
|
||||
- Collections now auto-order by date, giving your timeline a clear, chronological flow.
|
||||
- Lodging and transportation entries follow the same rules, making multi-city trips easier to visualize.
|
||||
- A chronologically accurate map and timeline view shows your adventure in the right order.
|
||||
|
||||
## 🗺️ Smart Mapping & Location Tools
|
||||
|
||||
**Google Maps Integration (Optional)**
|
||||
|
||||
- Autocomplete-powered location search (via Google Maps) for faster, more accurate entries.
|
||||
- Automatic geocoding ties new or updated adventures to the correct country, region, and city.
|
||||
- Improved map performance and cleaner markers throughout the app.
|
||||
|
||||
**Map Interaction Enhancements**
|
||||
|
||||
- Open any adventure location in Apple Maps, Google Maps, or OpenStreetMap with one click.
|
||||
- Full-width maps on mobile devices for better visibility.
|
||||
- Tidied-up markers and updated category icons for clarity.
|
||||
|
||||
## 🎨 UI & UX Refinements
|
||||
|
||||
- Updated adventure forms with clearer labels and streamlined inputs.
|
||||
- Smoother page transitions and consistent layouts on desktop and mobile.
|
||||
- Design and spacing adjustments for a more balanced, polished appearance.
|
||||
- Various bug fixes to address layout quirks and improve overall usability.
|
||||
|
||||
## 🌍 Localization & Navigation
|
||||
|
||||
- Expanded language support and updated locale files.
|
||||
- Improved back/forward navigation so you don’t lose your place when browsing collections.
|
||||
- Responsive collection cards that adapt to different screen sizes.
|
||||
- Fixed minor layout issues related to collections and navigation.
|
||||
|
||||
## 📷 Immich Integration Upgrades
|
||||
|
||||
- Choose whether to copy Immich images into AdventureLog storage or reference them via URL to avoid duplicating files.
|
||||
- Toggle “Copy Images” on or off to manage storage preferences.
|
||||
- Updated logic to prevent duplicate image uploads when using Immich.
|
||||
|
||||
## ⚙️ DevOps & Backend Enhancements
|
||||
|
||||
- Switched to `supervisord` in Docker for reliable container startup and centralized logging.
|
||||
- Restored IPv6 support for dual-stack deployments.
|
||||
- Upgraded to Node.js v22 for better performance and compatibility.
|
||||
- Added more tests, improved UTC-aware date validation, and refined ID generation.
|
||||
- Optimized database migrations for smoother updates.
|
||||
|
||||
## 📘 Documentation & Community Resources
|
||||
|
||||
- New guide for deploying with Caddy web server, covering TLS setup and reverse proxy configuration.
|
||||
- Updated instructions for Google Maps integration, including API key setup and troubleshooting.
|
||||
- Follow our Mastodon profile at [@adventurelog@mastodon.social](https://mastodon.social/@adventurelog) for updates and discussion.
|
||||
- Chat with other users on our [Discord server](https://discord.gg/wRbQ9Egr8C) to share feedback, ask questions, or swap travel tips.
|
||||
|
||||
## ✨ NEW: Quick Deploy Script
|
||||
|
||||
Based on community feedback, we’ve added a simple deployment script:
|
||||
|
||||
1. Run:
|
||||
|
||||
```bash
|
||||
curl -sSL https://get.adventurelog.app | bash
|
||||
```
|
||||
|
||||
2. Provide your domain/ip details when prompted.
|
||||
The script handles Docker Compose, and environment configuration automatically.
|
||||
|
||||
Self-hosting just got a bit easier—no more manual setup steps.
|
||||
|
||||
## ℹ️ Additional Notes
|
||||
|
||||
- **Bulk Geocoding**
|
||||
To geocode existing adventures in one go docker exec into the backend container and run:
|
||||
|
||||
```
|
||||
python manage.py bulk-adventure-geocode
|
||||
```
|
||||
|
||||
This will link all adventures to their correct country, region, and city.
|
||||
|
||||
- **Timezone Migrations**
|
||||
If you have older trips without explicit timezones, simply view a trip’s detail page and AdventureLog will auto-convert the dates.
|
||||
|
||||
## 👥 Thanks to Our Contributors
|
||||
|
||||
Your pull requests, issue reports, and ongoing feedback have been instrumental. Special thanks to:
|
||||
|
||||
- @ClumsyAdmin
|
||||
- @eidsheim98
|
||||
- @andreatitolo
|
||||
- @lesensei
|
||||
- @theshaun
|
||||
- @lkiesow
|
||||
- @larsl-net
|
||||
- @marcschumacher
|
||||
|
||||
Every contribution helps make AdventureLog more reliable and user-friendly.
|
||||
|
||||
## 💖 Support the Project
|
||||
|
||||
If you find AdventureLog helpful, consider sponsoring me! Your support keeps this project going:
|
||||
|
||||
[https://seanmorley.com/sponsor](https://seanmorley.com/sponsor)
|
||||
|
||||
📖 [View the Full Changelog on GitHub](https://github.com/seanmorley15/AdventureLog/compare/v0.9.0...v0.10.0)
|
||||
|
||||
Thanks for being part of the AdventureLog community. I appreciate your feedback and look forward to seeing where your next journey takes you!
|
||||
|
||||
Happy travels,
|
||||
**Sean Morley** (@seanmorley15)
|
||||
Project Lead, AdventureLog
|
133
documentation/docs/changelogs/v0-9-0.md
Normal file
|
@ -0,0 +1,133 @@
|
|||
# AdventureLog v0.9.0 - Smart Recommendations, Attachments, and Maps
|
||||
|
||||
Released 03-19-2025
|
||||
|
||||
Hi travelers! 🌍
|
||||
I’m excited to unveil **AdventureLog v0.9.0**, one of our most feature-packed updates yet! From Smart Recommendations to enhanced maps and a refreshed profile system, this release is all about improving your travel planning and adventure tracking experience. Let’s dive into what’s new!
|
||||
|
||||
---
|
||||
|
||||
## What's New ✨
|
||||
|
||||
### 🧠 Smart Recommendations
|
||||
|
||||
- **AdventureLog Smart Recommendations**: Get tailored suggestions for new adventures and activities based on your collection destinations.
|
||||
- Leverages OpenStreetMap to recommend places and activities near your travel destinations.
|
||||
|
||||
---
|
||||
|
||||
### 🗂️ Attachments, GPX Maps & Global Search
|
||||
|
||||
- **Attachments System**: Attach files to your adventures to view key trip data like maps and tickets in AdventureLog!
|
||||
- **GPX File Uploads & Maps**: Upload GPX tracks to adventures to visualize them directly on your maps.
|
||||
- **Global Search**: A universal search bar to quickly find adventures, cities, countries, and more across your instance.
|
||||
|
||||
---
|
||||
|
||||
### 🏨 Lodging & Itinerary
|
||||
|
||||
- **Lodging Tracking**: Add and manage lodging accommodations as part of your collections, complete with check-in/check-out dates.
|
||||
- **Improved Itinerary Views**: Better day-by-day itinerary display with clear UI enhancements.
|
||||
|
||||
---
|
||||
|
||||
### 🗺️ Maps & Locations
|
||||
|
||||
- **Open Locations in Maps**: Directly open adventure locations and points of interest in your preferred mapping service.
|
||||
- **Adventure Category Icons on Maps**: View custom category icons right on your adventure and collection maps.
|
||||
|
||||
---
|
||||
|
||||
### 🗓️ Calendar
|
||||
|
||||
- **Collection Range View**: Improved calendar view showing the full date range of collections.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Authentication & Security
|
||||
|
||||
- **OIDC Authentication**: Added support for OpenID Connect (OIDC) for seamless integration with identity providers.
|
||||
- **Secure Session Cookies**: Improved session cookie handling with dynamic domain detection and better security for IP addresses.
|
||||
- **Disable Password Auth**: Option to disable password auth for users with connected OIDC/Social accounts.
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ PWA Support
|
||||
|
||||
- **Progressive Web App (PWA) Support**: Install AdventureLog as a PWA on your desktop or mobile device for a native app experience.
|
||||
|
||||
---
|
||||
|
||||
### 🏗️ Infrastructure & DevOps
|
||||
|
||||
- **Dual-Stack Backend**: IPv4 and IPv6 ready backend system (@larsl-net).
|
||||
- **Kubernetes Configs** continue to be improved for scalable deployments.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Localization
|
||||
|
||||
- **Korean language support** (@seanmorley15).
|
||||
- **Improved Dutch** (@ThomasDetemmerman), **Simplified Chinese** (@jyyyeung), **German** (@Cathnan and @marcschumacher) translations.
|
||||
- **Polish and Swedish** translations improved in prior release!
|
||||
|
||||
---
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- **New Unraid Installation Guide** with community-contributed updates (@ThunderLord956, @evertyang).
|
||||
- Updated **OIDC** and **Immich integration** docs for clarity (@UndyingSoul, @motox986).
|
||||
- General spell-check and documentation polish (@ThunderLord956, @mcguirepr89).
|
||||
|
||||
---
|
||||
|
||||
### 🐛 Bug Fixes and Improvements
|
||||
|
||||
- Fixed CSRF issues with admin tools.
|
||||
- Backend ready for **dual-stack** environments.
|
||||
- Improved itinerary element display and GPX file handling.
|
||||
- Optimized session cookie handling for domain/IP setups.
|
||||
- Various **small Python fixes** (@larsl-net).
|
||||
- Fixed container relations (@bucherfa).
|
||||
- Django updated to **5.0.11** for security and performance improvements.
|
||||
- General **codebase clean-up** and UI polish.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 New Contributors
|
||||
|
||||
A huge shoutout to our amazing new contributors! 🎉
|
||||
|
||||
- @larsl-net
|
||||
- @bucherfa
|
||||
- @UndyingSoul
|
||||
- @ThunderLord956
|
||||
- @evertyang
|
||||
- @Thiesjoo
|
||||
- @motox986
|
||||
- @mcguirepr89
|
||||
- @ThomasDetemmerman
|
||||
- @Cathnan
|
||||
- @jyyyeung
|
||||
- @marcschumacher
|
||||
|
||||
Thank you for helping AdventureLog grow! 🙌
|
||||
|
||||
---
|
||||
|
||||
## Support My Work 💖
|
||||
|
||||
[](https://www.buymeacoffee.com/seanmorley15)
|
||||
If AdventureLog has made your travels more organized or your trip memories richer, consider supporting my work on **Buy Me A Coffee**. Your support directly helps shape the future of this project! ☕
|
||||
|
||||
---
|
||||
|
||||
Enjoy this update and keep sharing your journeys with us! 🌍✈️
|
||||
As always, drop your feedback and ideas in the [official Discord](https://discord.gg/wRbQ9Egr8) or in the discussions!
|
||||
|
||||
Happy travels,
|
||||
**Sean Morley** (@seanmorley15)
|
||||
|
||||
---
|
||||
|
||||
**[Full Changelog](https://github.com/seanmorley15/AdventureLog/compare/v0.8.0...v0.9.0)**
|
|
@ -6,7 +6,7 @@ To change the email backend, you can set the following variable in your docker-c
|
|||
|
||||
```yaml
|
||||
environment:
|
||||
- EMAIL_BACKEND='console'
|
||||
- EMAIL_BACKEND=console
|
||||
```
|
||||
|
||||
## With SMTP
|
||||
|
|
36
documentation/docs/configuration/google_maps_integration.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Google Maps Integration
|
||||
|
||||
To enable Google Maps integration in AdventureLog, you'll need to create a Google Maps API key. This key allows AdventureLog to use Google Maps services such as geocoding and location search throughout the application.
|
||||
|
||||
Follow the steps below to generate your own API key:
|
||||
|
||||
## Google Cloud Console Setup
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
2. Create an account if you don't have one in order to access the console.
|
||||
3. Click on the project dropdown in the top bar.
|
||||
4. Click **New Project**.
|
||||
5. Name your project (e.g., `AdventureLog Maps`) and click **Create**.
|
||||
6. Once the project is created, ensure it is selected in the project dropdown.
|
||||
7. Click on the **Navigation menu** (three horizontal lines in the top left corner).
|
||||
8. Navigate to **Google Maps Platform**.
|
||||
9. Once in the Maps Platform, click on **Keys & Credentials** in the left sidebar.
|
||||
10. Click on **Create credentials** and select **API key**.
|
||||
11. A dialog will appear with your new API key. Copy this key for later use.
|
||||
|
||||
<!-- To prevent misuse:
|
||||
|
||||
1. Click the **Edit icon** next to your new API key.
|
||||
2. Under **Application restrictions**, choose one:
|
||||
- Choose **Websites** as the restriction type.
|
||||
- Add the domain of the AdventureLog **backend** (e.g., `https://your-adventurelog-backend.com`). -->
|
||||
|
||||
## Configuration in AdventureLog
|
||||
|
||||
Set the API key in your environment file or configuration under the backend service of AdventureLog. This is typically done in the `docker-compose.yml` file or directly in your environment variables `.env` file.
|
||||
|
||||
```env
|
||||
GOOGLE_MAPS_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
Once this is set, AdventureLog will be able to utilize Google Maps services for geocoding and location searches instead of relying on the default OpenStreetMap services.
|
|
@ -9,6 +9,7 @@ The steps for each service varies so please refer to the specific service's docu
|
|||
- [Authentik](social_auth/authentik.md) (self-hosted)
|
||||
- [GitHub](social_auth/github.md)
|
||||
- [Open ID Connect](social_auth/oidc.md)
|
||||
- [Authelia](https://www.authelia.com/integration/openid-connect/adventure-log/)
|
||||
|
||||
## Linking Existing Accounts
|
||||
|
||||
|
|
67
documentation/docs/install/caddy.md
Normal file
|
@ -0,0 +1,67 @@
|
|||
# Installation with Caddy
|
||||
|
||||
Caddy is a modern HTTP reverse proxy. It automatically integrates with Let's Encrypt (or other certificate providers) to generate TLS certificates for your site.
|
||||
|
||||
As an example, if you want to add Caddy to your Docker compose configuration, add the following service to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: docker.io/library/caddy:2
|
||||
container_name: adventurelog-caddy
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./caddy:/etc/caddy
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
web: ...
|
||||
server: ...
|
||||
db: ...
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
```
|
||||
|
||||
Since all ingress traffic to the AdventureLog containsers now travels through Caddy, we can also remove the external ports configuration from those containsers in the `docker-compose.yml`. Just delete this configuration:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
ports:
|
||||
- "8016:80"
|
||||
…
|
||||
server:
|
||||
ports:
|
||||
- "8015:3000"
|
||||
```
|
||||
|
||||
That's it for the Docker compose changes. Of course, there are other methods to run Caddy which are equally valid.
|
||||
|
||||
However, we also need to configure Caddy. For this, create a file `./caddy/Caddyfile` in which you configure the requests which are proxied to the frontend and backend respectively and what domain Caddy should request a certificate for:
|
||||
|
||||
```
|
||||
adventurelog.example.com {
|
||||
|
||||
@frontend {
|
||||
not path /media* /admin* /static* /accounts*
|
||||
}
|
||||
reverse_proxy @frontend web:3000
|
||||
|
||||
reverse_proxy server:80
|
||||
}
|
||||
```
|
||||
|
||||
Once configured, you can start up the containsers:
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Your AdventureLog should now be up and running.
|
|
@ -1,7 +1,8 @@
|
|||
# Docker 🐋
|
||||
|
||||
Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers.
|
||||
**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems.
|
||||
|
||||
> **Note**: This guide mainly focuses on installation with a Linux-based host machine, but the steps are similar for other operating systems.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
@ -9,7 +10,16 @@ Docker is the preferred way to run AdventureLog on your local machine. It is a l
|
|||
|
||||
## Getting Started
|
||||
|
||||
Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine:
|
||||
Get the `docker-compose.yml` and `.env.example` files from the AdventureLog repository. You can download them here:
|
||||
|
||||
- [Docker Compose](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml)
|
||||
- [Environment Variables](https://github.com/seanmorley15/AdventureLog/blob/main/.env.example)
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
::: tip
|
||||
|
||||
|
@ -17,46 +27,56 @@ If running on an ARM based machine, you will need to use a different PostGIS Ima
|
|||
|
||||
:::
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Here is a summary of the configuration options available in the `docker-compose.yml` file:
|
||||
The `.env` file contains all the configuration settings for your AdventureLog instance. Here’s a breakdown of each section:
|
||||
|
||||
<!-- make a table with column name, is required, other -->
|
||||
### 🌐 Frontend (web)
|
||||
|
||||
### Frontend Container (web)
|
||||
| Name | Required | Description | Default Value |
|
||||
| ------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| `PUBLIC_SERVER_URL` | Yes | Used by the frontend SSR server to connect to the backend. Almost every user user will **never have to change this from default**! | `http://server:8000` |
|
||||
| `ORIGIN` | Sometimes | Needed only if not using HTTPS. Set it to the domain or IP you'll use to access the frontend. | `http://localhost:8015` |
|
||||
| `BODY_SIZE_LIMIT` | Yes | Maximum upload size in bytes. | `Infinity` |
|
||||
| `FRONTEND_PORT` | Yes | Port that the frontend will run on inside Docker. | `8015` |
|
||||
|
||||
| Name | Required | Description | Default Value |
|
||||
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | ```http://server:8000``` |
|
||||
| `ORIGIN` | Sometimes | Not needed if using HTTPS. If not, set it to the domain of what you will access the app from. | ```http://localhost:8015``` |
|
||||
| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **kilobytes**. | ```Infinity``` |
|
||||
### 🐘 PostgreSQL Database
|
||||
|
||||
### Backend Container (server)
|
||||
| Name | Required | Description | Default Value |
|
||||
| ------------------- | -------- | --------------------- | ------------- |
|
||||
| `PGHOST` | Yes | Internal DB hostname. | `db` |
|
||||
| `POSTGRES_DB` | Yes | DB name. | `database` |
|
||||
| `POSTGRES_USER` | Yes | DB user. | `adventure` |
|
||||
| `POSTGRES_PASSWORD` | Yes | DB password. | `changeme123` |
|
||||
|
||||
| Name | Required | Description | Default Value |
|
||||
| ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| `PGHOST` | Yes | Database host. | ```db``` |
|
||||
| `PGDATABASE` | Yes | Database. | ```database``` |
|
||||
| `PGUSER` | Yes | Database user. | ```adventure``` |
|
||||
| `PGPASSWORD` | Yes | Database password. | ```changeme123``` |
|
||||
| `PGPORT` | No | Database port. | ```5432``` |
|
||||
| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | ```admin``` |
|
||||
| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after initial login. | ```admin``` |
|
||||
| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | ```admin@example.com``` |
|
||||
| `PUBLIC_URL` | Yes | This needs to match the outward port of the server and be accessible from where the app is used. It is used for the creation of image urls. | ```http://localhost:8016``` |
|
||||
| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the origins where you use your backend server and frontend. These values are comma separated. | ```http://localhost:8016``` |
|
||||
| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | ```http://localhost:8015``` |
|
||||
### 🔒 Backend (server)
|
||||
|
||||
| Name | Required | Description | Default Value |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| `SECRET_KEY` | Yes | Django secret key. Change this in production! | `changeme123` |
|
||||
| `DJANGO_ADMIN_USERNAME` | Yes | Default Django admin username. | `admin` |
|
||||
| `DJANGO_ADMIN_PASSWORD` | Yes | Default Django admin password. | `admin` |
|
||||
| `DJANGO_ADMIN_EMAIL` | Yes | Default admin email. | `admin@example.com` |
|
||||
| `PUBLIC_URL` | Yes | Publicly accessible URL of the **backend**. Used for generating image URLs. | `http://localhost:8016` |
|
||||
| `CSRF_TRUSTED_ORIGINS` | Yes | Comma-separated list of frontend/backend URLs that are allowed to submit requests. | `http://localhost:8016,http://localhost:8015` |
|
||||
| `FRONTEND_URL` | Yes | URL to the **frontend**, used for email generation. | `http://localhost:8015` |
|
||||
| `BACKEND_PORT` | Yes | Port that the backend will run on inside Docker. | `8016` |
|
||||
| `DEBUG` | No | Should be `False` in production. | `False` |
|
||||
|
||||
## Optional Configuration
|
||||
|
||||
- [Disable Registration](../configuration/disable_registration.md)
|
||||
- [Google Maps](../configuration/google_maps_integration.md)
|
||||
- [Email Configuration](../configuration/email.md)
|
||||
- [Immich Integration](../configuration/immich_integration.md)
|
||||
- [Umami Analytics](../configuration/analytics.md)
|
||||
|
||||
## Running the Containers
|
||||
|
||||
To start the containers, run the following command:
|
||||
Once you've configured `.env`, you can start AdventureLog with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Enjoy AdventureLog! 🎉
|
||||
Enjoy using AdventureLog! 🎉
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
# Install Options for AdventureLog
|
||||
# 🚀 Install Options for AdventureLog
|
||||
|
||||
AdventureLog can be installed in a variety of ways. The following are the most common methods:
|
||||
AdventureLog can be installed in a variety of ways, depending on your platform or preference.
|
||||
|
||||
- [Docker](docker.md) 🐳
|
||||
- [Proxmox LXC](proxmox_lxc.md) 🐧
|
||||
- [Synology NAS](synology_nas.md) ☁️
|
||||
- [Kubernetes and Kustomize](kustomize.md) 🌐
|
||||
- [Unraid](unraid.md) 🧡
|
||||
## 📦 Docker Quick Start
|
||||
|
||||
### Other Options
|
||||
::: tip Quick Start Script
|
||||
**The fastest way to get started:**
|
||||
[Install AdventureLog with a single command →](quick_start.md)
|
||||
Perfect for Docker beginners.
|
||||
:::
|
||||
|
||||
- [Nginx Proxy Manager](nginx_proxy_manager.md) 🛡
|
||||
- [Traefik](traefik.md) 🚀
|
||||
## 🐳 Popular Installation Methods
|
||||
|
||||
- [Docker](docker.md) — Simple containerized setup
|
||||
- [Proxmox LXC](proxmox_lxc.md) — Lightweight virtual environment
|
||||
- [Synology NAS](synology_nas.md) — Self-host on your home NAS
|
||||
- [Kubernetes + Kustomize](kustomize.md) — Advanced, scalable deployment
|
||||
- [Unraid](unraid.md) — Easy integration for homelabbers
|
||||
- [Umbrel](https://apps.umbrel.com/app/adventurelog) — Home server app store
|
||||
|
||||
## ⚙️ Advanced & Alternative Setups
|
||||
|
||||
- [Nginx Proxy Manager](nginx_proxy_manager.md) — Easy reverse proxy config
|
||||
- [Traefik](traefik.md) — Dynamic reverse proxy with automation
|
||||
- [Caddy](caddy.md) — Automatic HTTPS with a clean config
|
||||
|
|
45
documentation/docs/install/quick_start.md
Normal file
|
@ -0,0 +1,45 @@
|
|||
# 🚀 Quick Start Install
|
||||
|
||||
Install **AdventureLog** in seconds using our automated script.
|
||||
|
||||
## 🧪 One-Liner Install
|
||||
|
||||
```bash
|
||||
curl -sSL https://get.adventurelog.app | bash
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Check dependencies (Docker, Docker Compose)
|
||||
- Set up project directory
|
||||
- Download required files
|
||||
- Prompt for basic configuration (like domain name)
|
||||
- Start AdventureLog with Docker Compose
|
||||
|
||||
## ✅ Requirements
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Linux server or VPS
|
||||
- Optional: Domain name for HTTPS
|
||||
|
||||
## 🔍 What It Does
|
||||
|
||||
The script automatically:
|
||||
|
||||
1. Verifies Docker is installed and running
|
||||
2. Downloads `docker-compose.yml` and `.env`
|
||||
3. Prompts you for domain and port settings
|
||||
4. Waits for services to start
|
||||
5. Prints success info with next steps
|
||||
|
||||
## 🧼 Uninstall
|
||||
|
||||
To remove everything:
|
||||
|
||||
```bash
|
||||
cd adventurelog
|
||||
docker compose down -v
|
||||
rm -rf adventurelog
|
||||
```
|
||||
|
||||
Need more control? Explore other [install options](getting_started.md) like Docker, Proxmox, Synology NAS, and more.
|
|
@ -27,4 +27,6 @@ AdventureLog is open-source software, licensed under the GPL-3.0 license. This m
|
|||
|
||||
## About the Maintainer
|
||||
|
||||
AdventureLog is created and maintained by [Sean Morley](https://seanmorley.com), a Computer Science student at the University of Connecticut. Sean is passionate about open-source software and building modern tools that help people solve real-world problems.
|
||||
Hi, I'm [Sean Morley](https://seanmorley.com), the creator of AdventureLog. I'm an Electrical Engineering student at the University of Connecticut, and I'm passionate about open-source software and building modern tools that help people solve real-world problems. I created AdventureLog to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone.
|
||||
|
||||
I hope you enjoy using AdventureLog as much as I enjoy creating it! If you have any questions, feedback, or suggestions, feel free to reach out to me via the email address listed on my website. I'm always happy to hear from users and help in any way I can. Thank you for using AdventureLog, and happy travels! 🌍
|
||||
|
|
18
documentation/docs/troubleshooting/login_unresponsive.md
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Troubleshooting: Login and Registration Unresponsive
|
||||
|
||||
When you encounter issues with the login and registration pages being unresponsive in AdventureLog, it can be due to various reasons. This guide will help you troubleshoot and resolve the unresponsive login and registration pages in AdventureLog.
|
||||
|
||||
1. Check to make sure the backend container is running and accessible.
|
||||
|
||||
- Check the backend container logs to see if there are any errors or issues blocking the container from running.
|
||||
2. Check the connection between the frontend and backend containers.
|
||||
|
||||
- Attempt login with the browser console network tab open to see if there are any errors or issues with the connection between the frontend and backend containers. If there is a connection issue, the code will show an error like `Failed to load resource: net::ERR_CONNECTION_REFUSED`. If this is the case, check the `PUBLIC_SERVER_URL` in the frontend container and refer to the installation docs to ensure the correct URL is set.
|
||||
- If the error is `403`, continue to the next step.
|
||||
|
||||
3. The error most likely is due to a CSRF security config issue in either the backend or frontend.
|
||||
|
||||
- Check that the `ORIGIN` variable in the frontend is set to the URL where the frontend is access and you are accessing the app from currently.
|
||||
- Check that the `CSRF_TRUSTED_ORIGINS` variable in the backend is set to a comma separated list of the origins where you use your backend server and frontend. One of these values should match the `ORIGIN` variable in the frontend.
|
||||
|
||||
4. If you are still experiencing issues, please refer to the [AdventureLog Discord Server](https://discord.gg/wRbQ9Egr8C) for further assistance, providing as much detail as possible about the issue you are experiencing!
|
33
documentation/docs/usage/usage.md
Normal file
|
@ -0,0 +1,33 @@
|
|||
# How to use AdventureLog
|
||||
|
||||
Welcome to AdventureLog! This guide will help you get started with AdventureLog and provide you with an overview of the features available to you.
|
||||
|
||||
## Key Terms
|
||||
|
||||
#### Adventures
|
||||
|
||||
- **Adventure**: think of an adventure as a point on a map, a location you want to visit, or a place you want to explore. An adventure can be anything you want it to be, from a local park to a famous landmark.
|
||||
- **Visit**: a visit is added to an adventure. It contains a date and notes about when the adventure was visited. If an adventure is visited multiple times, multiple visits can be added. If there are no visits on an adventure or the date of all visits is in the future, the adventure is considered planned. If the date of the visit is in the past, the adventure is considered completed.
|
||||
- **Category**: a category is a way to group adventures together. For example, you could have a category for parks, a category for museums, and a category for restaurants.
|
||||
- **Tag**: a tag is a way to add additional information to an adventure. For example, you could have a tag for the type of cuisine at a restaurant or the type of art at a museum. Multiple tags can be added to an adventure.
|
||||
- **Image**: an image is a photo that is added to an adventure. Images can be added to an adventure to provide a visual representation of the location or to capture a memory of the visit. These can be uploaded from your device or with a service like [Immich](/docs/configuration/immich_integration) if the integration is enabled.
|
||||
- **Attachment**: an attachment is a file that is added to an adventure. Attachments can be added to an adventure to provide additional information, such as a map of the location or a brochure from the visit.
|
||||
|
||||
#### Collections
|
||||
|
||||
- **Collection**: a collection is a way to group adventures together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group adventures together. When a start and end date is added to a collection, it acts like a trip to group adventures together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a map showing the route taken between adventures.
|
||||
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
||||
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time.
|
||||
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
||||
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
|
||||
|
||||
#### World Travel
|
||||
|
||||
- **World Travel**: the world travel feature of AdventureLog allows you to track the countries, regions, and cities you have visited during your lifetime. You can add visits to countries, regions, and cities, and view statistics about your travels. The world travel feature is a fun way to visualize where you have been and where you want to go next.
|
||||
- **Country**: a country is a geographical area that is recognized as an independent nation. You can add visits to countries to track where you have been.
|
||||
- **Region**: a region is a geographical area that is part of a country. You can add visits to regions to track where you have been within a country.
|
||||
- **City**: a city is a geographical area that is a populated urban center. You can add visits to cities to track where you have been within a region.
|
||||
|
||||
## Tutorial Video
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/4Y2LvxG3xn4" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
|
@ -31,3 +31,265 @@ features:
|
|||
details: "Share your adventures with friends and family and collaborate on trips together."
|
||||
icon: 📸
|
||||
---
|
||||
|
||||
## ⚡️ Quick Start
|
||||
|
||||
Get AdventureLog running in under 60 seconds:
|
||||
|
||||
```bash [One-Line Install]
|
||||
curl -sSL https://get.adventurelog.app | bash
|
||||
```
|
||||
|
||||
You can also explore our [full installation guide](/docs/install/getting_started) for plenty of options, including Docker, Proxmox, Synology NAS, and more.
|
||||
|
||||
## 📸 See It In Action
|
||||
|
||||
::: details 🗂️ **Adventure Overview & Management**
|
||||
Manage your full list of adventures with ease. View upcoming and past trips, filter and sort by status, date, or category to find exactly what you want quickly.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/adventures.png" alt="Adventure Overview" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details 📋 **Detailed Adventure Logs**
|
||||
Capture rich details for every adventure: name, dates, precise locations, vivid descriptions, personal ratings, photos, and customizable categories. Your memories deserve to be more than just map pins — keep them alive with full, organized logs.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/details.png" alt="Detailed Adventure Logs" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details 🗺️ **Interactive World Map**
|
||||
Track every destination you’ve visited or plan to visit with our beautifully detailed, interactive world map. Easily filter locations by visit status — visitedor planned — and add new adventures by simply clicking on the map. Watch your travel story unfold visually as your journey grows.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/map.png" alt="Interactive World Map" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details ✈️ **Comprehensive Trip Planning**
|
||||
Organize your multi-day trips with detailed itineraries, including flight information, daily activities, collaborative notes, packing checklists, and handy resource links. Stay on top of your plans and ensure every adventure runs smoothly.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/itinerary.png" alt="Comprehensive Trip Planning" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details 📊 **Travel Statistics Dashboard**
|
||||
Unlock insights into your travel habits and milestones through elegant, easy-to-understand analytics. Track total countries visited, regions explored, cities logged, and more. Visualize your world travels with ease and celebrate your achievements.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/dashboard.png" alt="Travel Statistics Dashboard" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details ✏️ **Edit & Customize Adventures**
|
||||
Make quick updates or deep customizations to any adventure using a clean and intuitive editing interface. Add photos, update notes, adjust dates, and more—keeping your records accurate and personal.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/edit.png" alt="Edit Adventure Modal" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
::: details 🌍 **Countries & Regions Explorer**
|
||||
Explore and manage the countries you’ve visited or plan to visit with an organized list, filtering by visit status. Dive deeper into each country’s regions, complete with interactive maps to help you visually select and track your regional travels.
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/countries.png" alt="Countries List" style="max-width:100%; margin-top:10px;" />
|
||||
<img src="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/screenshots/regions.png" alt="Regions Explorer" style="max-width:100%; margin-top:10px;" />
|
||||
:::
|
||||
|
||||
## 💬 What People Are Saying
|
||||
|
||||
::: details ✈️ **XDA Travel Week Reviews**
|
||||
|
||||
> “I stumbled upon AdventureLog. It's an open-source, self-hosted travel planner that's completely free to use and has a bunch of cool features that make it a treat to plan, organize, and log your journey across the world. Safe to say, it's become a mainstay in Docker for me.”
|
||||
>
|
||||
> — _Sumukh Rao, Senior Author at XDA_
|
||||
|
||||
[Article Link](https://www.xda-developers.com/i-self-hosted-this-app-to-plan-itinerary-when-traveling/)
|
||||
|
||||
:::
|
||||
|
||||
::: details 🧳 **Rich Edmonds, XDA**
|
||||
|
||||
**Overall Ranking: #1**
|
||||
|
||||
> “The most important part of travelling in this socially connected world is to log everything and showcase all of your adventures. AdventureLog is aptly named, as it allows you to do just that. It just so happens to be one of the best apps for the job and can be fully self-hosted at home.”
|
||||
>
|
||||
> — _Rich Edmonds, Lead PC Hardware Editor at XDA_
|
||||
|
||||
[Article Link](https://www.xda-developers.com/these-self-hosted-apps-are-perfect-for-those-on-the-go/)
|
||||
|
||||
:::
|
||||
|
||||
::: details 📆 **Open Source Daily**
|
||||
|
||||
> “Your travel memories are your personal treasures—don’t let them be held hostage by closed platforms, hidden fees, or privacy risks. AdventureLog represents a new era of travel tracking: open, private, comprehensive, and truly yours. Whether you’re a casual traveler, digital nomad, family vacation planner, or anyone who values their adventures, AdventureLog offers a compelling alternative that puts you back in control.”
|
||||
>
|
||||
> — _Open Source Daily_
|
||||
|
||||
[Article Link](https://opensourcedaily.blog/adventurelog-private-open-source-travel-tracking-trip-planning/)
|
||||
|
||||
:::
|
||||
|
||||
## 🏗️ Built With Excellence
|
||||
|
||||
<div class="tech-stack">
|
||||
|
||||
<div class="tech-card">
|
||||
|
||||
### **Frontend Excellence**
|
||||
|
||||
- 🎨 **SvelteKit** - Lightning-fast, modern web framework
|
||||
- 💨 **TailwindCSS** - Utility-first styling for beautiful designs
|
||||
- 🎭 **DaisyUI** - Beautiful, accessible component library
|
||||
- 🗺️ **MapLibre** - Interactive, customizable mapping
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tech-card">
|
||||
|
||||
### **Backend Power**
|
||||
|
||||
- 🐍 **Django** - Robust, scalable web framework
|
||||
- 🗺️ **PostGIS** - Advanced geospatial database capabilities
|
||||
- 🔌 **Django REST** - Modern API architecture
|
||||
- 🔐 **AllAuth** - Comprehensive authentication system
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
## 🌟 Join the Adventure
|
||||
|
||||
<div class="community-stats">
|
||||
|
||||
<div class="community-card">
|
||||
|
||||
### 🎯 **Active Development**
|
||||
|
||||
Regular updates, new features, and community-driven improvements keep AdventureLog at the forefront of travel technology.
|
||||
|
||||
</div>
|
||||
|
||||
<div class="community-card">
|
||||
|
||||
### 💬 **Thriving Community**
|
||||
|
||||
Join thousands of travelers sharing tips, contributing code, and building the future of travel documentation together.
|
||||
|
||||
</div>
|
||||
|
||||
<div class="community-card">
|
||||
|
||||
### 🚀 **Open Source Freedom**
|
||||
|
||||
GPL 3.0 licensed, fully transparent, and built for the community. By travelers, for travelers.
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
## 💖 Support the Project
|
||||
|
||||
AdventureLog is lovingly maintained by passionate developers and supported by amazing users like you:
|
||||
|
||||
- ⭐ [Star us on GitHub](https://github.com/seanmorley15/AdventureLog)
|
||||
- 💬 [Join our Discord community](https://discord.gg/wRbQ9Egr8C)
|
||||
- 💖 [Sponsor The Project](https://seanmorley.com/sponsor) to help us keep improving AdventureLog
|
||||
- 🐛 [Report bugs & request features](https://github.com/seanmorley15/AdventureLog/issues)
|
||||
|
||||
---
|
||||
|
||||
<div class="footer-cta">
|
||||
|
||||
### Ready to Transform Your Travel Experience?
|
||||
|
||||
Stop letting amazing adventures fade from memory. Start documenting, planning, and sharing your travel story today.
|
||||
|
||||
[**🚀 Get Started Now**](/docs/install/getting_started) • [**📱 Try the Demo**](https://demo.adventurelog.app) • [**📚 Read the Docs**](/docs/intro/adventurelog_overview)
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.why-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.why-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.why-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.why-card h3 {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tech-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.tech-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tech-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tech-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.community-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.community-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.community-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.community-card h3 {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.footer-cta {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
margin: 3rem 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--vp-c-brand-soft) 0%, var(--vp-c-brand-softer) 100%);
|
||||
border: 1px solid var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.footer-cta h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.footer-cta p {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
details img {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.5.0"
|
||||
"vitepress": "^1.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
|
|
326
documentation/pnpm-lock.yaml
generated
|
@ -16,8 +16,8 @@ importers:
|
|||
version: 3.5.13
|
||||
devDependencies:
|
||||
vitepress:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0(@algolia/client-search@5.15.0)(postcss@8.4.49)(search-insights@2.17.3)
|
||||
specifier: ^1.6.3
|
||||
version: 1.6.3(@algolia/client-search@5.15.0)(postcss@8.4.49)(search-insights@2.17.3)
|
||||
|
||||
packages:
|
||||
|
||||
|
@ -110,14 +110,14 @@ packages:
|
|||
resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@docsearch/css@3.8.0':
|
||||
resolution: {integrity: sha512-pieeipSOW4sQ0+bE5UFC51AOZp9NGxg89wAlZ1BAQFaiRAGK1IKUaPQ0UGZeNctJXyqZ1UvBtOQh2HH+U5GtmA==}
|
||||
'@docsearch/css@3.8.2':
|
||||
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
||||
|
||||
'@docsearch/js@3.8.0':
|
||||
resolution: {integrity: sha512-PVuV629f5UcYRtBWqK7ID6vNL5647+2ADJypwTjfeBIrJfwPuHtzLy39hMGMfFK+0xgRyhTR0FZ83EkdEraBlg==}
|
||||
'@docsearch/js@3.8.2':
|
||||
resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==}
|
||||
|
||||
'@docsearch/react@3.8.0':
|
||||
resolution: {integrity: sha512-WnFK720+iwTVt94CxY3u+FgX6exb3BfN5kE9xUY6uuAH/9W/UFboBZFLlrw/zxFRHoHZCOXRtOylsXF+6LHI+Q==}
|
||||
'@docsearch/react@3.8.2':
|
||||
resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==}
|
||||
peerDependencies:
|
||||
'@types/react': '>= 16.8.0 < 19.0.0'
|
||||
react: '>= 16.8.0 < 19.0.0'
|
||||
|
@ -271,8 +271,8 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@iconify-json/simple-icons@1.2.12':
|
||||
resolution: {integrity: sha512-lRNORrIdeLStShxAjN6FgXE1iMkaAgiAHZdP0P0GZecX91FVYW58uZnRSlXLlSx5cxMoELulkAAixybPA2g52g==}
|
||||
'@iconify-json/simple-icons@1.2.37':
|
||||
resolution: {integrity: sha512-jZwTBznpYVDYKWyAuRpepPpCiHScVrX6f8WRX8ReX6pdii99LYVHwJywKcH2excWQrWmBomC9nkxGlEKzXZ/wQ==}
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
|
@ -370,23 +370,29 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@shikijs/core@1.23.1':
|
||||
resolution: {integrity: sha512-NuOVgwcHgVC6jBVH5V7iblziw6iQbWWHrj5IlZI3Fqu2yx9awH7OIQkXIcsHsUmY19ckwSgUMgrqExEyP5A0TA==}
|
||||
'@shikijs/core@2.5.0':
|
||||
resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
|
||||
|
||||
'@shikijs/engine-javascript@1.23.1':
|
||||
resolution: {integrity: sha512-i/LdEwT5k3FVu07SiApRFwRcSJs5QM9+tod5vYCPig1Ywi8GR30zcujbxGQFJHwYD7A5BUqagi8o5KS+LEVgBg==}
|
||||
'@shikijs/engine-javascript@2.5.0':
|
||||
resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
|
||||
|
||||
'@shikijs/engine-oniguruma@1.23.1':
|
||||
resolution: {integrity: sha512-KQ+lgeJJ5m2ISbUZudLR1qHeH3MnSs2mjFg7bnencgs5jDVPeJ2NVDJ3N5ZHbcTsOIh0qIueyAJnwg7lg7kwXQ==}
|
||||
'@shikijs/engine-oniguruma@2.5.0':
|
||||
resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==}
|
||||
|
||||
'@shikijs/transformers@1.23.1':
|
||||
resolution: {integrity: sha512-yQ2Cn0M9i46p30KwbyIzLvKDk+dQNU+lj88RGO0XEj54Hn4Cof1bZoDb9xBRWxFE4R8nmK63w7oHnJwvOtt0NQ==}
|
||||
'@shikijs/langs@2.5.0':
|
||||
resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==}
|
||||
|
||||
'@shikijs/types@1.23.1':
|
||||
resolution: {integrity: sha512-98A5hGyEhzzAgQh2dAeHKrWW4HfCMeoFER2z16p5eJ+vmPeF6lZ/elEne6/UCU551F/WqkopqRsr1l2Yu6+A0g==}
|
||||
'@shikijs/themes@2.5.0':
|
||||
resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
|
||||
|
||||
'@shikijs/vscode-textmate@9.3.0':
|
||||
resolution: {integrity: sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==}
|
||||
'@shikijs/transformers@2.5.0':
|
||||
resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
|
||||
|
||||
'@shikijs/types@2.5.0':
|
||||
resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@types/estree@1.0.6':
|
||||
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
|
||||
|
@ -409,17 +415,17 @@ packages:
|
|||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@ungap/structured-clone@1.2.0':
|
||||
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.0':
|
||||
resolution: {integrity: sha512-7n7KdUEtx/7Yl7I/WVAMZ1bEb0eVvXF3ummWTeLcs/9gvo9pJhuLdouSXGjdZ/MKD1acf1I272+X0RMua4/R3g==}
|
||||
'@vitejs/plugin-vue@5.2.4':
|
||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0
|
||||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
|
@ -434,14 +440,14 @@ packages:
|
|||
'@vue/compiler-ssr@3.5.13':
|
||||
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
|
||||
|
||||
'@vue/devtools-api@7.6.4':
|
||||
resolution: {integrity: sha512-5AaJ5ELBIuevmFMZYYLuOO9HUuY/6OlkOELHE7oeDhy4XD/hSODIzktlsvBOsn+bto3aD0psj36LGzwVu5Ip8w==}
|
||||
'@vue/devtools-api@7.7.6':
|
||||
resolution: {integrity: sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==}
|
||||
|
||||
'@vue/devtools-kit@7.6.4':
|
||||
resolution: {integrity: sha512-Zs86qIXXM9icU0PiGY09PQCle4TI750IPLmAJzW5Kf9n9t5HzSYf6Rz6fyzSwmfMPiR51SUKJh9sXVZu78h2QA==}
|
||||
'@vue/devtools-kit@7.7.6':
|
||||
resolution: {integrity: sha512-geu7ds7tem2Y7Wz+WgbnbZ6T5eadOvozHZ23Atk/8tksHMFOFylKi1xgGlQlVn0wlkEf4hu+vd5ctj1G4kFtwA==}
|
||||
|
||||
'@vue/devtools-shared@7.6.4':
|
||||
resolution: {integrity: sha512-nD6CUvBEel+y7zpyorjiUocy0nh77DThZJ0k1GRnJeOmY3ATq2fWijEp7wk37gb023Cb0R396uYh5qMSBQ5WFg==}
|
||||
'@vue/devtools-shared@7.7.6':
|
||||
resolution: {integrity: sha512-yFEgJZ/WblEsojQQceuyK6FzpFDx4kqrz2ohInxNj5/DnhoX023upTv4OD6lNPLAA5LLkbwPVb10o/7b+Y4FVA==}
|
||||
|
||||
'@vue/reactivity@3.5.13':
|
||||
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
|
||||
|
@ -460,11 +466,11 @@ packages:
|
|||
'@vue/shared@3.5.13':
|
||||
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
|
||||
|
||||
'@vueuse/core@11.3.0':
|
||||
resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==}
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/integrations@11.3.0':
|
||||
resolution: {integrity: sha512-5fzRl0apQWrDezmobchoiGTkGw238VWESxZHazfhP3RM7pDSiyXy18QbfYkILoYNTd23HPAfQTJpkUc5QbkwTw==}
|
||||
'@vueuse/integrations@12.8.2':
|
||||
resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==}
|
||||
peerDependencies:
|
||||
async-validator: ^4
|
||||
axios: ^1
|
||||
|
@ -504,18 +510,18 @@ packages:
|
|||
universal-cookie:
|
||||
optional: true
|
||||
|
||||
'@vueuse/metadata@11.3.0':
|
||||
resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==}
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/shared@11.3.0':
|
||||
resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==}
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
algoliasearch@5.15.0:
|
||||
resolution: {integrity: sha512-Yf3Swz1s63hjvBVZ/9f2P1Uu48GjmjCN+Esxb6MAONMGtZB1fRX8/S1AhUTtsuTlcGovbYLxpHgc7wEzstDZBw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
birpc@0.2.19:
|
||||
resolution: {integrity: sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==}
|
||||
birpc@2.3.0:
|
||||
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
@ -558,16 +564,16 @@ packages:
|
|||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
focus-trap@7.6.2:
|
||||
resolution: {integrity: sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==}
|
||||
focus-trap@7.6.5:
|
||||
resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
hast-util-to-html@9.0.3:
|
||||
resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==}
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
@ -617,8 +623,8 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
oniguruma-to-es@0.4.1:
|
||||
resolution: {integrity: sha512-rNcEohFz095QKGRovP/yqPIKc+nP+Sjs4YTHMv33nMePGKrq/r2eu9Yh4646M5XluGJsUnmwoXuiXE69KDs+fQ==}
|
||||
oniguruma-to-es@3.1.1:
|
||||
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
@ -638,17 +644,17 @@ packages:
|
|||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
property-information@6.5.0:
|
||||
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
regex-recursion@4.2.1:
|
||||
resolution: {integrity: sha512-QHNZyZAeKdndD1G3bKAbBEKOSSK4KOHQrAJ01N1LJeb0SoH4DJIeFhp0uUpETgONifS4+P3sOgoA1dhzgrQvhA==}
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@5.0.2:
|
||||
resolution: {integrity: sha512-/pczGbKIQgfTMRV0XjABvc5RzLqQmwqxLHdQao2RTXPk+pmTXB2P0IaUHYdYyk412YLwUIkaeMd5T+RzVgTqnQ==}
|
||||
regex@6.0.1:
|
||||
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
@ -661,8 +667,8 @@ packages:
|
|||
search-insights@2.17.3:
|
||||
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
|
||||
|
||||
shiki@1.23.1:
|
||||
resolution: {integrity: sha512-8kxV9TH4pXgdKGxNOkrSMydn1Xf6It8lsle0fiqxf7a1149K1WGtdOu3Zb91T5r1JpvRPxqxU3C2XdZZXQnrig==}
|
||||
shiki@2.5.0:
|
||||
resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
|
@ -678,8 +684,8 @@ packages:
|
|||
stringify-entities@4.0.4:
|
||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||
|
||||
superjson@2.2.1:
|
||||
resolution: {integrity: sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==}
|
||||
superjson@2.2.2:
|
||||
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tabbable@6.2.0:
|
||||
|
@ -709,8 +715,8 @@ packages:
|
|||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@5.4.14:
|
||||
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
|
||||
vite@5.4.19:
|
||||
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
@ -740,8 +746,8 @@ packages:
|
|||
terser:
|
||||
optional: true
|
||||
|
||||
vitepress@1.5.0:
|
||||
resolution: {integrity: sha512-q4Q/G2zjvynvizdB3/bupdYkCJe2umSAMv9Ju4d92E6/NXJ59z70xB0q5p/4lpRyAwflDsbwy1mLV9Q5+nlB+g==}
|
||||
vitepress@1.6.3:
|
||||
resolution: {integrity: sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
markdown-it-mathjax3: ^4
|
||||
|
@ -752,17 +758,6 @@ packages:
|
|||
postcss:
|
||||
optional: true
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue@3.5.13:
|
||||
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
|
||||
peerDependencies:
|
||||
|
@ -894,11 +889,11 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
|
||||
'@docsearch/css@3.8.0': {}
|
||||
'@docsearch/css@3.8.2': {}
|
||||
|
||||
'@docsearch/js@3.8.0(@algolia/client-search@5.15.0)(search-insights@2.17.3)':
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.15.0)(search-insights@2.17.3)':
|
||||
dependencies:
|
||||
'@docsearch/react': 3.8.0(@algolia/client-search@5.15.0)(search-insights@2.17.3)
|
||||
'@docsearch/react': 3.8.2(@algolia/client-search@5.15.0)(search-insights@2.17.3)
|
||||
preact: 10.25.0
|
||||
transitivePeerDependencies:
|
||||
- '@algolia/client-search'
|
||||
|
@ -907,11 +902,11 @@ snapshots:
|
|||
- react-dom
|
||||
- search-insights
|
||||
|
||||
'@docsearch/react@3.8.0(@algolia/client-search@5.15.0)(search-insights@2.17.3)':
|
||||
'@docsearch/react@3.8.2(@algolia/client-search@5.15.0)(search-insights@2.17.3)':
|
||||
dependencies:
|
||||
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)(search-insights@2.17.3)
|
||||
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.15.0)(algoliasearch@5.15.0)
|
||||
'@docsearch/css': 3.8.0
|
||||
'@docsearch/css': 3.8.2
|
||||
algoliasearch: 5.15.0
|
||||
optionalDependencies:
|
||||
search-insights: 2.17.3
|
||||
|
@ -987,7 +982,7 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@iconify-json/simple-icons@1.2.12':
|
||||
'@iconify-json/simple-icons@1.2.37':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
|
@ -1049,36 +1044,45 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.27.4':
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@1.23.1':
|
||||
'@shikijs/core@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/engine-javascript': 1.23.1
|
||||
'@shikijs/engine-oniguruma': 1.23.1
|
||||
'@shikijs/types': 1.23.1
|
||||
'@shikijs/vscode-textmate': 9.3.0
|
||||
'@shikijs/engine-javascript': 2.5.0
|
||||
'@shikijs/engine-oniguruma': 2.5.0
|
||||
'@shikijs/types': 2.5.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.3
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@1.23.1':
|
||||
'@shikijs/engine-javascript@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.23.1
|
||||
'@shikijs/vscode-textmate': 9.3.0
|
||||
oniguruma-to-es: 0.4.1
|
||||
'@shikijs/types': 2.5.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 3.1.1
|
||||
|
||||
'@shikijs/engine-oniguruma@1.23.1':
|
||||
'@shikijs/engine-oniguruma@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.23.1
|
||||
'@shikijs/vscode-textmate': 9.3.0
|
||||
'@shikijs/types': 2.5.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/transformers@1.23.1':
|
||||
'@shikijs/langs@2.5.0':
|
||||
dependencies:
|
||||
shiki: 1.23.1
|
||||
'@shikijs/types': 2.5.0
|
||||
|
||||
'@shikijs/types@1.23.1':
|
||||
'@shikijs/themes@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 9.3.0
|
||||
'@shikijs/types': 2.5.0
|
||||
|
||||
'@shikijs/transformers@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/core': 2.5.0
|
||||
'@shikijs/types': 2.5.0
|
||||
|
||||
'@shikijs/types@2.5.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@9.3.0': {}
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@types/estree@1.0.6': {}
|
||||
|
||||
|
@ -1101,13 +1105,13 @@ snapshots:
|
|||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@ungap/structured-clone@1.2.0': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.0(vite@5.4.14)(vue@3.5.13)':
|
||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.19)(vue@3.5.13)':
|
||||
dependencies:
|
||||
vite: 5.4.14
|
||||
vite: 5.4.19
|
||||
vue: 3.5.13
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
|
@ -1140,21 +1144,21 @@ snapshots:
|
|||
'@vue/compiler-dom': 3.5.13
|
||||
'@vue/shared': 3.5.13
|
||||
|
||||
'@vue/devtools-api@7.6.4':
|
||||
'@vue/devtools-api@7.7.6':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.6.4
|
||||
'@vue/devtools-kit': 7.7.6
|
||||
|
||||
'@vue/devtools-kit@7.6.4':
|
||||
'@vue/devtools-kit@7.7.6':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.6.4
|
||||
birpc: 0.2.19
|
||||
'@vue/devtools-shared': 7.7.6
|
||||
birpc: 2.3.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.1
|
||||
superjson: 2.2.2
|
||||
|
||||
'@vue/devtools-shared@7.6.4':
|
||||
'@vue/devtools-shared@7.7.6':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
|
@ -1182,35 +1186,32 @@ snapshots:
|
|||
|
||||
'@vue/shared@3.5.13': {}
|
||||
|
||||
'@vueuse/core@11.3.0(vue@3.5.13)':
|
||||
'@vueuse/core@12.8.2':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 11.3.0
|
||||
'@vueuse/shared': 11.3.0(vue@3.5.13)
|
||||
vue-demi: 0.14.10(vue@3.5.13)
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2
|
||||
vue: 3.5.13
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
- typescript
|
||||
|
||||
'@vueuse/integrations@11.3.0(focus-trap@7.6.2)(vue@3.5.13)':
|
||||
'@vueuse/integrations@12.8.2(focus-trap@7.6.5)':
|
||||
dependencies:
|
||||
'@vueuse/core': 11.3.0(vue@3.5.13)
|
||||
'@vueuse/shared': 11.3.0(vue@3.5.13)
|
||||
vue-demi: 0.14.10(vue@3.5.13)
|
||||
'@vueuse/core': 12.8.2
|
||||
'@vueuse/shared': 12.8.2
|
||||
vue: 3.5.13
|
||||
optionalDependencies:
|
||||
focus-trap: 7.6.2
|
||||
focus-trap: 7.6.5
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@11.3.0': {}
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/shared@11.3.0(vue@3.5.13)':
|
||||
'@vueuse/shared@12.8.2':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13)
|
||||
vue: 3.5.13
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
- typescript
|
||||
|
||||
algoliasearch@5.15.0:
|
||||
dependencies:
|
||||
|
@ -1228,7 +1229,7 @@ snapshots:
|
|||
'@algolia/requester-fetch': 5.15.0
|
||||
'@algolia/requester-node-http': 5.15.0
|
||||
|
||||
birpc@0.2.19: {}
|
||||
birpc@2.3.0: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
|
@ -1282,14 +1283,14 @@ snapshots:
|
|||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
focus-trap@7.6.2:
|
||||
focus-trap@7.6.5:
|
||||
dependencies:
|
||||
tabbable: 6.2.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
hast-util-to-html@9.0.3:
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
|
@ -1298,7 +1299,7 @@ snapshots:
|
|||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.0
|
||||
property-information: 6.5.0
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
@ -1354,11 +1355,11 @@ snapshots:
|
|||
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
oniguruma-to-es@0.4.1:
|
||||
oniguruma-to-es@3.1.1:
|
||||
dependencies:
|
||||
emoji-regex-xs: 1.0.0
|
||||
regex: 5.0.2
|
||||
regex-recursion: 4.2.1
|
||||
regex: 6.0.1
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
|
@ -1374,15 +1375,15 @@ snapshots:
|
|||
|
||||
prettier@3.3.3: {}
|
||||
|
||||
property-information@6.5.0: {}
|
||||
property-information@7.1.0: {}
|
||||
|
||||
regex-recursion@4.2.1:
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@5.0.2:
|
||||
regex@6.0.1:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
|
@ -1414,13 +1415,15 @@ snapshots:
|
|||
|
||||
search-insights@2.17.3: {}
|
||||
|
||||
shiki@1.23.1:
|
||||
shiki@2.5.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 1.23.1
|
||||
'@shikijs/engine-javascript': 1.23.1
|
||||
'@shikijs/engine-oniguruma': 1.23.1
|
||||
'@shikijs/types': 1.23.1
|
||||
'@shikijs/vscode-textmate': 9.3.0
|
||||
'@shikijs/core': 2.5.0
|
||||
'@shikijs/engine-javascript': 2.5.0
|
||||
'@shikijs/engine-oniguruma': 2.5.0
|
||||
'@shikijs/langs': 2.5.0
|
||||
'@shikijs/themes': 2.5.0
|
||||
'@shikijs/types': 2.5.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
@ -1434,7 +1437,7 @@ snapshots:
|
|||
character-entities-html4: 2.1.0
|
||||
character-entities-legacy: 3.0.0
|
||||
|
||||
superjson@2.2.1:
|
||||
superjson@2.2.2:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
|
@ -1475,7 +1478,7 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite@5.4.14:
|
||||
vite@5.4.19:
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.49
|
||||
|
@ -1483,25 +1486,25 @@ snapshots:
|
|||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitepress@1.5.0(@algolia/client-search@5.15.0)(postcss@8.4.49)(search-insights@2.17.3):
|
||||
vitepress@1.6.3(@algolia/client-search@5.15.0)(postcss@8.4.49)(search-insights@2.17.3):
|
||||
dependencies:
|
||||
'@docsearch/css': 3.8.0
|
||||
'@docsearch/js': 3.8.0(@algolia/client-search@5.15.0)(search-insights@2.17.3)
|
||||
'@iconify-json/simple-icons': 1.2.12
|
||||
'@shikijs/core': 1.23.1
|
||||
'@shikijs/transformers': 1.23.1
|
||||
'@shikijs/types': 1.23.1
|
||||
'@docsearch/css': 3.8.2
|
||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.15.0)(search-insights@2.17.3)
|
||||
'@iconify-json/simple-icons': 1.2.37
|
||||
'@shikijs/core': 2.5.0
|
||||
'@shikijs/transformers': 2.5.0
|
||||
'@shikijs/types': 2.5.0
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 5.2.0(vite@5.4.14)(vue@3.5.13)
|
||||
'@vue/devtools-api': 7.6.4
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@5.4.19)(vue@3.5.13)
|
||||
'@vue/devtools-api': 7.7.6
|
||||
'@vue/shared': 3.5.13
|
||||
'@vueuse/core': 11.3.0(vue@3.5.13)
|
||||
'@vueuse/integrations': 11.3.0(focus-trap@7.6.2)(vue@3.5.13)
|
||||
focus-trap: 7.6.2
|
||||
'@vueuse/core': 12.8.2
|
||||
'@vueuse/integrations': 12.8.2(focus-trap@7.6.5)
|
||||
focus-trap: 7.6.5
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.1.1
|
||||
shiki: 1.23.1
|
||||
vite: 5.4.14
|
||||
shiki: 2.5.0
|
||||
vite: 5.4.19
|
||||
vue: 3.5.13
|
||||
optionalDependencies:
|
||||
postcss: 8.4.49
|
||||
|
@ -1509,7 +1512,6 @@ snapshots:
|
|||
- '@algolia/client-search'
|
||||
- '@types/node'
|
||||
- '@types/react'
|
||||
- '@vue/composition-api'
|
||||
- async-validator
|
||||
- axios
|
||||
- change-case
|
||||
|
@ -1533,10 +1535,6 @@ snapshots:
|
|||
- typescript
|
||||
- universal-cookie
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13):
|
||||
dependencies:
|
||||
vue: 3.5.13
|
||||
|
||||
vue@3.5.13:
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
|
|
|
@ -1,35 +1,49 @@
|
|||
# Use this image as the platform to build the app
|
||||
FROM node:18-alpine AS external-website
|
||||
FROM node:22-alpine AS external-website
|
||||
|
||||
# A small line inside the image to show who made it
|
||||
LABEL Developers="Sean Morley"
|
||||
# Metadata labels for the AdventureLog image
|
||||
LABEL maintainer="Sean Morley" \
|
||||
version="v0.10.0" \
|
||||
description="AdventureLog — the ultimate self-hosted travel companion." \
|
||||
org.opencontainers.image.title="AdventureLog" \
|
||||
org.opencontainers.image.description="AdventureLog is a self-hosted travel companion that helps you plan, track, and share your adventures." \
|
||||
org.opencontainers.image.version="v0.10.0" \
|
||||
org.opencontainers.image.authors="Sean Morley" \
|
||||
org.opencontainers.image.url="https://raw.githubusercontent.com/seanmorley15/AdventureLog/refs/heads/main/brand/banner.png" \
|
||||
org.opencontainers.image.source="https://github.com/seanmorley15/AdventureLog" \
|
||||
org.opencontainers.image.vendor="Sean Morley" \
|
||||
org.opencontainers.image.created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
||||
|
||||
# The WORKDIR instruction sets the working directory for everything that will happen next
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all local files into the image
|
||||
# Install pnpm globally first
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy package files first for better Docker layer caching
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Clean install all node modules using pnpm with frozen lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application files
|
||||
COPY . .
|
||||
|
||||
# Remove the development .env file if present
|
||||
RUN rm -f .env
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Clean install all node modules using pnpm
|
||||
RUN pnpm install
|
||||
|
||||
# Build SvelteKit app
|
||||
RUN pnpm run build
|
||||
|
||||
# Make startup script executable
|
||||
RUN chmod +x ./startup.sh
|
||||
|
||||
# Change to non-root user for security
|
||||
USER node:node
|
||||
|
||||
# Expose the port that the app is listening on
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the app
|
||||
RUN chmod +x ./startup.sh
|
||||
|
||||
# The USER instruction sets the user name to use as the default user for the remainder of the current stage
|
||||
USER node:node
|
||||
|
||||
# Run startup.sh instead of the default command
|
||||
CMD ["./startup.sh"]
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "adventurelog-frontend",
|
||||
"version": "0.8.0",
|
||||
"version": "0.10.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"django": "cd .. && cd backend/server && python3 manage.py runserver",
|
||||
|
@ -14,6 +14,7 @@
|
|||
"devDependencies": {
|
||||
"@event-calendar/core": "^3.7.1",
|
||||
"@event-calendar/day-grid": "^3.7.1",
|
||||
"@event-calendar/interaction": "^3.12.0",
|
||||
"@event-calendar/time-grid": "^3.7.1",
|
||||
"@iconify-json/mdi": "^1.1.67",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
|
@ -34,7 +35,7 @@
|
|||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.2",
|
||||
"unplugin-icons": "^0.19.0",
|
||||
"vite": "^5.4.12"
|
||||
"vite": "^5.4.19"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
@ -43,6 +44,7 @@
|
|||
"dompurify": "^3.2.4",
|
||||
"emoji-picker-element": "^1.26.0",
|
||||
"gsap": "^3.12.7",
|
||||
"luxon": "^3.6.1",
|
||||
"marked": "^15.0.4",
|
||||
"psl": "^1.15.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
|
2378
frontend/pnpm-lock.yaml
generated
1
frontend/src/lib/assets/google_maps.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg height="2500" viewBox="14.32 4.87961494 37.85626587 52.79038506" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m37.34 7.82c-1.68-.53-3.48-.82-5.34-.82-5.43 0-10.29 2.45-13.54 6.31l8.35 7.02z" fill="#1a73e8"/><path d="m18.46 13.31a17.615 17.615 0 0 0 -4.14 11.36c0 3.32.66 6.02 1.75 8.43l10.74-12.77z" fill="#ea4335"/><path d="m32 17.92a6.764 6.764 0 0 1 5.16 11.13l10.52-12.51a17.684 17.684 0 0 0 -10.35-8.71l-10.51 12.51a6.74 6.74 0 0 1 5.18-2.42" fill="#4285f4"/><path d="m32 31.44c-3.73 0-6.76-3.03-6.76-6.76a6.7 6.7 0 0 1 1.58-4.34l-10.75 12.77c1.84 4.07 4.89 7.34 8.03 11.46l13.06-15.52a6.752 6.752 0 0 1 -5.16 2.39" fill="#fbbc04"/><path d="m36.9 48.8c5.9-9.22 12.77-13.41 12.77-24.13 0-2.94-.72-5.71-1.99-8.15l-23.57 28.05c1 1.31 2.01 2.7 2.99 4.24 3.58 5.54 2.59 8.86 4.9 8.86s1.32-3.33 4.9-8.87" fill="#34a853"/></svg>
|
After Width: | Height: | Size: 843 B |
1
frontend/src/lib/assets/undraw_server_error.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="799.031" height="618.112" viewBox="0 0 799.031 618.112" xmlns:xlink="http://www.w3.org/1999/xlink" role="img" artist="Katerina Limpitsouni" source="https://undraw.co/"><g transform="translate(-893 -197)"><path d="M15.18,488.763c0,.872.478,1.573,1.073,1.573h535.1c.6,0,1.073-.7,1.073-1.573s-.478-1.573-1.073-1.573H16.253C15.658,487.191,15.18,487.891,15.18,488.763Z" transform="translate(1007.711 324.776)" fill="#ccc"/><rect width="19.105" height="3.371" transform="translate(1198.162 808.354)" fill="#b6b3c5"/><rect width="19.105" height="3.371" transform="translate(1367.295 808.917)" fill="#b6b3c5"/><path d="M352.955,370.945a27.529,27.529,0,0,1-54.321,0H229.146V521.536h193.3V370.945Z" transform="translate(966.721 287.378)" fill="#d6d6e3"/><rect width="193.296" height="5.242" transform="translate(1196.43 796.983)" fill="#090814"/><path d="M788.255,487.17H10.776A10.788,10.788,0,0,1,0,476.394V32.688A10.788,10.788,0,0,1,10.776,21.911H788.255a10.789,10.789,0,0,1,10.776,10.776V476.394a10.789,10.789,0,0,1-10.776,10.776Z" transform="translate(893 175.089)" fill="#090814"/><rect width="760.822" height="429.297" transform="translate(911.104 213.968)" fill="#fff"/><g transform="translate(20.477 16.308)"><path d="M604.463,379.271H317.442a8.655,8.655,0,0,1-8.645-8.645V273.8a8.655,8.655,0,0,1,8.645-8.645H604.463a8.655,8.655,0,0,1,8.645,8.645v96.826a8.655,8.655,0,0,1-8.645,8.645Z" transform="translate(811.648 85.826)" fill="#6c63ff"/><rect width="76.078" height="8.645" rx="2" transform="translate(1165.4 380.374)" fill="#fff"/><ellipse cx="5.187" cy="5.187" rx="5.187" ry="5.187" transform="translate(1336.576 380.374)" fill="#090814"/><ellipse cx="5.187" cy="5.187" rx="5.187" ry="5.187" transform="translate(1353.865 380.374)" fill="#090814"/><ellipse cx="5.187" cy="5.187" rx="5.187" ry="5.187" transform="translate(1371.156 380.374)" fill="#090814"/></g><ellipse cx="40.952" cy="40.952" rx="40.952" ry="40.952" transform="translate(1404.281 440.452)" fill="#090814"/><path d="M10.863-57.7l-.524-29.6h8.246l-.554,29.6Zm3.613,14.307a4.7,4.7,0,0,1-3.409-1.3,4.368,4.368,0,0,1-1.34-3.278,4.39,4.39,0,0,1,1.34-3.322,4.732,4.732,0,0,1,3.409-1.282,4.732,4.732,0,0,1,3.409,1.282,4.39,4.39,0,0,1,1.34,3.322,4.368,4.368,0,0,1-1.34,3.278A4.7,4.7,0,0,1,14.476-43.394Z" transform="translate(1430.76 546.754)" fill="#fff"/></g></svg>
|
After Width: | Height: | Size: 2.3 KiB |
|
@ -7,11 +7,19 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
onMount(() => {
|
||||
let integrations: Record<string, boolean> | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('about_modal') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
const response = await fetch('/api/integrations');
|
||||
if (response.ok) {
|
||||
integrations = await response.json();
|
||||
} else {
|
||||
integrations = null;
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
|
@ -90,18 +98,38 @@
|
|||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{$t('about.oss_attributions')}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('about.nominatim_1')}
|
||||
<a
|
||||
href="https://operations.osmfoundation.org/policies/nominatim/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
. {$t('about.nominatim_2')}
|
||||
</p>
|
||||
{#if integrations && integrations?.google_maps}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('about.nominatim_1')}
|
||||
<a
|
||||
href="https://developers.google.com/maps/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Google Maps
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{:else if integrations && !integrations?.google_maps}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('about.nominatim_1')}
|
||||
<a
|
||||
href="https://operations.osmfoundation.org/policies/nominatim/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
. {$t('about.nominatim_2')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{$t('about.generic_attributions')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -18,6 +18,10 @@
|
|||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
import CardCarousel from './CardCarousel.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Star from '~icons/mdi/star';
|
||||
import StarOutline from '~icons/mdi/star-outline';
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
|
||||
export let type: string | null = null;
|
||||
export let user: User | null;
|
||||
|
@ -28,15 +32,18 @@
|
|||
let isWarningModalOpen: boolean = false;
|
||||
|
||||
export let adventure: Adventure;
|
||||
let activityTypes: string[] = [];
|
||||
// makes it reactivty to changes so it updates automatically
|
||||
let displayActivityTypes: string[] = [];
|
||||
let remainingCount = 0;
|
||||
|
||||
// Process activity types for display
|
||||
$: {
|
||||
if (adventure.activity_types) {
|
||||
activityTypes = adventure.activity_types;
|
||||
if (activityTypes.length > 3) {
|
||||
activityTypes = activityTypes.slice(0, 3);
|
||||
let remaining = adventure.activity_types.length - 3;
|
||||
activityTypes.push('+' + remaining);
|
||||
if (adventure.activity_types.length <= 3) {
|
||||
displayActivityTypes = adventure.activity_types;
|
||||
remainingCount = 0;
|
||||
} else {
|
||||
displayActivityTypes = adventure.activity_types.slice(0, 3);
|
||||
remainingCount = adventure.activity_types.length - 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,18 +54,28 @@
|
|||
$: {
|
||||
if (collection && collection?.start_date && collection.end_date) {
|
||||
unlinked = adventure.visits.every((visit) => {
|
||||
// Check if visit dates exist
|
||||
if (!visit.start_date || !visit.end_date) return true; // Consider "unlinked" for incomplete visit data
|
||||
|
||||
// Check if collection dates are completely outside this visit's range
|
||||
if (!visit.start_date || !visit.end_date) return true;
|
||||
const isBeforeVisit = collection.end_date && collection.end_date < visit.start_date;
|
||||
const isAfterVisit = collection.start_date && collection.start_date > visit.end_date;
|
||||
|
||||
return isBeforeVisit || isAfterVisit;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for display
|
||||
function formatVisitCount() {
|
||||
const count = adventure.visits.length;
|
||||
return count > 1 ? `${count} ${$t('adventures.visits')}` : `${count} ${$t('adventures.visit')}`;
|
||||
}
|
||||
|
||||
function renderStars(rating: number) {
|
||||
const stars = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
stars.push(i <= rating);
|
||||
}
|
||||
return stars;
|
||||
}
|
||||
|
||||
async function deleteAdventure() {
|
||||
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||
method: 'DELETE'
|
||||
|
@ -71,38 +88,61 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function removeFromCollection() {
|
||||
async function linkCollection(event: CustomEvent<string>) {
|
||||
let collectionId = event.detail;
|
||||
// Create a copy to avoid modifying the original directly
|
||||
const updatedCollections = adventure.collections ? [...adventure.collections] : [];
|
||||
|
||||
// Add the new collection if not already present
|
||||
if (!updatedCollections.some((c) => String(c) === String(collectionId))) {
|
||||
updatedCollections.push(collectionId);
|
||||
}
|
||||
|
||||
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: null })
|
||||
body: JSON.stringify({ collections: updatedCollections })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
addToast('info', `${$t('adventures.collection_remove_success')}`);
|
||||
dispatch('delete', adventure.id);
|
||||
// Only update the adventure.collections after server confirms success
|
||||
adventure.collections = updatedCollections;
|
||||
addToast('info', `${$t('adventures.collection_link_success')}`);
|
||||
} else {
|
||||
addToast('error', `${$t('adventures.collection_remove_error')}`);
|
||||
addToast('error', `${$t('adventures.collection_link_error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkCollection(event: CustomEvent<number>) {
|
||||
async function removeFromCollection(event: CustomEvent<string>) {
|
||||
let collectionId = event.detail;
|
||||
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collection: collectionId })
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log('Adventure linked to collection');
|
||||
addToast('info', `${$t('adventures.collection_link_success')}`);
|
||||
isCollectionModalOpen = false;
|
||||
dispatch('delete', adventure.id);
|
||||
} else {
|
||||
addToast('error', `${$t('adventures.collection_link_error')}`);
|
||||
if (!collectionId) {
|
||||
addToast('error', `${$t('adventures.collection_remove_error')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a copy to avoid modifying the original directly
|
||||
if (adventure.collections) {
|
||||
const updatedCollections = adventure.collections.filter(
|
||||
(c) => String(c) !== String(collectionId)
|
||||
);
|
||||
|
||||
let res = await fetch(`/api/adventures/${adventure.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ collections: updatedCollections })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Only update adventure.collections after server confirms success
|
||||
adventure.collections = updatedCollections;
|
||||
addToast('info', `${$t('adventures.collection_remove_success')}`);
|
||||
} else {
|
||||
addToast('error', `${$t('adventures.collection_remove_error')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,7 +156,12 @@
|
|||
</script>
|
||||
|
||||
{#if isCollectionModalOpen}
|
||||
<CollectionLink on:link={linkCollection} on:close={() => (isCollectionModalOpen = false)} />
|
||||
<CollectionLink
|
||||
on:link={(e) => linkCollection(e)}
|
||||
on:unlink={(e) => removeFromCollection(e)}
|
||||
on:close={() => (isCollectionModalOpen = false)}
|
||||
linkedCollectionList={adventure.collections}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isWarningModalOpen}
|
||||
|
@ -131,119 +176,172 @@
|
|||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
|
||||
class="card w-full max-w-md bg-base-300 shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
|
||||
>
|
||||
<CardCarousel adventures={[adventure]} />
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
<CardCarousel adventures={[adventure]} />
|
||||
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
<!-- Status Overlay -->
|
||||
<div class="absolute top-4 left-4 flex flex-col gap-2">
|
||||
<div
|
||||
class="badge badge-sm {adventure.is_visited ? 'badge-success' : 'badge-warning'} shadow-lg"
|
||||
>
|
||||
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
|
||||
</div>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-sm badge-error shadow-lg">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-4 right-4">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
>
|
||||
<div
|
||||
class="btn btn-circle btn-sm btn-ghost bg-black/20 backdrop-blur-sm border-0 text-white"
|
||||
>
|
||||
{#if adventure.is_public}
|
||||
<Eye class="w-4 h-4" />
|
||||
{:else}
|
||||
<EyeOff class="w-4 h-4" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Badge -->
|
||||
{#if adventure.category}
|
||||
<div class="absolute bottom-4 left-4">
|
||||
<div class="badge badge-primary shadow-lg font-medium">
|
||||
{adventure.category.display_name}
|
||||
{adventure.category.icon}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<!-- Header Section -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
class="text-2xl font-semibold -mt-2 break-words text-wrap hover:underline text-left"
|
||||
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline"
|
||||
>
|
||||
{adventure.name}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="badge badge-primary">
|
||||
{adventure.category?.display_name + ' ' + adventure.category?.icon}
|
||||
</div>
|
||||
<div class="badge badge-success">
|
||||
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
|
||||
</div>
|
||||
<div class="badge badge-secondary">
|
||||
{adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
</div>
|
||||
</div>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
{#if adventure.location && adventure.location !== ''}
|
||||
<div class="inline-flex items-center">
|
||||
<MapMarker class="w-5 h-5 mr-1" />
|
||||
<p class="ml-.5">{adventure.location}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if adventure.visits.length > 0}
|
||||
<!-- visited badge -->
|
||||
<div class="flex items-center">
|
||||
<Calendar class="w-5 h-5 mr-1" />
|
||||
<p class="ml-.5">
|
||||
{adventure.visits.length}
|
||||
{adventure.visits.length > 1 ? $t('adventures.visits') : $t('adventures.visit')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if adventure.activity_types && adventure.activity_types.length > 0}
|
||||
<ul class="flex flex-wrap">
|
||||
{#each activityTypes as activity}
|
||||
<div class="badge badge-primary mr-1 text-md font-semibold pb-2 pt-1 mb-1">
|
||||
{activity}
|
||||
|
||||
<!-- Location -->
|
||||
{#if adventure.location}
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<MapMarker class="w-4 h-4 text-primary" />
|
||||
<span class="text-sm font-medium truncate">{adventure.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rating -->
|
||||
{#if adventure.rating}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex">
|
||||
{#each renderStars(adventure.rating) as filled}
|
||||
{#if filled}
|
||||
<Star class="w-4 h-4 text-warning fill-current" />
|
||||
{:else}
|
||||
<StarOutline class="w-4 h-4 text-base-content/30" />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="text-sm text-base-content/60">({adventure.rating}/5)</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
{#if adventure.visits.length > 0}
|
||||
<div class="flex items-center gap-2 p-3 bg-base-200 rounded-lg">
|
||||
<Calendar class="w-4 h-4 text-primary" />
|
||||
<span class="text-sm font-medium">{formatVisitCount()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions Section -->
|
||||
{#if !readOnly}
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<!-- action options dropdown -->
|
||||
|
||||
<div class="pt-4 border-t border-base-300">
|
||||
{#if type != 'link'}
|
||||
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral-200">
|
||||
<DotsHorizontal class="w-6 h-6" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
|
||||
>
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
><Launch class="w-6 h-6" />{$t('adventures.open_details')}</button
|
||||
>
|
||||
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
|
||||
<FileDocumentEdit class="w-6 h-6" />
|
||||
{$t('adventures.edit_adventure')}
|
||||
</button>
|
||||
|
||||
<!-- remove from collection -->
|
||||
{#if adventure.collection && user?.uuid == adventure.user_id}
|
||||
<button class="btn btn-neutral mb-2" on:click={removeFromCollection}
|
||||
><LinkVariantRemove class="w-6 h-6" />{$t(
|
||||
'adventures.remove_from_collection'
|
||||
)}</button
|
||||
>
|
||||
{/if}
|
||||
{#if !adventure.collection}
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => (isCollectionModalOpen = true)}
|
||||
><Plus class="w-6 h-6" />{$t('adventures.add_to_collection')}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
class="btn btn-warning"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
><TrashCan class="w-6 h-6" />{$t('adventures.delete')}</button
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-between items-center">
|
||||
<button
|
||||
class="btn btn-neutral-200 mb-2"
|
||||
class="btn btn-neutral btn-sm flex-1 mr-2"
|
||||
on:click={() => goto(`/adventures/${adventure.id}`)}
|
||||
><Launch class="w-6 h-6" /></button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if type == 'link'}
|
||||
<button class="btn btn-primary" on:click={link}><Link class="w-6 h-6" /></button>
|
||||
<Launch class="w-4 h-4" />
|
||||
{$t('adventures.open_details')}
|
||||
</button>
|
||||
|
||||
{#if adventure.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-56 p-2 shadow-xl border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editAdventure} class="flex items-center gap-2">
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('adventures.edit_adventure')}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{#if user?.uuid == adventure.user_id}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => (isCollectionModalOpen = true)}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('collection.manage_collections')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-block" on:click={link}>
|
||||
<Link class="w-4 h-4" />
|
||||
Link Adventure
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,26 +7,83 @@
|
|||
import AdventureCard from './AdventureCard.svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let adventures: Adventure[] = [];
|
||||
// Icons - following the worldtravel pattern
|
||||
import Adventures from '~icons/mdi/map-marker-path';
|
||||
import Search from '~icons/mdi/magnify';
|
||||
import Clear from '~icons/mdi/close';
|
||||
import Link from '~icons/mdi/link-variant';
|
||||
import Check from '~icons/mdi/check-circle';
|
||||
import Cancel from '~icons/mdi/cancel';
|
||||
import Public from '~icons/mdi/earth';
|
||||
import Private from '~icons/mdi/lock';
|
||||
|
||||
let adventures: Adventure[] = [];
|
||||
let filteredAdventures: Adventure[] = [];
|
||||
let searchQuery: string = '';
|
||||
let filterOption: string = 'all';
|
||||
let isLoading: boolean = true;
|
||||
|
||||
export let user: User | null;
|
||||
export let collectionId: string;
|
||||
|
||||
// Search and filter functionality following worldtravel pattern
|
||||
$: {
|
||||
let filtered = adventures;
|
||||
|
||||
// Apply search filter - include name and location
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((adventure) => {
|
||||
const nameMatch = adventure.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const locationMatch =
|
||||
adventure.location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const descriptionMatch =
|
||||
adventure.description?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return nameMatch || locationMatch || descriptionMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (filterOption === 'public') {
|
||||
filtered = filtered.filter((adventure) => adventure.is_public);
|
||||
} else if (filterOption === 'private') {
|
||||
filtered = filtered.filter((adventure) => !adventure.is_public);
|
||||
} else if (filterOption === 'visited') {
|
||||
filtered = filtered.filter((adventure) => adventure.visits && adventure.visits.length > 0);
|
||||
} else if (filterOption === 'not_visited') {
|
||||
filtered = filtered.filter((adventure) => !adventure.visits || adventure.visits.length === 0);
|
||||
}
|
||||
|
||||
filteredAdventures = filtered;
|
||||
}
|
||||
|
||||
// Statistics following worldtravel pattern
|
||||
$: totalAdventures = adventures.length;
|
||||
$: publicAdventures = adventures.filter((a) => a.is_public).length;
|
||||
$: privateAdventures = adventures.filter((a) => !a.is_public).length;
|
||||
$: visitedAdventures = adventures.filter((a) => a.visits && a.visits.length > 0).length;
|
||||
$: notVisitedAdventures = adventures.filter((a) => !a.visits || a.visits.length === 0).length;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
let res = await fetch(`/api/adventures/all/?include_collections=false`, {
|
||||
|
||||
let res = await fetch(`/api/adventures/all/?include_collections=true`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const newAdventures = await res.json();
|
||||
|
||||
if (res.ok && adventures) {
|
||||
// Filter out adventures that are already linked to the collections
|
||||
if (collectionId) {
|
||||
adventures = newAdventures.filter((adventure: Adventure) => {
|
||||
return !(adventure.collections ?? []).includes(collectionId);
|
||||
});
|
||||
} else {
|
||||
adventures = newAdventures;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
|
@ -44,28 +101,200 @@
|
|||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery = '';
|
||||
filterOption = 'all';
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h1 class="text-center font-bold text-4xl mb-6">{$t('adventures.my_adventures')}</h1>
|
||||
<div
|
||||
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
|
||||
role="dialog"
|
||||
on:keydown={handleKeydown}
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Header Section - Following worldtravel pattern -->
|
||||
<div
|
||||
class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<Adventures class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary bg-clip-text">
|
||||
{$t('adventures.my_adventures')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{filteredAdventures.length}
|
||||
{$t('worldtravel.of')}
|
||||
{totalAdventures}
|
||||
{$t('navbar.adventures')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="stats stats-horizontal bg-base-200/50 border border-base-300/50">
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">{$t('collection.available')}</div>
|
||||
<div class="stat-value text-lg text-info">{totalAdventures}</div>
|
||||
</div>
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">{$t('adventures.visited')}</div>
|
||||
<div class="stat-value text-lg text-success">{visitedAdventures}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<Clear class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="{$t('navbar.search')} {$t('adventures.name_location')}..."
|
||||
class="input input-bordered w-full pl-10 pr-10 bg-base-100/80"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery.length > 0}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
|
||||
on:click={() => (searchQuery = '')}
|
||||
>
|
||||
<Clear class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery || filterOption !== 'all'}
|
||||
<button class="btn btn-ghost btn-xs gap-1" on:click={clearFilters}>
|
||||
<Clear class="w-3 h-3" />
|
||||
{$t('worldtravel.clear_all')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter Chips -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content/60">
|
||||
{$t('worldtravel.filter_by')}:
|
||||
</span>
|
||||
<div class="tabs tabs-boxed bg-base-200">
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'all')}
|
||||
>
|
||||
<Adventures class="w-3 h-3" />
|
||||
{$t('adventures.all')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'visited')}
|
||||
>
|
||||
<Check class="w-3 h-3" />
|
||||
{$t('adventures.visited')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'not_visited' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'not_visited')}
|
||||
>
|
||||
<Cancel class="w-3 h-3" />
|
||||
{$t('adventures.not_visited')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'public' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'public')}
|
||||
>
|
||||
<Public class="w-3 h-3" />
|
||||
{$t('adventures.public')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'private' ? 'tab-active' : ''}"
|
||||
on:click={() => (filterOption = 'private')}
|
||||
>
|
||||
<Private class="w-3 h-3" />
|
||||
{$t('adventures.private')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center items-center w-full mt-16">
|
||||
<span class="loading loading-spinner w-24 h-24"></span>
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||
<span class="loading loading-spinner w-16 h-16 text-primary"></span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||||
{$t('adventures.loading_adventures')}
|
||||
</h3>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Main Content -->
|
||||
<div class="px-2">
|
||||
{#if filteredAdventures.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||
<Adventures class="w-16 h-16 text-base-content/30" />
|
||||
</div>
|
||||
{#if searchQuery || filterOption !== 'all'}
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||||
{$t('adventures.no_adventures_found')}
|
||||
</h3>
|
||||
<p class="text-base-content/50 text-center max-w-md mb-6">
|
||||
{$t('collection.try_different_search')}
|
||||
</p>
|
||||
<button class="btn btn-primary gap-2" on:click={clearFilters}>
|
||||
<Clear class="w-4 h-4" />
|
||||
{$t('worldtravel.clear_filters')}
|
||||
</button>
|
||||
{:else}
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||||
{$t('adventures.no_linkable_adventures')}
|
||||
</h3>
|
||||
<p class="text-base-content/50 text-center max-w-md">
|
||||
{$t('adventures.all_adventures_already_linked')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Adventures Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 p-4">
|
||||
{#each filteredAdventures as adventure}
|
||||
<AdventureCard {user} type="link" {adventure} on:link={add} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each adventures as adventure}
|
||||
<AdventureCard {user} type="link" {adventure} on:link={add} />
|
||||
{/each}
|
||||
{#if adventures.length === 0 && !isLoading}
|
||||
<p class="text-center text-lg">
|
||||
{$t('adventures.no_linkable_adventures')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div
|
||||
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
{filteredAdventures.length}
|
||||
{$t('adventures.adventures_available')}
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2" on:click={close}>
|
||||
<Link class="w-4 h-4" />
|
||||
{$t('adventures.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
@ -6,9 +6,22 @@
|
|||
import { t } from 'svelte-i18n';
|
||||
export let collection: Collection | null = null;
|
||||
|
||||
let fullStartDate: string = '';
|
||||
let fullEndDate: string = '';
|
||||
let fullStartDateOnly: string = '';
|
||||
let fullEndDateOnly: string = '';
|
||||
|
||||
// Set full start and end dates from collection
|
||||
if (collection && collection.start_date && collection.end_date) {
|
||||
fullStartDate = `${collection.start_date}T00:00`;
|
||||
fullEndDate = `${collection.end_date}T23:59`;
|
||||
fullStartDateOnly = collection.start_date;
|
||||
fullEndDateOnly = collection.end_date;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let images: { id: string; image: string; is_primary: boolean }[] = [];
|
||||
let images: { id: string; image: string; is_primary: boolean; immich_id: string | null }[] = [];
|
||||
let warningMessage: string = '';
|
||||
let constrainDates: boolean = false;
|
||||
|
||||
|
@ -60,25 +73,26 @@
|
|||
'.tar.lzma',
|
||||
'.tar.lzo',
|
||||
'.tar.z',
|
||||
'gpx',
|
||||
'md',
|
||||
'pdf'
|
||||
'.gpx',
|
||||
'.md'
|
||||
];
|
||||
|
||||
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let immichIntegration: boolean = false;
|
||||
let copyImmichLocally: boolean = false;
|
||||
|
||||
import ActivityComplete from './ActivityComplete.svelte';
|
||||
import CategoryDropdown from './CategoryDropdown.svelte';
|
||||
import { findFirstValue } from '$lib';
|
||||
import { findFirstValue, isAllDay } 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';
|
||||
import DateRangeCollapse from './DateRangeCollapse.svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
let wikiError: string = '';
|
||||
|
@ -97,7 +111,6 @@
|
|||
location: null,
|
||||
images: [],
|
||||
user_id: null,
|
||||
collection: collection?.id || null,
|
||||
category: {
|
||||
id: '',
|
||||
name: '',
|
||||
|
@ -123,7 +136,6 @@
|
|||
location: adventureToEdit?.location || null,
|
||||
images: adventureToEdit?.images || [],
|
||||
user_id: adventureToEdit?.user_id || null,
|
||||
collection: adventureToEdit?.collection || collection?.id || null,
|
||||
visits: adventureToEdit?.visits || [],
|
||||
is_visited: adventureToEdit?.is_visited || false,
|
||||
category: adventureToEdit?.category || {
|
||||
|
@ -147,13 +159,19 @@
|
|||
addToast('error', $t('adventures.category_fetch_error'));
|
||||
}
|
||||
// Check for Immich Integration
|
||||
let res = await fetch('/api/integrations');
|
||||
if (!res.ok) {
|
||||
let res = await fetch('/api/integrations/immich/');
|
||||
// If the response is not ok, we assume Immich integration is not available
|
||||
if (!res.ok && res.status !== 404) {
|
||||
addToast('error', $t('immich.integration_fetch_error'));
|
||||
} else {
|
||||
let data = await res.json();
|
||||
if (data.immich) {
|
||||
if (data.error) {
|
||||
immichIntegration = false;
|
||||
} else if (data.id) {
|
||||
immichIntegration = true;
|
||||
copyImmichLocally = data.copy_locally || false;
|
||||
} else {
|
||||
immichIntegration = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -163,6 +181,8 @@
|
|||
let wikiImageError: string = '';
|
||||
let triggerMarkVisted: boolean = false;
|
||||
|
||||
let isLoading: boolean = false;
|
||||
|
||||
images = adventure.images || [];
|
||||
$: {
|
||||
if (!adventure.rating) {
|
||||
|
@ -314,7 +334,12 @@
|
|||
});
|
||||
if (res.ok) {
|
||||
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
|
||||
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
|
||||
let newImage = {
|
||||
id: newData.data.id,
|
||||
image: newData.data.image,
|
||||
is_primary: false,
|
||||
immich_id: null
|
||||
};
|
||||
images = [...images, newImage];
|
||||
adventure.images = images;
|
||||
addToast('success', $t('adventures.image_upload_success'));
|
||||
|
@ -365,7 +390,12 @@
|
|||
});
|
||||
if (res2.ok) {
|
||||
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
|
||||
let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
|
||||
let newImage = {
|
||||
id: newData.data.id,
|
||||
image: newData.data.image,
|
||||
is_primary: false,
|
||||
immich_id: null
|
||||
};
|
||||
images = [...images, newImage];
|
||||
adventure.images = images;
|
||||
addToast('success', $t('adventures.image_upload_success'));
|
||||
|
@ -376,35 +406,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
let new_start_date: string = '';
|
||||
let new_end_date: string = '';
|
||||
let new_notes: string = '';
|
||||
function addNewVisit() {
|
||||
if (new_start_date && !new_end_date) {
|
||||
new_end_date = new_start_date;
|
||||
}
|
||||
if (new_start_date > new_end_date) {
|
||||
addToast('error', $t('adventures.start_before_end_error'));
|
||||
return;
|
||||
}
|
||||
if (new_end_date && !new_start_date) {
|
||||
addToast('error', $t('adventures.no_start_date'));
|
||||
return;
|
||||
}
|
||||
adventure.visits = [
|
||||
...adventure.visits,
|
||||
{
|
||||
start_date: new_start_date,
|
||||
end_date: new_end_date,
|
||||
notes: new_notes,
|
||||
id: ''
|
||||
}
|
||||
];
|
||||
new_start_date = '';
|
||||
new_end_date = '';
|
||||
new_notes = '';
|
||||
}
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
@ -429,6 +430,14 @@
|
|||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
triggerMarkVisted = true;
|
||||
isLoading = true;
|
||||
|
||||
// if category icon is empty, set it to the default icon
|
||||
if (adventure.category?.icon == '' || adventure.category?.icon == null) {
|
||||
if (adventure.category) {
|
||||
adventure.category.icon = '🌍';
|
||||
}
|
||||
}
|
||||
|
||||
if (adventure.id === '') {
|
||||
if (adventure.category?.display_name == '') {
|
||||
|
@ -446,6 +455,12 @@
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
// add this collection to the adventure
|
||||
if (collection && collection.id) {
|
||||
adventure.collections = [collection.id];
|
||||
}
|
||||
|
||||
let res = await fetch('/api/adventures', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
@ -484,6 +499,7 @@
|
|||
}
|
||||
}
|
||||
imageSearch = adventure.name;
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -614,7 +630,7 @@
|
|||
<p class="text-red-500">{wikiError}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !collection?.id}
|
||||
{#if !adventureToEdit || (adventureToEdit.collections && adventureToEdit.collections.length === 0)}
|
||||
<div>
|
||||
<div class="form-control flex items-start mt-1">
|
||||
<label class="label cursor-pointer flex items-start space-x-2">
|
||||
|
@ -652,138 +668,8 @@
|
|||
<ActivityComplete bind:activities={adventure.activity_types} />
|
||||
</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.visits')} ({adventure.visits.length})
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<label class="label cursor-pointer flex items-start space-x-2">
|
||||
{#if adventure.collection && collection && collection.start_date && collection.end_date}
|
||||
<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)}
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="flex gap-2 mb-1">
|
||||
{#if !constrainDates}
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Start Date"
|
||||
bind:value={new_start_date}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewVisit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
placeholder={$t('adventures.end_date')}
|
||||
bind:value={new_end_date}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewVisit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
placeholder={$t('adventures.start_date')}
|
||||
min={collection?.start_date}
|
||||
max={collection?.end_date}
|
||||
bind:value={new_start_date}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewVisit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
placeholder={$t('adventures.end_date')}
|
||||
bind:value={new_end_date}
|
||||
min={collection?.start_date}
|
||||
max={collection?.end_date}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewVisit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 mb-1">
|
||||
<!-- textarea for notes -->
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder={$t('adventures.add_notes')}
|
||||
bind:value={new_notes}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewVisit();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-neutral" on:click={addNewVisit}
|
||||
>{$t('adventures.add')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if adventure.visits.length > 0}
|
||||
<h2 class=" font-bold text-xl mt-2">{$t('adventures.my_visits')}</h2>
|
||||
{#each adventure.visits as visit}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<p>
|
||||
{new Date(visit.start_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})}
|
||||
</p>
|
||||
{#if visit.end_date && visit.end_date !== visit.start_date}
|
||||
<p>
|
||||
{new Date(visit.end_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error"
|
||||
on:click={() => {
|
||||
adventure.visits = adventure.visits.filter((v) => v !== visit);
|
||||
}}
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap -mt-2 mb-2">{visit.notes}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<DateRangeCollapse type="adventure" {collection} bind:visits={adventure.visits} />
|
||||
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
|
@ -805,8 +691,17 @@
|
|||
<span>{$t('adventures.warning')}: {warningMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="submit" class="btn btn-primary">{$t('adventures.save_next')}</button>
|
||||
<button type="button" class="btn" on:click={close}>{$t('about.close')}</button>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#if !isLoading}
|
||||
<button type="submit" class="btn btn-primary">{$t('adventures.save_next')}</button
|
||||
>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-primary"
|
||||
><span class="loading loading-spinner loading-md"></span></button
|
||||
>
|
||||
{/if}
|
||||
<button type="button" class="btn" on:click={close}>{$t('about.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -849,6 +744,23 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="alert bg-neutral">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{$t('adventures.gpx_tip')}</span>
|
||||
</div>
|
||||
|
||||
{#if attachmentToEdit}
|
||||
<form
|
||||
on:submit={(e) => {
|
||||
|
@ -941,6 +853,18 @@
|
|||
url = e.detail;
|
||||
fetchImage();
|
||||
}}
|
||||
{copyImmichLocally}
|
||||
on:remoteImmichSaved={(e) => {
|
||||
const newImage = {
|
||||
id: e.detail.id,
|
||||
image: e.detail.image,
|
||||
is_primary: e.detail.is_primary,
|
||||
immich_id: e.detail.immich_id
|
||||
};
|
||||
images = [...images, newImage];
|
||||
adventure.images = images;
|
||||
addToast('success', $t('adventures.image_upload_success'));
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -2,49 +2,172 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
// Icons
|
||||
import Account from '~icons/mdi/account';
|
||||
import MapMarker from '~icons/mdi/map-marker';
|
||||
import Shield from '~icons/mdi/shield-account';
|
||||
import Settings from '~icons/mdi/cog';
|
||||
import Logout from '~icons/mdi/logout';
|
||||
|
||||
export let user: any;
|
||||
|
||||
let letter: string = user.first_name[0];
|
||||
let letter: string = user.first_name?.[0] || user.username?.[0] || '?';
|
||||
|
||||
if (user && !user.first_name && user.username) {
|
||||
letter = user.username[0];
|
||||
}
|
||||
// Get display name
|
||||
$: displayName = user.first_name
|
||||
? `${user.first_name} ${user.last_name || ''}`.trim()
|
||||
: user.username || 'User';
|
||||
|
||||
// Get initials for fallback
|
||||
$: initials =
|
||||
user.first_name && user.last_name ? `${user.first_name[0]}${user.last_name[0]}` : letter;
|
||||
|
||||
// Menu items for better organization
|
||||
const menuItems = [
|
||||
{
|
||||
path: `/profile/${user.username}`,
|
||||
icon: Account,
|
||||
label: 'navbar.profile',
|
||||
section: 'main'
|
||||
},
|
||||
{
|
||||
path: '/adventures',
|
||||
icon: MapMarker,
|
||||
label: 'navbar.my_adventures',
|
||||
section: 'main'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
icon: Settings,
|
||||
label: 'navbar.settings',
|
||||
section: 'secondary'
|
||||
}
|
||||
];
|
||||
|
||||
// Add admin item if user is staff
|
||||
$: adminMenuItem = user.is_staff
|
||||
? {
|
||||
path: '/admin',
|
||||
icon: Shield,
|
||||
label: 'navbar.admin_panel',
|
||||
section: 'secondary'
|
||||
}
|
||||
: null;
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-bottom dropdown-end" tabindex="0" role="button">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral rounded-full text-neutral-200 w-10 ml-4">
|
||||
<div class="dropdown dropdown-bottom dropdown-end z-[100]">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<div class="w-10 rounded-full ring-2 ring-primary/20 hover:ring-primary/40 transition-all">
|
||||
{#if user.profile_pic}
|
||||
<img src={user.profile_pic} alt={$t('navbar.profile')} />
|
||||
<img src={user.profile_pic} alt={$t('navbar.profile')} class="rounded-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-2xl -mt-1">{letter}</span>
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-sm"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content z-[1] text-neutral-200 menu p-2 shadow bg-neutral mt-2 rounded-box w-52"
|
||||
class="dropdown-content z-[100] menu p-4 shadow-2xl bg-base-100 border border-base-300 rounded-2xl w-72 mt-2"
|
||||
>
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<p class="text-lg ml-4 font-bold">
|
||||
{$t('navbar.greeting')}, {user.first_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.username}
|
||||
</p>
|
||||
<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>
|
||||
{#if user.is_staff}
|
||||
<li><button on:click={() => goto('/admin')}>{$t('navbar.admin_panel')}</button></li>
|
||||
{/if}
|
||||
<li><button on:click={() => goto('/settings')}>{$t('navbar.settings')}</button></li>
|
||||
<form method="post">
|
||||
<li><button formaction="/?/logout">{$t('navbar.logout')}</button></li>
|
||||
<!-- User Info Header -->
|
||||
<div class="px-2 py-3 mb-3 border-b border-base-300">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="w-12 rounded-full ring-2 ring-primary/20">
|
||||
{#if user.profile_pic}
|
||||
<img
|
||||
src={user.profile_pic}
|
||||
alt={$t('navbar.profile')}
|
||||
class="rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-12 h-12 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-lg"
|
||||
style="line-height: 3rem;"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base text-base-content truncate">
|
||||
{$t('navbar.greeting')}, {displayName}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
@{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Menu Items -->
|
||||
<div class="space-y-1 mb-3">
|
||||
{#each menuItems.filter((item) => item.section === 'main') as item}
|
||||
<li>
|
||||
<button
|
||||
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl hover:bg-base-200"
|
||||
on:click={() => goto(item.path)}
|
||||
>
|
||||
<svelte:component this={item.icon} class="w-5 h-5 text-base-content/70" />
|
||||
<span>{$t(item.label)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Secondary Menu Items -->
|
||||
<div class="space-y-1 mb-3">
|
||||
{#if adminMenuItem}
|
||||
<li>
|
||||
<button
|
||||
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl hover:bg-base-200"
|
||||
on:click={() => goto(adminMenuItem.path)}
|
||||
>
|
||||
<svelte:component this={adminMenuItem.icon} class="w-5 h-5 text-warning" />
|
||||
<span class="text-warning font-medium">{$t(adminMenuItem.label)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#each menuItems.filter((item) => item.section === 'secondary') as item}
|
||||
<li>
|
||||
<button
|
||||
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl hover:bg-base-200"
|
||||
on:click={() => goto(item.path)}
|
||||
>
|
||||
<svelte:component this={item.icon} class="w-5 h-5 text-base-content/70" />
|
||||
<span>{$t(item.label)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Logout -->
|
||||
<form method="post" class="w-full">
|
||||
<li class="w-full">
|
||||
<button
|
||||
formaction="/?/logout"
|
||||
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl hover:bg-error/10 hover:text-error transition-colors"
|
||||
>
|
||||
<Logout class="w-5 h-5" />
|
||||
<span>{$t('navbar.logout')}</span>
|
||||
</button>
|
||||
</li>
|
||||
</form>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -97,12 +97,12 @@
|
|||
{:else}
|
||||
<!-- add a figure with a gradient instead - -->
|
||||
<div class="w-full h-48 bg-gradient-to-r from-success via-base to-primary relative">
|
||||
<!-- subtle button bottom left text -->
|
||||
<!-- subtle button bottom left text
|
||||
<div
|
||||
class="absolute bottom-0 left-0 px-2 py-1 text-md font-medium bg-neutral rounded-tr-lg shadow-md"
|
||||
>
|
||||
{$t('adventures.no_image_found')}
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
|
|
|
@ -37,25 +37,34 @@
|
|||
|
||||
<div class="collapse collapse-plus mb-4">
|
||||
<input type="checkbox" />
|
||||
|
||||
<div class="collapse-title text-xl bg-base-300 font-medium">
|
||||
{$t('adventures.category_filter')}
|
||||
</div>
|
||||
|
||||
<div class="collapse-content bg-base-300">
|
||||
<button class="btn btn-wide btn-neutral-300" on:click={clearTypes}
|
||||
>{$t(`adventures.clear`)}</button
|
||||
>
|
||||
{#each adventure_types as type}
|
||||
<li>
|
||||
<label class="cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={type.name}
|
||||
on:change={() => toggleSelect(type.name)}
|
||||
checked={types.indexOf(type.name) > -1}
|
||||
/>
|
||||
<span>{type.display_name + ' ' + type.icon + ` (${type.num_adventures})`}</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
<button class="btn btn-sm btn-neutral-300 w-full mb-2" on:click={clearTypes}>
|
||||
{$t('adventures.clear')}
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{#each adventure_types as type}
|
||||
<li class="mb-1">
|
||||
<label class="cursor-pointer flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
value={type.name}
|
||||
on:change={() => toggleSelect(type.name)}
|
||||
checked={types.indexOf(type.name) > -1}
|
||||
/>
|
||||
<span>
|
||||
{type.display_name}
|
||||
{type.icon} ({type.num_adventures})
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,149 +1,353 @@
|
|||
<script lang="ts">
|
||||
import type { Category } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
let modal: HTMLDialogElement;
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let modal: HTMLDialogElement;
|
||||
|
||||
export let categories: Category[] = [];
|
||||
|
||||
let category_to_edit: Category | null = null;
|
||||
|
||||
let is_changed: boolean = false;
|
||||
|
||||
let has_loaded: boolean = false;
|
||||
let categoryToEdit: Category | null = null;
|
||||
let newCategory = { display_name: '', icon: '' };
|
||||
let showAddForm = false;
|
||||
let isChanged = false;
|
||||
let hasLoaded = false;
|
||||
let warningMessage: string | null = null;
|
||||
let showEmojiPickerAdd = false;
|
||||
let showEmojiPickerEdit = false;
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
let category_fetch = await fetch('/api/categories/categories');
|
||||
categories = await category_fetch.json();
|
||||
has_loaded = true;
|
||||
// remove the general category if it exists
|
||||
// categories = categories.filter((c) => c.name !== 'general');
|
||||
await import('emoji-picker-element');
|
||||
modal = document.querySelector('#category-modal') as HTMLDialogElement;
|
||||
modal.showModal();
|
||||
await loadCategories();
|
||||
});
|
||||
|
||||
async function saveCategory() {
|
||||
if (category_to_edit) {
|
||||
let edit_fetch = await fetch(`/api/categories/${category_to_edit.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(category_to_edit)
|
||||
});
|
||||
if (edit_fetch.ok) {
|
||||
category_to_edit = null;
|
||||
let the_category = (await edit_fetch.json()) as Category;
|
||||
categories = categories.map((c) => {
|
||||
if (c.id === the_category.id) {
|
||||
return the_category;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
is_changed = true;
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const res = await fetch('/api/categories/categories');
|
||||
if (res.ok) {
|
||||
categories = await res.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
} finally {
|
||||
hasLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
function closeModal() {
|
||||
dispatch('close');
|
||||
modal.close();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch('close');
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function removeCategory(category: Category) {
|
||||
return async () => {
|
||||
let response = await fetch(`/api/categories/${category.id}`, {
|
||||
function handleEmojiSelectAdd(event: CustomEvent) {
|
||||
newCategory.icon = event.detail.unicode;
|
||||
showEmojiPickerAdd = false;
|
||||
}
|
||||
|
||||
function handleEmojiSelectEdit(event: CustomEvent) {
|
||||
if (categoryToEdit) {
|
||||
categoryToEdit.icon = event.detail.unicode;
|
||||
}
|
||||
showEmojiPickerEdit = false;
|
||||
}
|
||||
|
||||
async function createCategory(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const nameTrimmed = newCategory.display_name.trim();
|
||||
if (!nameTrimmed) {
|
||||
warningMessage = $t('categories.name_required');
|
||||
return;
|
||||
}
|
||||
warningMessage = null;
|
||||
|
||||
const payload = {
|
||||
display_name: nameTrimmed,
|
||||
name: nameTrimmed
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-z0-9_]/g, ''),
|
||||
icon: newCategory.icon.trim() || '🌍'
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
categories = [...categories, created];
|
||||
isChanged = true;
|
||||
newCategory = { display_name: '', icon: '' };
|
||||
showAddForm = false;
|
||||
showEmojiPickerAdd = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create category:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!categoryToEdit) return;
|
||||
|
||||
const nameTrimmed = categoryToEdit.display_name.trim();
|
||||
if (!nameTrimmed) {
|
||||
warningMessage = $t('categories.name_required');
|
||||
return;
|
||||
}
|
||||
warningMessage = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/categories/${categoryToEdit.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...categoryToEdit, display_name: nameTrimmed })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
categories = categories.map((c) => (c.id === updated.id ? updated : c));
|
||||
categoryToEdit = null;
|
||||
isChanged = true;
|
||||
showEmojiPickerEdit = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save category:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(category: Category) {
|
||||
categoryToEdit = { ...category };
|
||||
showAddForm = false;
|
||||
showEmojiPickerAdd = false;
|
||||
showEmojiPickerEdit = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
categoryToEdit = null;
|
||||
showEmojiPickerEdit = false;
|
||||
}
|
||||
|
||||
async function removeCategory(category: Category) {
|
||||
if (category.name === 'general') return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/categories/${category.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
if (res.ok) {
|
||||
categories = categories.filter((c) => c.id !== category.id);
|
||||
is_changed = true;
|
||||
isChanged = true;
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to delete category:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h3 class="font-bold text-lg">{$t('categories.manage_categories')}</h3>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<dialog id="category-modal" class="modal" on:keydown={handleKeydown}>
|
||||
<div class="modal-box max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold">{$t('categories.manage_categories')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
on:click={closeModal}
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if has_loaded}
|
||||
{#each categories as category}
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span>{category.display_name} {category.icon}</span>
|
||||
<div class="flex space-x-2">
|
||||
<button on:click={() => (category_to_edit = category)} class="btn btn-primary btn-sm"
|
||||
>Edit</button
|
||||
>
|
||||
{#if category.name != 'general'}
|
||||
<button on:click={removeCategory(category)} class="btn btn-warning btn-sm"
|
||||
>{$t('adventures.remove')}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-warning btn-sm btn-disabled">{$t('adventures.remove')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Category List -->
|
||||
{#if hasLoaded}
|
||||
{#if categories.length > 0}
|
||||
<div class="space-y-2 mb-6">
|
||||
{#each categories as category (category.id)}
|
||||
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">{category.icon || '🌍'}</span>
|
||||
<span class="font-medium">{category.display_name}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => startEdit(category)}
|
||||
class="btn btn-xs btn-neutral"
|
||||
>
|
||||
{$t('lodging.edit')}
|
||||
</button>
|
||||
{#if category.name !== 'general'}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => removeCategory(category)}
|
||||
class="btn btn-xs btn-error"
|
||||
>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
{$t('categories.no_categories_found')}
|
||||
</div>
|
||||
{/each}
|
||||
{#if categories.length === 0}
|
||||
<p>{$t('categories.no_categories_found')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg m-4"></span>
|
||||
<div class="text-center py-8">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if category_to_edit}
|
||||
<h2 class="text-center text-xl font-semibold mt-2 mb-2">{$t('categories.edit_category')}</h2>
|
||||
<div class="flex flex-row space-x-2 form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('adventures.name')}
|
||||
bind:value={category_to_edit.display_name}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<!-- Edit Category Form -->
|
||||
{#if categoryToEdit}
|
||||
<div class="bg-base-100 border border-base-300 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-medium mb-4">{$t('categories.edit_category')}</h3>
|
||||
<form on:submit={saveCategory} class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('categories.category_name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={categoryToEdit.display_name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('categories.icon')}</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={categoryToEdit.icon}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showEmojiPickerEdit = !showEmojiPickerEdit)}
|
||||
class="btn btn-square btn-outline"
|
||||
>
|
||||
😀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('categories.icon')}
|
||||
bind:value={category_to_edit.icon}
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
/>
|
||||
{#if showEmojiPickerEdit}
|
||||
<div class="p-2 border rounded-lg bg-base-100">
|
||||
<emoji-picker on:emoji-click={handleEmojiSelectEdit}></emoji-picker>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" on:click={cancelEdit}>
|
||||
{$t('adventures.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary"> {$t('notes.save')} </button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button class="btn btn-primary" on:click={saveCategory}>{$t('notes.save')}</button>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-primary mt-4" on:click={close}>{$t('about.close')}</button>
|
||||
<!-- Add Category Section -->
|
||||
<div class="collapse collapse-plus bg-base-200 mb-4">
|
||||
<input type="checkbox" bind:checked={showAddForm} />
|
||||
<div class="collapse-title font-medium">{$t('categories.add_new_category')}</div>
|
||||
{#if showAddForm}
|
||||
<div class="collapse-content">
|
||||
<form on:submit={createCategory} class="space-y-4">
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('categories.category_name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newCategory.display_name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if is_changed}
|
||||
<div role="alert" class="alert alert-info mt-6">
|
||||
<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>
|
||||
<div>
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<label class="label">
|
||||
<span class="label-text">{$t('categories.icon')}</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={newCategory.icon}
|
||||
placeholder="🌍"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showEmojiPickerAdd = !showEmojiPickerAdd)}
|
||||
class="btn btn-square btn-outline"
|
||||
>
|
||||
😀
|
||||
</button>
|
||||
</div>
|
||||
{#if showEmojiPickerAdd}
|
||||
<div class="mt-2 p-2 border rounded-lg bg-base-100">
|
||||
<emoji-picker on:emoji-click={handleEmojiSelectAdd}></emoji-picker>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
{$t('collection.create')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{#if warningMessage}
|
||||
<div class="alert alert-warning mb-4">
|
||||
<span>{warningMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isChanged}
|
||||
<div class="alert alert-success mb-4">
|
||||
<span>{$t('categories.update_after_refresh')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn" on:click={closeModal}> {$t('about.close')} </button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.modal-box {
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -62,43 +62,53 @@
|
|||
on:confirm={deleteChecklist}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
<h2 class="text-2xl font-semibold -mt-2 break-words text-wrap">
|
||||
{checklist.name}
|
||||
</h2>
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<h2 class="text-xl font-bold break-words">{checklist.name}</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="badge badge-primary">{$t('adventures.checklist')}</div>
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="badge badge-primary">{$t('adventures.checklist')}</div>
|
||||
|
||||
<!-- Checklist Stats -->
|
||||
{#if checklist.items.length > 0}
|
||||
<p>
|
||||
<p class="text-sm">
|
||||
{checklist.items.length}
|
||||
{checklist.items.length > 1 ? $t('checklist.items') : $t('checklist.item')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if unlinked}
|
||||
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Date -->
|
||||
{#if checklist.date && checklist.date !== ''}
|
||||
<div class="inline-flex items-center">
|
||||
<Calendar class="w-5 h-5 mr-1" />
|
||||
<div class="inline-flex items-center gap-2 text-sm">
|
||||
<Calendar class="w-5 h-5 text-primary" />
|
||||
<p>{new Date(checklist.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-actions justify-end">
|
||||
<button class="btn btn-neutral-200 mb-2" on:click={editChecklist}>
|
||||
<Launch class="w-6 h-6" />{$t('notes.open')}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
|
||||
<button class="btn btn-neutral btn-sm flex items-center gap-1" on:click={editChecklist}>
|
||||
<Launch class="w-5 h-5" />
|
||||
{$t('notes.open')}
|
||||
</button>
|
||||
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
|
||||
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Checklist"
|
||||
class="btn btn-warning"
|
||||
on:click={() => (isWarningModalOpen = true)}><TrashCan class="w-6 h-6" /></button
|
||||
class="btn btn-secondary btn-sm flex items-center gap-1"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCan class="w-5 h-5" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -41,25 +41,40 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl overflow-hidden"
|
||||
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group overflow-hidden"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title overflow-ellipsis">{city.name}</h2>
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<!-- Header -->
|
||||
<h2 class="text-xl font-bold truncate">{city.name}</h2>
|
||||
|
||||
<!-- Metadata Badges -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="badge badge-primary">
|
||||
{city.region_name}, {city.country_name}
|
||||
</div>
|
||||
<div class="badge badge-neutral-300">{city.region}</div>
|
||||
<div class="badge badge-neutral-300">Region ID: {city.region}</div>
|
||||
</div>
|
||||
<div class="card-actions justify-end">
|
||||
{#if !visited}
|
||||
<button class="btn btn-primary" on:click={markVisited}
|
||||
>{$t('adventures.mark_visited')}</button
|
||||
>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
|
||||
{#if visited === false}
|
||||
<button class="btn btn-primary btn-sm" on:click={markVisited}>
|
||||
{$t('adventures.mark_visited')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if visited}
|
||||
<button class="btn btn-warning" on:click={removeVisit}>{$t('adventures.remove')}</button>
|
||||
{#if visited === true}
|
||||
<button class="btn btn-warning btn-sm" on:click={removeVisit}>
|
||||
{$t('adventures.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
551
frontend/src/lib/components/CollectionAllView.svelte
Normal file
|
@ -0,0 +1,551 @@
|
|||
<script lang="ts">
|
||||
import type {
|
||||
Adventure,
|
||||
Transportation,
|
||||
Lodging,
|
||||
Note,
|
||||
Checklist,
|
||||
User,
|
||||
Collection
|
||||
} from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Icons
|
||||
import Adventures from '~icons/mdi/map-marker-path';
|
||||
import TransportationIcon from '~icons/mdi/car';
|
||||
import Hotel from '~icons/mdi/hotel';
|
||||
import NoteIcon from '~icons/mdi/note-text';
|
||||
import ChecklistIcon from '~icons/mdi/check-box-outline';
|
||||
import Search from '~icons/mdi/magnify';
|
||||
import Clear from '~icons/mdi/close';
|
||||
import Filter from '~icons/mdi/filter-variant';
|
||||
|
||||
// Component imports
|
||||
import AdventureCard from './AdventureCard.svelte';
|
||||
import TransportationCard from './TransportationCard.svelte';
|
||||
import LodgingCard from './LodgingCard.svelte';
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import ChecklistCard from './ChecklistCard.svelte';
|
||||
|
||||
// Props
|
||||
export let adventures: Adventure[] = [];
|
||||
export let transportations: Transportation[] = [];
|
||||
export let lodging: Lodging[] = [];
|
||||
export let notes: Note[] = [];
|
||||
export let checklists: Checklist[] = [];
|
||||
export let user: User | null;
|
||||
export let collection: Collection;
|
||||
|
||||
// State
|
||||
let searchQuery: string = '';
|
||||
let filterOption: string = 'all';
|
||||
let sortOption: string = 'name_asc';
|
||||
|
||||
// Filtered arrays
|
||||
let filteredAdventures: Adventure[] = [];
|
||||
let filteredTransportations: Transportation[] = [];
|
||||
let filteredLodging: Lodging[] = [];
|
||||
let filteredNotes: Note[] = [];
|
||||
let filteredChecklists: Checklist[] = [];
|
||||
|
||||
// Helper function to sort items
|
||||
function sortItems(items: any[], sortOption: string) {
|
||||
const sorted = [...items];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'name_asc':
|
||||
return sorted.sort((a, b) =>
|
||||
(a.name || a.title || '').localeCompare(b.name || b.title || '')
|
||||
);
|
||||
case 'name_desc':
|
||||
return sorted.sort((a, b) =>
|
||||
(b.name || b.title || '').localeCompare(a.name || a.title || '')
|
||||
);
|
||||
case 'date_newest':
|
||||
return sorted.sort(
|
||||
(a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime()
|
||||
);
|
||||
case 'date_oldest':
|
||||
return sorted.sort(
|
||||
(a, b) => new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime()
|
||||
);
|
||||
case 'visited_first':
|
||||
return sorted.sort((a, b) => {
|
||||
const aVisited = a.visits && a.visits.length > 0;
|
||||
const bVisited = b.visits && b.visits.length > 0;
|
||||
if (aVisited && !bVisited) return -1;
|
||||
if (!aVisited && bVisited) return 1;
|
||||
return 0;
|
||||
});
|
||||
case 'unvisited_first':
|
||||
return sorted.sort((a, b) => {
|
||||
const aVisited = a.visits && a.visits.length > 0;
|
||||
const bVisited = b.visits && b.visits.length > 0;
|
||||
if (!aVisited && bVisited) return -1;
|
||||
if (aVisited && !bVisited) return 1;
|
||||
return 0;
|
||||
});
|
||||
default:
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all filters function
|
||||
function clearAllFilters() {
|
||||
searchQuery = '';
|
||||
filterOption = 'all';
|
||||
sortOption = 'name_asc';
|
||||
}
|
||||
|
||||
// Reactive statements for filtering and sorting
|
||||
$: {
|
||||
// Filter adventures
|
||||
let filtered = adventures;
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((adventure) => {
|
||||
const nameMatch =
|
||||
adventure.name?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const locationMatch =
|
||||
adventure.location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const descriptionMatch =
|
||||
adventure.description?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return nameMatch || locationMatch || descriptionMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredAdventures = sortItems(filtered, sortOption);
|
||||
}
|
||||
|
||||
$: {
|
||||
// Filter transportations
|
||||
let filtered = transportations;
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((transport) => {
|
||||
const nameMatch =
|
||||
transport.name?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const fromMatch =
|
||||
transport.from_location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const toMatch =
|
||||
transport.to_location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return nameMatch || fromMatch || toMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredTransportations = sortItems(filtered, sortOption);
|
||||
}
|
||||
|
||||
$: {
|
||||
// Filter lodging
|
||||
let filtered = lodging;
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((hotel) => {
|
||||
const nameMatch = hotel.name?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const locationMatch =
|
||||
hotel.location?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return nameMatch || locationMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredLodging = sortItems(filtered, sortOption);
|
||||
}
|
||||
|
||||
$: {
|
||||
// Filter notes
|
||||
let filtered = notes;
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((note) => {
|
||||
const titleMatch = note.name?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
const contentMatch =
|
||||
note.content?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return titleMatch || contentMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredNotes = sortItems(filtered, sortOption);
|
||||
}
|
||||
|
||||
$: {
|
||||
// Filter checklists
|
||||
let filtered = checklists;
|
||||
if (searchQuery !== '') {
|
||||
filtered = filtered.filter((checklist) => {
|
||||
const titleMatch =
|
||||
checklist.name?.toLowerCase().includes(searchQuery.toLowerCase()) || false;
|
||||
return titleMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredChecklists = sortItems(filtered, sortOption);
|
||||
}
|
||||
|
||||
// Calculate total items
|
||||
$: totalItems =
|
||||
filteredAdventures.length +
|
||||
filteredTransportations.length +
|
||||
filteredLodging.length +
|
||||
filteredNotes.length +
|
||||
filteredChecklists.length;
|
||||
|
||||
// Event handlers
|
||||
function handleEditAdventure(event: { detail: any }) {
|
||||
dispatch('editAdventure', event.detail);
|
||||
}
|
||||
|
||||
function handleDeleteAdventure(event: { detail: any }) {
|
||||
dispatch('deleteAdventure', event.detail);
|
||||
}
|
||||
|
||||
function handleEditTransportation(event: { detail: any }) {
|
||||
dispatch('editTransportation', event.detail);
|
||||
}
|
||||
|
||||
function handleDeleteTransportation(event: { detail: any }) {
|
||||
dispatch('deleteTransportation', event.detail);
|
||||
}
|
||||
|
||||
function handleEditLodging(event: { detail: any }) {
|
||||
dispatch('editLodging', event.detail);
|
||||
}
|
||||
|
||||
function handleDeleteLodging(event: { detail: any }) {
|
||||
dispatch('deleteLodging', event.detail);
|
||||
}
|
||||
|
||||
function handleEditNote(event: { detail: any }) {
|
||||
dispatch('editNote', event.detail);
|
||||
}
|
||||
|
||||
function handleDeleteNote(event: { detail: any }) {
|
||||
dispatch('deleteNote', event.detail);
|
||||
}
|
||||
|
||||
function handleEditChecklist(event: { detail: any }) {
|
||||
dispatch('editChecklist', event.detail);
|
||||
}
|
||||
|
||||
function handleDeleteChecklist(event: { detail: any }) {
|
||||
dispatch('deleteChecklist', event.detail);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Search and Filter Controls -->
|
||||
<div
|
||||
class="bg-base-100/90 backdrop-blur-lg border border-base-300/50 rounded-2xl p-6 mx-4 mb-6 shadow-lg mt-4"
|
||||
>
|
||||
<!-- Header with Stats -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<Adventures class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-primary">
|
||||
{$t('adventures.collection_contents')}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{totalItems}
|
||||
{$t('worldtravel.total_items')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="stats stats-horizontal bg-base-200/50 border border-base-300/50">
|
||||
<div class="stat py-2 px-3">
|
||||
<div class="stat-title text-xs">{$t('navbar.adventures')}</div>
|
||||
<div class="stat-value text-sm text-info">{adventures.length}</div>
|
||||
</div>
|
||||
<div class="stat py-2 px-3">
|
||||
<div class="stat-title text-xs">{$t('adventures.transportations')}</div>
|
||||
<div class="stat-value text-sm text-warning">{transportations.length}</div>
|
||||
</div>
|
||||
<div class="stat py-2 px-3">
|
||||
<div class="stat-title text-xs">{$t('adventures.lodging')}</div>
|
||||
<div class="stat-value text-sm text-success">{lodging.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex flex-col lg:flex-row items-stretch lg:items-center gap-4 mb-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="{$t('navbar.search')} {$t('adventures.name_location')}..."
|
||||
class="input input-bordered w-full pl-10 pr-10 bg-base-100/80"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery.length > 0}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
|
||||
on:click={() => (searchQuery = '')}
|
||||
>
|
||||
<Clear class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery || filterOption !== 'all' || sortOption !== 'name_asc'}
|
||||
<button class="btn btn-ghost btn-sm gap-1" on:click={clearAllFilters}>
|
||||
<Clear class="w-3 h-3" />
|
||||
{$t('worldtravel.clear_all')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sort Labels (Mobile Friendly) -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="badge badge-outline gap-1">
|
||||
<Filter class="w-3 h-3" />
|
||||
{$t('adventures.sort')}:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
class="badge {sortOption === 'name_asc'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'name_asc')}
|
||||
>
|
||||
A-Z
|
||||
</button>
|
||||
<button
|
||||
class="badge {sortOption === 'name_desc'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'name_desc')}
|
||||
>
|
||||
Z-A
|
||||
</button>
|
||||
<button
|
||||
class="badge {sortOption === 'date_newest'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'date_newest')}
|
||||
>
|
||||
{$t('worldtravel.newest_first')}
|
||||
</button>
|
||||
<button
|
||||
class="badge {sortOption === 'date_oldest'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'date_oldest')}
|
||||
>
|
||||
{$t('worldtravel.oldest_first')}
|
||||
</button>
|
||||
<button
|
||||
class="badge {sortOption === 'visited_first'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'visited_first')}
|
||||
>
|
||||
{$t('worldtravel.visited_first')}
|
||||
</button>
|
||||
<button
|
||||
class="badge {sortOption === 'unvisited_first'
|
||||
? 'badge-primary'
|
||||
: 'badge-ghost'} cursor-pointer hover:badge-primary"
|
||||
on:click={() => (sortOption = 'unvisited_first')}
|
||||
>
|
||||
{$t('worldtravel.unvisited_first')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content/60">
|
||||
{$t('adventures.show')}:
|
||||
</span>
|
||||
|
||||
<!-- Scrollable container on mobile -->
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="tabs tabs-boxed bg-base-200 flex-nowrap flex sm:flex-wrap w-max sm:w-auto">
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'all' ? 'tab-active' : ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'all')}
|
||||
>
|
||||
<Adventures class="w-3 h-3" />
|
||||
{$t('adventures.all')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'adventures'
|
||||
? 'tab-active'
|
||||
: ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'adventures')}
|
||||
>
|
||||
<Adventures class="w-3 h-3" />
|
||||
{$t('navbar.adventures')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'transportation'
|
||||
? 'tab-active'
|
||||
: ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'transportation')}
|
||||
>
|
||||
<TransportationIcon class="w-3 h-3" />
|
||||
{$t('adventures.transportations')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'lodging'
|
||||
? 'tab-active'
|
||||
: ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'lodging')}
|
||||
>
|
||||
<Hotel class="w-3 h-3" />
|
||||
{$t('adventures.lodging')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'notes' ? 'tab-active' : ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'notes')}
|
||||
>
|
||||
<NoteIcon class="w-3 h-3" />
|
||||
{$t('adventures.notes')}
|
||||
</button>
|
||||
<button
|
||||
class="tab tab-sm gap-2 {filterOption === 'checklists'
|
||||
? 'tab-active'
|
||||
: ''} whitespace-nowrap"
|
||||
on:click={() => (filterOption = 'checklists')}
|
||||
>
|
||||
<ChecklistIcon class="w-3 h-3" />
|
||||
{$t('adventures.checklists')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adventures Section -->
|
||||
{#if (filterOption === 'all' || filterOption === 'adventures') && filteredAdventures.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mx-4 mb-4">
|
||||
<h1 class="text-3xl font-bold text-primary">
|
||||
{$t('adventures.linked_adventures')}
|
||||
</h1>
|
||||
<div class="badge badge-primary badge-lg">{filteredAdventures.length}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mx-4">
|
||||
{#each filteredAdventures as adventure}
|
||||
<AdventureCard
|
||||
{user}
|
||||
on:edit={handleEditAdventure}
|
||||
on:delete={handleDeleteAdventure}
|
||||
{adventure}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transportation Section -->
|
||||
{#if (filterOption === 'all' || filterOption === 'transportation') && filteredTransportations.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mx-4 mb-4">
|
||||
<h1 class="text-3xl font-bold bg-clip-text text-primary">
|
||||
{$t('adventures.transportations')}
|
||||
</h1>
|
||||
<div class="badge badge-warning badge-lg">{filteredTransportations.length}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mx-4">
|
||||
{#each filteredTransportations as transportation}
|
||||
<TransportationCard
|
||||
{transportation}
|
||||
{user}
|
||||
on:delete={handleDeleteTransportation}
|
||||
on:edit={handleEditTransportation}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lodging Section -->
|
||||
{#if (filterOption === 'all' || filterOption === 'lodging') && filteredLodging.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mx-4 mb-4">
|
||||
<h1 class="text-3xl font-bold bg-clip-text text-primary">
|
||||
{$t('adventures.lodging')}
|
||||
</h1>
|
||||
<div class="badge badge-success badge-lg">{filteredLodging.length}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mx-4">
|
||||
{#each filteredLodging as hotel}
|
||||
<LodgingCard
|
||||
lodging={hotel}
|
||||
{user}
|
||||
on:delete={handleDeleteLodging}
|
||||
on:edit={handleEditLodging}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes Section -->
|
||||
{#if (filterOption === 'all' || filterOption === 'notes') && filteredNotes.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mx-4 mb-4">
|
||||
<h1 class="text-3xl font-bold bg-clip-text text-primary">
|
||||
{$t('adventures.notes')}
|
||||
</h1>
|
||||
<div class="badge badge-info badge-lg">{filteredNotes.length}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mx-4">
|
||||
{#each filteredNotes as note}
|
||||
<NoteCard
|
||||
{note}
|
||||
{user}
|
||||
on:edit={handleEditNote}
|
||||
on:delete={handleDeleteNote}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Checklists Section -->
|
||||
{#if (filterOption === 'all' || filterOption === 'checklists') && filteredChecklists.length > 0}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mx-4 mb-4">
|
||||
<h1 class="text-3xl font-bold bg-clip-text text-primary">
|
||||
{$t('adventures.checklists')}
|
||||
</h1>
|
||||
<div class="badge badge-secondary badge-lg">{filteredChecklists.length}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mx-4">
|
||||
{#each filteredChecklists as checklist}
|
||||
<ChecklistCard
|
||||
{checklist}
|
||||
{user}
|
||||
on:delete={handleDeleteChecklist}
|
||||
on:edit={handleEditChecklist}
|
||||
{collection}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if totalItems === 0}
|
||||
<div class="hero min-h-96">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<Clear class="w-16 h-16 text-base-content/30 mb-4" />
|
||||
<h1 class="text-3xl font-bold text-base-content/70">
|
||||
{$t('immich.no_items_found')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
|
@ -9,11 +9,12 @@
|
|||
import ShareVariant from '~icons/mdi/share-variant';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Adventure, Collection } from '$lib/types';
|
||||
import type { Adventure, Collection, User } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import Minus from '~icons/mdi/minus';
|
||||
import DotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||
import TrashCan from '~icons/mdi/trashcan';
|
||||
import DeleteWarning from './DeleteWarning.svelte';
|
||||
|
@ -23,7 +24,8 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let type: String | undefined | null;
|
||||
export let adventures: Adventure[] = [];
|
||||
export let linkedCollectionList: string[] | null = null;
|
||||
export let user: User | null;
|
||||
let isShareModalOpen: boolean = false;
|
||||
|
||||
function editAdventure() {
|
||||
|
@ -42,10 +44,11 @@
|
|||
if (res.ok) {
|
||||
if (is_archived) {
|
||||
addToast('info', $t('adventures.archived_collection_message'));
|
||||
dispatch('archive', collection.id);
|
||||
} else {
|
||||
addToast('info', $t('adventures.unarchived_collection_message'));
|
||||
dispatch('unarchive', collection.id);
|
||||
}
|
||||
dispatch('delete', collection.id);
|
||||
} else {
|
||||
console.log('Error archiving collection');
|
||||
}
|
||||
|
@ -54,11 +57,8 @@
|
|||
export let collection: Collection;
|
||||
|
||||
async function deleteCollection() {
|
||||
let res = await fetch(`/collections/${collection.id}?/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
let res = await fetch(`/api/collections/${collection.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
addToast('info', $t('adventures.delete_collection_success'));
|
||||
|
@ -87,99 +87,173 @@
|
|||
{/if}
|
||||
|
||||
<div
|
||||
class="card min-w-max lg:w-96 md:w-80 sm:w-60 xs:w-40 bg-neutral text-neutral-content shadow-xl"
|
||||
class="card w-full max-w-md bg-base-300 shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
|
||||
>
|
||||
<CardCarousel {adventures} />
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
class="text-2xl font-semibold -mt-2 break-words text-wrap hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
</div>
|
||||
<div class="inline-flex gap-2 mb-2">
|
||||
<div class="badge badge-secondary">
|
||||
<!-- Image Carousel -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
<CardCarousel adventures={collection.adventures} />
|
||||
|
||||
<!-- Badge Overlay -->
|
||||
<div class="absolute top-4 left-4 flex flex-col gap-2">
|
||||
<div class="badge badge-sm badge-secondary shadow-lg">
|
||||
{collection.is_public ? $t('adventures.public') : $t('adventures.private')}
|
||||
</div>
|
||||
{#if collection.is_archived}
|
||||
<div class="badge badge-warning">{$t('adventures.archived')}</div>
|
||||
<div class="badge badge-sm badge-warning shadow-lg">
|
||||
{$t('adventures.archived')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p>{collection.adventures.length} {$t('navbar.adventures')}</p>
|
||||
{#if collection.start_date && collection.end_date}
|
||||
<p>
|
||||
{$t('adventures.dates')}: {new Date(collection.start_date).toLocaleDateString(undefined, {
|
||||
timeZone: 'UTC'
|
||||
})} -
|
||||
{new Date(collection.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
<!-- display the duration in days -->
|
||||
<p>
|
||||
{$t('adventures.duration')}: {Math.floor(
|
||||
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1}{' '}
|
||||
days
|
||||
</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<!-- Content -->
|
||||
<div class="card-body p-6 space-y-4">
|
||||
<!-- Title -->
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
|
||||
<!-- Adventure Count -->
|
||||
<p class="text-sm text-base-content/70">
|
||||
{collection.adventures.length}
|
||||
{$t('navbar.adventures')}
|
||||
</p>
|
||||
|
||||
<!-- Date Range -->
|
||||
{#if collection.start_date && collection.end_date}
|
||||
<p class="text-sm font-medium">
|
||||
{$t('adventures.dates')}:
|
||||
{new Date(collection.start_date).toLocaleDateString(undefined, { timeZone: 'UTC' })} –
|
||||
{new Date(collection.end_date).toLocaleDateString(undefined, { timeZone: 'UTC' })}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{$t('adventures.duration')}: {Math.floor(
|
||||
(new Date(collection.end_date).getTime() - new Date(collection.start_date).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1} days
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-base-300">
|
||||
{#if type == 'link'}
|
||||
<button class="btn btn-primary" on:click={() => dispatch('link', collection.id)}>
|
||||
<Plus class="w-5 h-5 mr-1" />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-neutral-200">
|
||||
<DotsHorizontal class="w-6 h-6" />
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
|
||||
{#if linkedCollectionList && linkedCollectionList
|
||||
.map(String)
|
||||
.includes(String(collection.id))}
|
||||
<button
|
||||
class="btn btn-error btn-block"
|
||||
on:click={() => dispatch('unlink', collection.id)}
|
||||
>
|
||||
{#if type != 'link' && type != 'viewonly'}
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
><Launch class="w-5 h-5 mr-1" />{$t('adventures.open_details')}</button
|
||||
<Minus class="w-4 h-4" />
|
||||
{$t('adventures.remove_from_collection')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary btn-block"
|
||||
on:click={() => dispatch('link', collection.id)}
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{$t('adventures.add_to_collection')}
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex justify-between items-center">
|
||||
<button
|
||||
class="btn btn-neutral btn-sm flex-1 mr-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
>
|
||||
<Launch class="w-4 h-4" />
|
||||
{$t('adventures.open_details')}
|
||||
</button>
|
||||
{#if user && user.uuid == collection.user_id}
|
||||
<div class="dropdown dropdown-end">
|
||||
<button type="button" class="btn btn-square btn-sm btn-base-300">
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-64 p-2 shadow-xl border border-base-300"
|
||||
>
|
||||
{#if !collection.is_archived}
|
||||
<button class="btn btn-neutral mb-2" on:click={editAdventure}>
|
||||
<FileDocumentEdit class="w-6 h-6" />{$t('adventures.edit_collection')}
|
||||
</button>
|
||||
<button class="btn btn-neutral mb-2" on:click={() => (isShareModalOpen = true)}>
|
||||
<ShareVariant class="w-6 h-6" />{$t('adventures.share')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if collection.is_archived}
|
||||
<button class="btn btn-neutral mb-2" on:click={() => archiveCollection(false)}>
|
||||
<ArchiveArrowUp class="w-6 h-6 mr-1" />{$t('adventures.unarchive')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-neutral mb-2" on:click={() => archiveCollection(true)}>
|
||||
<ArchiveArrowDown class="w-6 h-6 mr" />{$t('adventures.archive')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
class="btn btn-warning"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
><TrashCan class="w-6 h-6" />{$t('adventures.delete')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if type == 'viewonly'}
|
||||
<button
|
||||
class="btn btn-neutral mb-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
><Launch class="w-5 h-5 mr-1" />{$t('adventures.open_details')}</button
|
||||
>
|
||||
{/if}
|
||||
</ul>
|
||||
{#if type != 'viewonly'}
|
||||
<li>
|
||||
<button class="flex items-center gap-2" on:click={editAdventure}>
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('adventures.edit_collection')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2"
|
||||
on:click={() => (isShareModalOpen = true)}
|
||||
>
|
||||
<ShareVariant class="w-4 h-4" />
|
||||
{$t('adventures.share')}
|
||||
</button>
|
||||
</li>
|
||||
{#if collection.is_archived}
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2"
|
||||
on:click={() => archiveCollection(false)}
|
||||
>
|
||||
<ArchiveArrowUp class="w-4 h-4" />
|
||||
{$t('adventures.unarchive')}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2"
|
||||
on:click={() => archiveCollection(true)}
|
||||
>
|
||||
<ArchiveArrowDown class="w-4 h-4" />
|
||||
{$t('adventures.archive')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
id="delete_collection"
|
||||
data-umami-event="Delete Collection"
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
>
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if type == 'viewonly'}
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2"
|
||||
on:click={() => goto(`/collections/${collection.id}`)}
|
||||
>
|
||||
<Launch class="w-4 h-4" />
|
||||
{$t('adventures.open_details')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,53 +7,220 @@
|
|||
let modal: HTMLDialogElement;
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
// Icons - following the worldtravel pattern
|
||||
import Collections from '~icons/mdi/folder-multiple';
|
||||
import Search from '~icons/mdi/magnify';
|
||||
import Clear from '~icons/mdi/close';
|
||||
import Link from '~icons/mdi/link-variant';
|
||||
|
||||
let collections: Collection[] = [];
|
||||
let filteredCollections: Collection[] = [];
|
||||
let searchQuery: string = '';
|
||||
|
||||
export let linkedCollectionList: string[] | null = null;
|
||||
|
||||
// Search functionality following worldtravel pattern
|
||||
$: {
|
||||
if (searchQuery === '') {
|
||||
filteredCollections = collections;
|
||||
} else {
|
||||
filteredCollections = collections.filter((collection) =>
|
||||
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
let res = await fetch(`/api/collections/all/`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
let result = await res.json();
|
||||
collections = result as Collection[];
|
||||
|
||||
if (result.type === 'success' && result.data) {
|
||||
collections = result.data.adventures as Collection[];
|
||||
} else {
|
||||
collections = result as Collection[];
|
||||
}
|
||||
|
||||
// Move linked collections to the front
|
||||
if (linkedCollectionList) {
|
||||
collections.sort((a, b) => {
|
||||
const aLinked = linkedCollectionList?.includes(a.id);
|
||||
const bLinked = linkedCollectionList?.includes(b.id);
|
||||
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
filteredCollections = collections;
|
||||
});
|
||||
|
||||
function close() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function link(event: CustomEvent<number>) {
|
||||
function link(event: CustomEvent<string>) {
|
||||
dispatch('link', event.detail);
|
||||
}
|
||||
|
||||
function unlink(event: CustomEvent<string>) {
|
||||
dispatch('unlink', event.detail);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch('close');
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics following worldtravel pattern
|
||||
$: linkedCount = linkedCollectionList ? linkedCollectionList.length : 0;
|
||||
$: totalCollections = collections.length;
|
||||
</script>
|
||||
|
||||
<dialog id="my_modal_1" class="modal">
|
||||
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div class="modal-box w-11/12 max-w-5xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
|
||||
<h1 class="text-center font-bold text-4xl mb-6">{$t('adventures.my_collections')}</h1>
|
||||
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
|
||||
{#each collections as collection}
|
||||
<CollectionCard {collection} type="link" on:link={link} />
|
||||
{/each}
|
||||
{#if collections.length === 0}
|
||||
<p class="text-center text-lg">{$t('adventures.no_collections_found')}</p>
|
||||
<div
|
||||
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
|
||||
role="dialog"
|
||||
on:keydown={handleKeydown}
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Header Section - Following worldtravel pattern -->
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<Collections class="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary bg-clip-text">
|
||||
{$t('adventures.my_collections')}
|
||||
</h1>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{filteredCollections.length}
|
||||
{$t('worldtravel.of')}
|
||||
{totalCollections}
|
||||
{$t('navbar.collections')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div class="stats stats-horizontal bg-base-200/50 border border-base-300/50">
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">{$t('collection.linked')}</div>
|
||||
<div class="stat-value text-lg text-success">{linkedCount}</div>
|
||||
</div>
|
||||
<div class="stat py-2 px-4">
|
||||
<div class="stat-title text-xs">{$t('collection.available')}</div>
|
||||
<div class="stat-value text-lg text-info">{totalCollections}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button class="btn btn-ghost btn-square" on:click={close}>
|
||||
<Clear class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('navbar.search')}
|
||||
class="input input-bordered w-full pl-10 pr-10 bg-base-100/80"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery.length > 0}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
|
||||
on:click={() => (searchQuery = '')}
|
||||
>
|
||||
<Clear class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery}
|
||||
<button class="btn btn-ghost btn-xs gap-1" on:click={() => (searchQuery = '')}>
|
||||
<Clear class="w-3 h-3" />
|
||||
{$t('worldtravel.clear_all')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="px-2">
|
||||
{#if filteredCollections.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<div class="p-6 bg-base-200/50 rounded-2xl mb-6">
|
||||
<Collections class="w-16 h-16 text-base-content/30" />
|
||||
</div>
|
||||
{#if searchQuery}
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||||
{$t('adventures.no_collections_found')}
|
||||
</h3>
|
||||
<p class="text-base-content/50 text-center max-w-md mb-6">
|
||||
{$t('collection.try_different_search')}
|
||||
</p>
|
||||
<button class="btn btn-primary gap-2" on:click={() => (searchQuery = '')}>
|
||||
<Clear class="w-4 h-4" />
|
||||
{$t('worldtravel.clear_filters')}
|
||||
</button>
|
||||
{:else}
|
||||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||||
{$t('adventures.no_collections_found')}
|
||||
</h3>
|
||||
<p class="text-base-content/50 text-center max-w-md">
|
||||
{$t('adventures.create_collection_first')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collections Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 p-4">
|
||||
{#each filteredCollections as collection}
|
||||
<CollectionCard
|
||||
{collection}
|
||||
type="link"
|
||||
on:link={link}
|
||||
bind:linkedCollectionList
|
||||
on:unlink={unlink}
|
||||
user={null}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div
|
||||
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-base-content/60">
|
||||
{linkedCount}
|
||||
{$t('adventures.collections_linked')}
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2" on:click={close}>
|
||||
<Link class="w-4 h-4" />
|
||||
{$t('adventures.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
@ -189,15 +189,58 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Form Actions -->
|
||||
|
||||
{#if !collection.start_date && !collection.end_date}
|
||||
<div class="mt-4">
|
||||
<div role="alert" class="alert alert-neutral">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{$t('adventures.collection_no_start_end_date')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{$t('adventures.save_next')}
|
||||
{$t('notes.save')}
|
||||
</button>
|
||||
<button type="button" class="btn" on:click={close}>
|
||||
{$t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if collection.is_public && collection.id}
|
||||
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm text-neutral-content">
|
||||
<p class=" font-semibold">{$t('adventures.share_collection')}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-card-foreground font-mono">
|
||||
{window.location.origin}/collections/{collection.id}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/collections/${collection.id}`
|
||||
);
|
||||
}}
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2"
|
||||
>
|
||||
{$t('adventures.copy_link')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|