1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 05:05:17 +02:00

Compare commits

...

306 commits
v0.9.0 ... main

Author SHA1 Message Date
Sean Morley
4e96e529f4
fix(adventure): enhance collection ownership validation in AdventureSerializer (#723) 2025-07-09 23:03:48 -04:00
Sean Morley
cadea118d3
Merge pull request #680 from seanmorley15/development
Date and Timezone fixes (lots of them!!)
2025-06-19 11:54:41 -04:00
Sean Morley
7a17e0e1d8 feat(calendar): add markdown rendering for event descriptions in modal 2025-06-19 11:53:24 -04:00
Sean Morley
6516bc56ef refactor(lodging): remove unused icon imports and add comment for check-out date logic 2025-06-19 11:40:04 -04:00
Sean Morley
a6b39f64d6 feat(calendar): add adventure detail link in event modal 2025-06-19 11:37:08 -04:00
Sean Morley
36f9022872 fix(lodging): remove console log and improve all-day event checks in lodging modal 2025-06-19 11:33:04 -04:00
Sean Morley
3b0ccdb6d3 feat(DateRangeCollapse): auto-detect all-day setting for transportation and lodging types 2025-06-18 22:29:37 -04:00
Sean Morley
9964398e25 feat(lodging): add check-in and check-out labels and enhance date handling for lodging events 2025-06-18 22:21:34 -04:00
Sean Morley
df24316837 feat(lodging): improve lodging date handling with all-day event support and timezone adjustments 2025-06-18 21:10:10 -04:00
Sean Morley
63e8e96d52 feat(collections): enhance lodging date handling with timezone support and all-day event formatting 2025-06-18 19:57:23 -04:00
Sean Morley
08cd3912c7 fix(config): correct appVersion string formatting 2025-06-18 19:24:51 -04:00
Sean Morley
eef8c92e82 feat(calendar): enhance event handling with timezone support and filtering capabilities 2025-06-18 19:03:32 -04:00
Sean Morley
3306b799df
Merge pull request #678 from seanmorley15/development
feat: add CollectionAllView component for unified display of adventur…
2025-06-18 14:29:01 -04:00
Sean Morley
8b108c5797 refactor(CollectionAllView): enhance mobile responsiveness and clean up unused imports 2025-06-18 14:28:03 -04:00
Sean Morley
93a489a778 feat: add CollectionAllView component for unified display of adventures, transportations, lodging, notes, and checklists with filtering and sorting capabilities
i18n: update translations for collection contents and sorting options in multiple languages

refactor: replace individual sections for adventures, transportations, lodging, notes, and checklists in the collection page with the new CollectionAllView component
2025-06-18 14:05:39 -04:00
Sean Morley
44ea7dff0c
Merge pull request #677 from seanmorley15/development
fix(adventure): add collection ID to adventure when creating a new ad…
2025-06-18 10:23:02 -04:00
Sean Morley
7ec4e5d0f5 fix(adventure): add collection ID to adventure when creating a new adventure 2025-06-18 10:20:30 -04:00
Sean Morley
380fe1364f
Merge pull request #676 from blitzdose/main
Fixed frontend returning corrupt binary data
2025-06-17 18:05:20 -04:00
Christian Zäske
69631848cf
Fixed frontend returning corrupt binary data 2025-06-18 00:00:51 +02:00
Sean Morley
7f285f2b1d
Merge pull request #675 from seanmorley15/development
fix(integration): update image entry retrieval to handle multiple col…
2025-06-17 16:11:12 -04:00
Sean Morley
4f7d408460
Merge pull request #674 from nordtektiger/patch-1
update readme to reflect username changes (sorry sean!)
2025-06-17 16:03:44 -04:00
Sean Morley
aed76a5689 fix(integration): update image entry retrieval to handle multiple collections and improve access control logic 2025-06-17 15:49:27 -04:00
Jacob
a556c49147
chore: update readme to reflect username changes (sorry sean!) 2025-06-17 18:59:13 +00:00
Sean Morley
ea4b6bd715
Add Adventures to Multiple Collections 2025-06-16 18:19:43 -04:00
Sean Morley
2fb1548f9f fix(recommendations): update Google Places API integration to new endpoint and response structure 2025-06-16 17:35:38 -04:00
Sean Morley
be8ac67161 fix(geocoding): update search_google function to use new Places API and improve response handling 2025-06-16 17:31:17 -04:00
Sean Morley
0636f0ec8c docs: update maintainer information and add testimonial from Open Source Daily 2025-06-16 14:00:58 -04:00
Sean Morley
09ffb6cdff fix(docker.md): update frontend configuration table for clarity and consistency 2025-06-16 12:12:37 -04:00
Sean Morley
930c98a607 fix(geocoding): improve error handling and response validation in search_google function 2025-06-16 11:47:36 -04:00
Sean Morley
cee9345bf1 feat: Enhance CollectionLink component with search functionality and statistics display
- Implemented search functionality to filter collections based on user input.
- Added statistics display for linked collections and total collections.
- Updated modal layout for better user experience, including a search bar and clear filters option.
- Improved accessibility and visual design of the modal and its components.

refactor: Update localization files for multiple languages

- Removed outdated delete collection warning messages.
- Added new keys for adventures available, collections linked, and other relevant phrases in various languages.
- Ensured consistency across localization files for better user experience.

fix: Adjust styles in worldtravel and collections pages

- Updated styles for quick stats section in worldtravel page for improved visibility.
- Ensured proper handling of sorting parameters in collections page navigation.
2025-06-15 18:28:48 -04:00
Sean Morley
ced1f94473 fix(adventure_view): restrict queryset to user-owned adventures only 2025-06-15 17:40:43 -04:00
Sean Morley
da65235277 Update screenshots for adventures, countries, dashboard, details, edit, itinerary, map, and regions 2025-06-15 13:24:18 -04:00
Sean Morley
ab5082317e Add "invalid_credentials" message to multiple language files
- Updated Spanish (es.json) to include "invalid_credentials": "Credenciales no válidas"
- Updated French (fr.json) to include "invalid_credentials": "Des références non valides"
- Updated Italian (it.json) to include "invalid_credentials": "Credenziali non valide"
- Updated Korean (ko.json) to include "invalid_credentials": "잘못된 자격 증명"
- Updated Dutch (nl.json) to include "invalid_credentials": "Ongeldige referenties"
- Updated Norwegian (no.json) to include "invalid_credentials": "Ugyldig legitimasjon"
- Updated Russian (ru.json) to include "invalid_credentials": "Неверные полномочия"
- Updated Swedish (sv.json) to include "invalid_credentials": "Ogiltiga referenser"
- Updated Chinese (zh.json) to include "invalid_credentials": "无效的凭据"
2025-06-15 12:56:53 -04:00
Sean Morley
c2074c1581 fix(collections): ensure linked collections are sorted correctly after fetching 2025-06-14 22:16:39 -04:00
Sean Morley
977843cbc6 fix(collections): remove debug log for API URL fetching 2025-06-14 22:01:56 -04:00
Sean Morley
b0e8000cf8 Refactor UI components for improved localization and styling
- Updated various headings and text elements to utilize localization functions for better internationalization support.
- Simplified gradient styles in headings to enhance readability.
- Adjusted adventure and travel statistics sections to reflect localized titles and descriptions.
- Enhanced filter options and buttons with localized text for clarity.
- Modified Tailwind CSS configuration to include a new color 'dim' for future styling needs.
2025-06-14 18:55:59 -04:00
Sean Morley
b5931c6c23 refactor(worldtravel): remove insert_id fields from city, country, and region models; update related migration
feat(search): enhance search results display with total results count and improved layout
fix(profile): update achievement levels based on adventure count; remove unused quick actions
refactor(shared): delete unused shared collections route and related components
feat(worldtravel): improve interactive map functionality and layout in world travel detail view
2025-06-14 14:05:30 -04:00
Sean Morley
151c76dbd1 Enhance user profile and world travel pages with improved UI and functionality
- Updated user profile page to include achievement calculations and enhanced styling for user information and statistics.
- Added icons for better visual representation of user stats and achievements.
- Improved layout for displaying adventures and collections with conditional rendering for empty states.
- Refactored world travel page to include search and filter functionality for cities, with a sidebar for progress and stats.
- Implemented completion percentage and progress bars for visited cities.
- Enhanced map integration with markers for visited and not visited cities, including toggle options for map labels.
2025-06-14 11:10:59 -04:00
Sean Morley
d4c76f8718 feat(map): implement dynamic basemap URL based on theme; update map styles across components 2025-06-13 23:49:14 -04:00
Sean Morley
badeac867d fix(worldtravel): remove unnecessary hover scale effect on country cards 2025-06-13 21:48:35 -04:00
Sean Morley
a99553ba0d refactor: remove archived collections page and related components; enhance world travel pages with improved UI and filtering options 2025-06-13 21:41:10 -04:00
Sean Morley
14eb4ca802 feat(collections): enhance collections page with sorting, filtering, and pagination features
- Updated the collections loading logic to include sorting and pagination parameters from the URL.
- Refactored the collections page to manage owned, shared, and archived collections with a tabbed interface.
- Added sorting functionality to allow users to sort collections by different attributes.
- Implemented a sidebar for filtering and sorting options.
- Improved the UI for better user experience, including a floating action button for creating new collections.
- Added a not found page for collections that do not exist, enhancing error handling.
2025-06-13 12:11:42 -04:00
Sean Morley
7eb96bcc2a
Merge pull request #663 from seanmorley15/dependabot/npm_and_yarn/frontend/npm_and_yarn-6ea9762674
chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 in /frontend in the npm_and_yarn group across 1 directory
2025-06-13 09:50:39 -04:00
Sean Morley
24cf3f844d
Merge pull request #665 from DesarrolloAntonio/fix-spanish-translations
fix: correct Spanish translations
2025-06-13 09:49:35 -04:00
Sean Morley
c60ced09c4
Merge branch 'development' into fix-spanish-translations 2025-06-13 09:48:18 -04:00
Antonio Corrales
05db98f968 fix: correct Spanish translations
- Fix incorrect translations and typos
- Fix inconsistent capitalization
- Keep proper names untranslated (Immich)
- Improve consistency in technical terms translation
- Correct 'Campo de golf' to 'Enlaces' (links)
- Change 'Ahorrar' to 'Guardar' (save)
- Fix various grammatical errors
2025-06-13 09:14:28 +02:00
Sean Morley
3f9a6767bd feat: Enhance Adventure and Collection Management
- Added support for multiple collections in AdventureSerializer, allowing adventures to be linked to multiple collections.
- Implemented validation to ensure collections belong to the current user during adventure creation and updates.
- Introduced a signal to update adventure publicity based on the public status of linked collections.
- Updated file permission checks to consider multiple collections when determining access rights.
- Modified AdventureImageViewSet and AttachmentViewSet to check access against collections instead of a single collection.
- Enhanced AdventureViewSet to support filtering and sorting adventures based on collections.
- Updated frontend components to manage collections more effectively, including linking and unlinking adventures from collections.
- Adjusted API endpoints and data structures to accommodate the new collections feature.
- Improved user experience with appropriate notifications for collection actions.
2025-06-12 15:54:01 -04:00
dependabot[bot]
f6b9802ba7
chore(deps): bump brace-expansion
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.11 to 1.1.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-12 05:57:26 +00:00
Sean Morley
71ff217323
Delete documentation/public/funding.json 2025-06-11 19:45:30 -04:00
Sean Morley
ef5dca79e5
Create funding.json 2025-06-11 19:42:45 -04:00
Sean Morley
d9070e68bb
Merge pull request #660 from ShalunBdk/patch-1
Added Russian translation
2025-06-11 19:12:42 -04:00
Sean Morley
f72fb50eb0
Update ru.json 2025-06-11 19:11:23 -04:00
Sean Morley
b59dcade3b
Merge pull request #661 from jlcs-es/patch-1
Update es.json with more contextual translations
2025-06-11 12:53:43 -04:00
José Luis Cánovas
164627b35b
Update es.json
Modified some translations with more contextual ones.
2025-06-11 17:17:47 +02:00
Александр
3f89fad67f
Update +layout.svelte 2025-06-11 14:59:41 +07:00
Александр
871e265001
Update Navbar.svelte 2025-06-11 14:58:55 +07:00
Александр
2cab28e921
Create ru.json 2025-06-11 14:49:55 +07:00
Sean Morley
8a46bd7ed3
Bug fixes, UI enhancements, Google Maps, Immich Integration Improvements 2025-06-09 09:18:47 -04:00
Sean Morley
f36de76501 fix: clarify instruction in PUBLIC_SERVER_URL comment in .env.example 2025-06-08 21:05:31 -04:00
Sean Morley
55f501d939 fix: handle user_id correctly in ChecklistItem creation and updates to avoid constraint issues 2025-06-08 13:15:43 -04:00
Sean Morley
0037d037cf fix: add Authelia to the list of supported social authentication services 2025-06-07 11:16:17 -04:00
Sean Morley
19baf6ab35 chore: update version to v0.10.0 across Dockerfiles, package.json, and configuration files; add changelog for v0.10.0 release 2025-06-07 11:14:27 -04:00
Sean Morley
20cdc2405f feat: add alert message in AdventureModal for GPX file tips 2025-06-07 10:58:02 -04:00
Sean Morley
297eb2916a fix: improve Docker installation documentation for clarity and completeness 2025-06-06 19:07:47 -04:00
Sean Morley
27a27545ca fix: update button styles in CategoryModal for improved UI consistency 2025-06-06 18:39:46 -04:00
Sean Morley
06a5bb06b3 feat: enhance CategoryModal with add/edit functionality and improve localization support 2025-06-06 14:20:37 -04:00
Sean Morley
c0f2d060db fix: update marker colors based on visit status for countries, regions, and cities 2025-06-06 11:00:46 -04:00
Sean Morley
2d7b6c85c9 fix: enhance Navbar component with scroll effect and improve layout responsiveness 2025-06-06 10:21:53 -04:00
Sean Morley
5d0132a2a8 fix: pin setuptools version and update docker-compose to use images 2025-06-06 09:44:01 -04:00
Sean Morley
39c664ab1a fix: update Dockerfiles with metadata labels and improve build process 2025-06-05 23:29:39 -04:00
Sean Morley
d91a4fbe98 fix: update link to installation options in quick start guide 2025-06-05 16:40:37 -04:00
Sean Morley
90624664f4 fix: ensure interactive terminal requirement for installation script 2025-06-05 16:33:34 -04:00
Sean Morley
5f9d0cd207 chore: update dependencies and improve script execution condition
- Updated vitepress from version 1.5.0 to 1.6.3 in package.json and pnpm-lock.yaml.
- Updated various dependencies in pnpm-lock.yaml including @docsearch/css, @docsearch/js, @iconify-json/simple-icons, @shikijs/core, and others to their latest versions.
- Modified install_adventurelog.sh to improve script execution condition by allowing it to run when piped input is detected.
2025-06-05 16:24:43 -04:00
Sean Morley
36a2e59d52 fix: add logging import to integrations views 2025-06-05 15:12:40 -04:00
Sean Morley
af5be0bc8f
Potential fix for code scanning alert no. 21: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-06-05 15:10:41 -04:00
Sean Morley
16840a6040 fix: update support button link in Navbar component 2025-06-05 15:02:01 -04:00
Sean Morley
ebee6f52e8 fix: update request data handling in AdventureImageViewSet and improve error handling in RecommendationsViewSet 2025-06-05 14:53:08 -04:00
Sean Morley
9d817a5ce9 feat: add Immich server connection validation and error handling in integration settings 2025-06-05 14:28:37 -04:00
Sean Morley
6fdfb86297 fix: update Docker images to use beta tags for frontend and backend services 2025-06-04 17:08:38 -04:00
Sean Morley
c0c91c8472 fix: update environment configuration and improve installer output messages 2025-06-04 16:59:41 -04:00
Sean Morley
d6ab4e9f64 feat: add auto-generation for item name in LocationDropdown and improve theme handling in Navbar 2025-06-04 14:41:29 -04:00
Sean Morley
4f342425ab
Update FUNDING.yml 2025-06-04 10:12:40 -04:00
Sean Morley
6c338b8c0f chore: update screenshots for adventures, countries, dashboard, details, edit, itinerary, map, and regions 2025-06-03 19:50:37 -04:00
Sean Morley
2f7103f5f3 fix: clear album selection when switching search categories in ImmichSelect 2025-06-03 19:16:44 -04:00
Sean Morley
d0c1ecd394 feat: enhance ImmichIntegrationView to support date range filtering and improve error handling for invalid date formats 2025-06-03 19:12:38 -04:00
Sean Morley
cf108ecd3a fix: update CollectionCard to use adventures from collection instead of a separate prop 2025-06-03 19:03:36 -04:00
Sean Morley
50a80a8116 fix: improve error handling for Immich image fetching and processing 2025-06-03 18:11:29 -04:00
Sean Morley
442a7724a0 feat: enhance Immich integration to support image downloading for shared users and improve access control for adventure images 2025-06-03 17:59:29 -04:00
Sean Morley
b336a24401 feat: enhance AdventureImageSerializer to support Immich integration and improve image URL handling 2025-06-03 17:16:28 -04:00
Sean Morley
45e195a84e feat: update Immich integration to use dynamic image URLs and enhance image retrieval logic 2025-06-02 21:25:07 -04:00
Sean Morley
937db00226 feat: add Google Maps integration with description and display status in settings 2025-06-01 23:09:48 -04:00
Sean Morley
0838a41156 feat: add unique constraint for immich_id per user in AdventureImage model and enhance Immich integration image retrieval 2025-06-01 22:50:26 -04:00
Sean Morley
06787bccf6 feat: enhance Immich integration with local copy option and validation for image handling 2025-06-01 19:55:12 -04:00
Sean Morley
f95afdc35c fix: update location blocks for protected media in nginx configuration 2025-06-01 12:47:08 -04:00
Sean Morley
c159e176b3 refactor: improve formatting and organization in nginx configuration 2025-05-31 21:54:45 -04:00
Sean Morley
b50447b1a2 refactor: clean up comments and improve readability in nginx configuration 2025-05-31 21:36:23 -04:00
Sean Morley
92f9bf6908 fix: standardize quotes in language object and improve dropdown z-index 2025-05-31 21:10:42 -04:00
Sean Morley
48df9ee56d
Update backend/server/main/settings.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-31 21:08:33 -04:00
Sean Morley
64bfda6bf0
Update backend/nginx.conf
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-31 21:08:20 -04:00
Sean Morley
8f1c60a440
Update backend/server/users/backends.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-31 21:08:09 -04:00
Sean Morley
806efd71bf feat: secure CSRF cookie based on frontend URL protocol 2025-05-31 17:30:33 -04:00
Sean Morley
e89f2a947e feat: enhance image navigation in adventure view with improved controls and indicators 2025-05-31 16:20:24 -04:00
Sean Morley
724aec1f3a feat: update get_num_visits method to improve user visit count retrieval 2025-05-31 15:46:15 -04:00
Sean Morley
514ee85767 feat: add distance calculation to Transportation model and update TransportationCard to display distance in km and miles 2025-05-30 12:33:30 -04:00
Sean Morley
53d370297e feat: enhance adventure save method to support skipping geocode threading 2025-05-29 21:04:07 -04:00
Sean Morley
ae16c12251 feat: update Docker Compose and environment configuration for dynamic port handling 2025-05-29 20:21:13 -04:00
Sean Morley
4e8024051c feat: implement background geocoding for adventure locations on save 2025-05-29 18:17:15 -04:00
Sean Morley
9d69935f22
Update backend/nginx.conf
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-29 18:02:05 -04:00
Sean Morley
787ac4a8b3 feat: add new translations for 'joined' and 'view_profile' in German locale 2025-05-29 17:48:02 -04:00
Sean Morley
81006af027 refactor: enhance UI components with improved styling and layout
- Updated CollectionCard, CountryCard, LodgingCard, NoteCard, RegionCard, TransportationCard, UserCard, and ShareModal components for better visual consistency and responsiveness.
- Introduced hover effects and transitions for a more interactive experience.
- Improved accessibility by ensuring proper alt text for images and using semantic HTML elements.
- Refactored date formatting logic into a utility function for reuse across components.
- Added new translations for profile viewing and joined date in the localization files.
2025-05-29 17:47:58 -04:00
Sean Morley
3acfc9f228 Fix banner formatting in AdventureLog installer script for improved aesthetics 2025-05-29 09:52:41 -04:00
Sean Morley
8be723b9ad Refactor authentication backends to enhance NoPasswordAuthBackend functionality; integrate Allauth for email login and improve password handling logic. 2025-05-28 12:21:43 -04:00
Sean Morley
a7128756bd Reorder authentication backends to prioritize NoPasswordAuthBackend while retaining Allauth and ModelBackend 2025-05-28 11:54:21 -04:00
Sean Morley
d41d46f15d Remove commented-out logging statements in login handling for cleaner code 2025-05-28 11:52:53 -04:00
Sean Morley
0adfdfa62f Refactor login cookie handling to improve parsing and error handling; ensure multiple Set-Cookie headers are processed correctly. 2025-05-28 11:43:03 -04:00
Sean Morley
fa52af8ad1 Add detailed logging for login process and cookie handling 2025-05-28 11:31:38 -04:00
Sean Morley
6be4acb196 Add authentication backends for Allauth and Django's ModelBackend 2025-05-28 11:06:21 -04:00
Sean Morley
9df0338c3d Add new translations for distance, itinerary, and linked items in multiple languages; update UI to reflect new localization keys. 2025-05-28 10:51:26 -04:00
Sean Morley
069bcfb58a Enhance dynamic port handling in installer and configuration files; update .env.example and docker-compose.yml to use environment variables for frontend and backend ports. 2025-05-27 22:42:08 -04:00
Sean Morley
1551fba9ab Refactor migration to set end_date only if start_date is present; improve data integrity during migration process. 2025-05-27 12:43:29 -04:00
Sean Morley
56b8b55b84 Refactor deleteCollection function to use DELETE method for API call; update endpoint to match new API structure. 2025-05-27 12:39:38 -04:00
Sean Morley
57aa2c9916 Enhance download-countries command with temporary SQLite database for efficient data processing; add batch size argument for improved performance and memory management. 2025-05-27 10:12:33 -04:00
Sean Morley
37866660d3 Adjust batch size in download-countries command for improved processing efficiency; update installer banner for better visual alignment. 2025-05-27 09:59:15 -04:00
Sean Morley
d34a9001c0 Enhance AdventureLog installer with Docker container check; improve service readiness feedback and success message formatting. 2025-05-26 21:09:00 -04:00
Sean Morley
bcd1f02131 Enhance download-countries command with batch processing feedback; improve logging for countries, regions, and cities processing. 2025-05-26 20:43:11 -04:00
Sean Morley
f2246921d4 Refactor download-countries command for improved memory efficiency and batch processing; enhance data import logic for countries, regions, and cities. 2025-05-26 20:39:24 -04:00
Sean Morley
575669aedf Refactor batch processing in download-countries command for clarity and safety; enhance service wait function in installer script by removing unnecessary whitespace. 2025-05-26 18:06:06 -04:00
Sean Morley
9eaaadc0f2 Enhance entrypoint script with a helper function for environment variable retrieval; improve PostgreSQL connection logic. Update installer script to add spacing for readability in service wait function. 2025-05-26 17:36:22 -04:00
Sean Morley
3f6aa67b3f Refactor database configuration to use a helper function for environment variables, improving compatibility with legacy setups. 2025-05-26 17:18:30 -04:00
Sean Morley
5e6d5305cc Update docker-compose to use beta images for frontend and backend services; clean up install script 2025-05-26 17:14:12 -04:00
Sean Morley
4fb25f63fd Update .env.example with additional optional configurations and change docker-compose to use development images for frontend and backend services 2025-05-26 17:07:52 -04:00
Sean Morley
40f54529a4 Refactor environment variable names in .env.example and settings.py for consistency; add install script for streamlined setup 2025-05-26 16:55:00 -04:00
Sean Morley
7707ac7769 Add .env.example and update docker-compose to use env_file for configuration 2025-05-26 14:11:31 -04:00
Sean Morley
b25bf4af27 Add Google Maps integration documentation and update docker-compose for API key 2025-05-26 13:59:18 -04:00
Sean Morley
f355ba48e2 Fix condition for Google Maps integration check in IntegrationView 2025-05-25 22:25:36 -04:00
Sean Morley
e56335d30f Refactor geocoding and integration handling: remove debug print, streamline reverse geocoding logic, and enhance integration response structure 2025-05-25 22:13:18 -04:00
Sean Morley
c123231bab Optimize country data import process: increase batch size, implement memory management, and streamline record creation and updates 2025-05-24 19:05:16 -04:00
Sean Morley
9e304f81fe Fix date display logic to handle undefined visit dates in DateRangeCollapse and adventure page 2025-05-24 18:56:59 -04:00
Sean Morley
1997f164b8 Handle exceptions in search method with a generic error message 2025-05-24 18:08:58 -04:00
Sean Morley
c0b9013576 Remove OpenStreetMap button from recommendations in collections page 2025-05-24 18:06:25 -04:00
Sean Morley
ce9faa28f8 Refactor recommendations feature: add RecommendationsViewSet, update routing, and remove OverpassViewSet 2025-05-24 18:00:05 -04:00
Sean Morley
68ba3c4b4d Add Google Maps API integration for geocoding and reverse geocoding functionality 2025-05-24 14:59:58 -04:00
Sean Morley
042d034594 Implement reverse geocoding search functionality and update type definitions 2025-05-24 14:12:06 -04:00
Sean Morley
ec2b285d50 Remove logging for error handling in reverse_geocode function 2025-05-23 23:17:47 -04:00
Sean Morley
03c76adc6d Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-05-23 23:15:41 -04:00
Sean Morley
4404064263 Adjust heading margin for improved layout on adventures page 2025-05-23 23:15:39 -04:00
Sean Morley
7fddca6fb0
Potential fix for code scanning alert no. 16: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-23 23:13:31 -04:00
Sean Morley
809cf98169 Enhance AdventureCard component with activity type display and rating system; update CardCarousel gradient fallback for no images 2025-05-23 23:00:15 -04:00
Sean Morley
d3d74f9f35 Add localization support for adventure and settings pages; enhance UI elements
- Updated Chinese translations in zh.json for various UI components including coordinates, sun times, and authentication settings.
- Refactored adventure page to utilize localization for visit counts, descriptions, and other text elements.
- Improved settings page by integrating localization for profile, security, email management, and integration sections.
- Enhanced visual consistency by updating card backgrounds and adding localized text for buttons and labels.
2025-05-23 17:22:28 -04:00
Sean Morley
e856a57498 Refactor code structure for improved readability and maintainability 2025-05-23 12:15:44 -04:00
Sean Morley
0d5792a99a Refactor adventure page layout and enhance UI components
- Updated the layout of the adventure page to improve visual hierarchy and user experience.
- Reorganized the structure of the hero section, including image navigation and quick info cards.
- Enhanced the display of adventure details, including user info, visit history, and sun times.
- Improved the handling of attachments and external links with better styling and accessibility.
- Added functionality for copying coordinates and links to the clipboard.
- Refined the map section to provide clearer location information and improved marker display.
- Cleaned up unused imports and optimized the code for better readability.
2025-05-23 11:23:24 -04:00
Sean Morley
2ccb8f5e0b Enhance geocoding functionality with host resolution and improved error handling; update AdventureModal for loading state management; refine LocationDropdown toast display logic; adjust world travel page for better documentation link visibility. 2025-05-23 10:46:37 -04:00
Sean Morley
0d800e8986
Merge pull request #587 from lkiesow/bool-case-sensitivity
Make boolean settings case insensitive
2025-05-22 21:22:36 -04:00
Sean Morley
3d9f4545a1
Merge branch 'development' into bool-case-sensitivity 2025-05-22 21:22:27 -04:00
Sean Morley
0a7db8985d
Merge pull request #608 from seanmorley15/dependabot/npm_and_yarn/documentation/npm_and_yarn-1b547a5c31
chore(deps): bump the npm_and_yarn group across 2 directories with 1 update
2025-05-22 21:19:41 -04:00
Sean Morley
c828f86570
Change note preview to render markdown content 2025-05-22 21:17:21 -04:00
Sean Morley
a1062e72cf Enhance Adventure model and serializers with visited status logic and toast notifications for marking visits 2025-05-22 21:13:31 -04:00
Sean Morley
84cd136401 Add bulk geocoding command and trigger geocoding action in admin 2025-05-22 20:29:05 -04:00
Sean Morley
ac32f9ac5b Add country field to AdventureSerializer with country code retrieval 2025-05-22 20:13:36 -04:00
Sean Morley
d52e302e9b Add geocoding functionality and enhance Adventure model with location fields 2025-05-22 20:05:13 -04:00
Sean Morley
14e71626f6 Enhance Nginx configuration for protected media files with PDF handling 2025-05-22 16:26:50 -04:00
Sean Morley
f96b6f5f65
Merge pull request #556 from janausis/oicd_login_fix
Frontend OICD Login Fix
2025-05-22 11:07:43 -04:00
Sean Morley
5dfe39468a
Add link to Mastodon profile in VitePress config 2025-05-21 14:14:59 -04:00
Sean Morley
a991f54d0a Add link to Mastodon profile in VitePress config 2025-05-21 14:14:39 -04:00
Florian Meinicke
274dafc47d Change note preview to render markdown content
in the Collection/Itinerary view

Closes #627
2025-05-21 07:33:20 +00:00
Sean Morley
5f19670ed9
Fixes [BUG] Cannot change a adventure from Private to Public #617 2025-05-19 11:52:51 -04:00
Sean Morley
bd9f3fc494
Update backend/server/adventures/views/adventure_view.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-19 11:28:04 -04:00
Sean Morley
d87d0e807f Fixes [BUG] Cannot change a adventure from Private to Public #617 2025-05-19 11:21:40 -04:00
Sean Morley
8b41a0f5a4
Update date validation to use UTC comparison and enhance documentation 2025-05-13 12:55:06 -04:00
Sean Morley
9435ccfa5a Add arrival and departure date labels to localization files 2025-05-13 12:54:06 -04:00
Sean Morley
890332f4b6 Update date validation to use UTC comparison and enhance documentation 2025-05-13 12:50:43 -04:00
Sean Morley
049c229799
Merge pull request #614 from seanmorley15/development
Development
2025-05-12 10:46:02 -04:00
Sean Morley
f15d7bfd1e Remove debug print statements from DisableCSRFForMobileLoginSignup middleware 2025-05-12 10:42:50 -04:00
Sean Morley
7c3c139e61 Add DisableCSRFForMobileLoginSignup middleware to handle CSRF checks for mobile login/signup requests 2025-05-12 10:42:26 -04:00
Sean Morley
1ff116ed00
Merge pull request #611 from seanmorley15/development
Adjust CollectionCard styles: remove overflow-hidden and increase dro…
2025-05-11 12:44:05 -04:00
Sean Morley
b0e8c025fc Adjust CollectionCard styles: remove overflow-hidden and increase dropdown width 2025-05-11 12:43:31 -04:00
Sean Morley
feeb682e14
Merge pull request #610 from seanmorley15/development
Replace crypto.randomUUID with a unique ID generation method for visi…
2025-05-11 11:50:58 -04:00
Sean Morley
6de737bbf8 Replace crypto.randomUUID with a unique ID generation method for visit objects and timezone selector instance 2025-05-11 11:50:29 -04:00
dependabot[bot]
6ca674ff7e
chore(deps): bump the npm_and_yarn group across 2 directories with 1 update
Bumps the npm_and_yarn group with 1 update in the /documentation directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 5.4.14 to 5.4.19
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.19/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.19/packages/vite)

Updates `vite` from 5.4.18 to 5.4.19
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.19/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.19/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.19
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 5.4.19
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-11 02:21:09 +00:00
Sean Morley
908d31a4a3
Adventure Times, Collection Ordering, Trip Maps 2025-05-10 22:19:43 -04:00
Sean Morley
4af80eb584 Add arrival and departure timezone translations in multiple languages 2025-05-10 22:10:55 -04:00
Sean Morley
d9d754a87c Fix date handling in DateRangeCollapse and TransportationModal; improve hash change handling in +page.svelte 2025-05-10 22:03:31 -04:00
Sean Morley
abc2d86dcf Enhance visit date display with timezone support and improved formatting 2025-05-10 21:40:18 -04:00
Sean Morley
330fabb3e0 Fix validation in Lodging model to check check-in and check-out dates; update LodgingCard to conditionally display timezone 2025-05-10 13:17:01 -04:00
Sean Morley
8538aa0b7c Validate ISO date in toLocalDatetime function and return empty string for invalid dates 2025-05-10 13:02:44 -04:00
Sean Morley
c9fa1d55f7 Refactor toLocalDatetime function to improve ISO date handling and return format 2025-05-10 13:02:06 -04:00
Sean Morley
ab189c8aff Improve UTC date formatting by adding validation for ISO date strings in formatUTCDate function 2025-05-10 12:49:43 -04:00
Sean Morley
1323d91e32 Add timezone support for visits, transportation, and lodging
- Introduced TIMEZONES constant in models.py to store valid timezone options.
- Updated Visit, Transportation, and Lodging models to include timezone fields.
- Modified serializers to include timezone fields in VisitSerializer, TransportationSerializer, and LodgingSerializer.
- Enhanced DateRangeCollapse component to handle timezone selection and formatting.
- Implemented timezone formatting functions in LodgingCard and TransportationCard components.
- Updated LodgingModal and TransportationModal to bind timezone data.
- Added VALID_TIMEZONES to dateUtils for consistent timezone management across the application.
2025-05-10 11:59:56 -04:00
Sean Morley
b30d6df964 Enhance timezone handling in AdventureModal and DateRangeCollapse components; add support for departure and arrival timezones in the TimezoneSelector and update localization for new timezone labels. 2025-05-10 10:47:00 -04:00
Sean Morley
89c4f1058a Fix date formatting for constraint dates in DateRangeCollapse component 2025-05-09 23:33:58 -04:00
Sean Morley
13bc748d0d Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-05-09 23:27:54 -04:00
Sean Morley
c6177c5a05 Refactor DateRangeCollapse component layout and improve styling 2025-05-09 23:27:53 -04:00
Sean Morley
e9c333642f
Merge pull request #589 from lkiesow/sm-hide-al
Move hiding AdventureLog to first Tailwind breakpoint
2025-05-09 23:13:08 -04:00
Sean Morley
07c0c36ab8
Potential fix for code scanning alert no. 14: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-09 21:35:33 -04:00
Sean Morley
b712d10d7e
Potential fix for code scanning alert no. 13: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-09 21:35:16 -04:00
Sean Morley
a5e44d29e6
Merge pull request #594 from lkiesow/mobile-map-full-width
Full width map on mobile
2025-05-09 21:33:29 -04:00
Sean Morley
a91018d792
Merge pull request #572 from lkiesow/theme-picker
Harmonize language and theme picker interface
2025-05-09 21:32:47 -04:00
Sean Morley
9f86688fe9 Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-05-09 21:31:39 -04:00
Sean Morley
4ce7ed7045 Improve layout by wrapping map link text in a paragraph element 2025-05-09 21:31:37 -04:00
Sean Morley
b74fe90512
Merge pull request #586 from lkiesow/collection-dropdown
Collection view selection on mobile devices
2025-05-09 21:30:54 -04:00
Sean Morley
73c664e549
Merge pull request #569 from lkiesow/note-preview
Show Note Preview on Card
2025-05-09 21:29:51 -04:00
Sean Morley
5e1f17fe2e
Merge pull request #591 from lkiesow/menu-text-size
Adjust main menu font size
2025-05-09 21:27:44 -04:00
Sean Morley
f3f75368df
Merge pull request #593 from lkiesow/google-maps
Open in Apple Maps or Google Maps
2025-05-09 21:19:58 -04:00
Sean Morley
ab12a2e7d8
Merge pull request #588 from lkiesow/collection-card-width
Unify collection card width
2025-05-09 21:19:20 -04:00
Sean Morley
cbb8c6283b
Merge pull request #600 from theshaun/patch-1
Update en.json - Correct spelling of Search
2025-05-09 21:18:28 -04:00
Sean Morley
0af0218a34
Merge branch 'development' into patch-1 2025-05-09 21:18:13 -04:00
Sean Morley
04f9227ae6 Add default category icon and improve visit display:
- Set default icon for empty category in AdventureModal
- Enhance layout for visit buttons and validation messages in DateRangeCollapse
- Update localization files to include "no visits" strings in multiple languages
2025-05-09 21:17:11 -04:00
Sean Morley
3caebd37dd Add additional localization strings for itinerary features in Polish, Swedish, and Chinese
- Added "additional_info", "invalid_date_range", "sunrise_sunset", and "timezone" keys to pl.json, sv.json, and zh.json.
- Updated existing strings for consistency across languages.
2025-05-09 15:59:48 -04:00
Sean Morley
311e2847cb Enhance visit display: Improve layout and formatting of visit dates and notes 2025-05-09 15:19:17 -04:00
Sean Morley
2c50ca0b1a Refactor date handling components: Replace DateRangeDropdown with DateRangeCollapse
- Introduced DateRangeCollapse.svelte to manage date range selection with timezone support.
- Removed DateRangeDropdown.svelte as it was redundant.
- Updated LodgingModal and TransportationModal to utilize DateRangeCollapse for date selection.
- Enhanced date conversion utilities to handle all-day events correctly.
- Adjusted TimezoneSelector for improved accessibility and focus management.
- Updated date handling logic in dateUtils.ts to support all-day events.
- Modified test page to reflect changes in date range component usage.
2025-05-09 10:24:29 -04:00
Lars Kiesow
bbad7b890c
Add support for OpenStreetMap
This patch adds an option to open an item in OpenStreetMap as well as in
Google Maps and Apple Maps.
2025-05-07 21:11:42 +02:00
Sean Morley
5c109bbbaf
Update config.mts 2025-05-06 19:40:36 -04:00
Sean Morley
c93d4865ce
Update config.mts 2025-05-06 16:57:32 -04:00
Sean Morley
8d0490fd81 fix: Update pageData check to use 'index.md' for JSON-LD transformation 2025-05-06 14:50:39 -04:00
Sean Morley
77be046f19 feat: Add JSON-LD structured data for homepage SEO enhancement 2025-05-06 14:47:03 -04:00
Sean Morley
827b150965 Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-05-06 14:38:33 -04:00
Sean Morley
7442bd70cd Update version to 0.9.0, add DateRangeDropdown component, enhance LodgingModal with price step, and add invalid date range message 2025-05-06 14:38:31 -04:00
Shaun Walker
645cc9728b
Update en.json - Correct spelling of Search 2025-05-03 20:50:06 +10:00
Sean Morley
d99ca18e1c
Merge pull request #590 from lkiesow/remove-invisible-mapmarker
Remove Invisible MapMarker
2025-04-30 22:29:27 -04:00
Sean Morley
39104b3fef
Merge pull request #595 from lesensei/development
Update fr.json
2025-04-27 12:14:31 -04:00
lesensei
5bdadd0f88
Merge pull request #1 from lesensei/fr-translations-update-1
Update fr.json
2025-04-27 18:07:26 +02:00
lesensei
f1f1cda799
Update fr.json 2025-04-27 18:05:27 +02:00
Lars Kiesow
f31db982ce
Full width map on mobile
This patch lets the map in the adventure details use the full screen
width on mobile instead of having one sixth of the screen empty.
2025-04-27 17:49:31 +02:00
Lars Kiesow
f6097a2d60
Open in Apple Maps or Google Maps
This patch provides the additional option to open a location in Google
maps as an alternative to Apple Maps.
2025-04-27 17:42:51 +02:00
Lars Kiesow
5f7bf52758
Adjust main menu font size
This patch slightly adjusts the font size of list items in the main
menu. I probably wouldn't mind making it even a bit bigger, but that's
probably worth a separate discussion.

Reasons for this adjustment:

1. Don't use different font sizes in the same lement.
   While the buttons rendered at 14px, the search text rendered at 16px.
   They should have the same font-size.

2. The buttons were below the base text size controlled by Tailwind CSS.
   This means, it also puts the font size below the recommended
   font-size for mobile devices (Google/Apple guidelines). This patch
   puts them at the base level.
2025-04-27 16:08:03 +02:00
Lars Kiesow
911ce67d9f
Remove Invisible MapMarker
This patch removes the map marker from the adventures list item of the
main menu dropdown. It is not being rendered and given that all other
elements do not have an icon, it is probably a remnant of old code and
left by accident.
2025-04-27 16:03:16 +02:00
Lars Kiesow
932036bc8b
Move hiding AdventureLog to first Tailwind breakpoint
This is a slight improvement to pull request #576. I noticed that on a
tablet, the AdventureLog in the navigation bar would not render, even
though there would be enpugh free space. This patch moves the breakpoint
for hiding the text one step further towards smaller devices.
2025-04-27 15:41:43 +02:00
Lars Kiesow
cd494fefee Unify collection card width
The width of collection cards can vary quite a bit. Additionally, the
cards look different than the cards within a collection. It would be
nice if the design would be more or leyy the same for all of them.

This patch adjusts the collection card design by adapting the classes
from the cards within the collection (I literally just copied the
classes from ChecklistCard). This is another step in making the user
interface more homogeneous.
2025-04-27 15:30:12 +02:00
Lars Kiesow
5136122ed9
Make boolean settings case insensitive
Having to use the Python syntax when it comes to case sensitivity for
booleans in environment variables can be unexpected and doesn't really
provide any benefit.

This patch makes all boolean settings case-insensitive. This means that,
for example, both `True` and `true` evaluate to `True` in Python.

This fixes #559
2025-04-26 23:27:25 +02:00
Lars Kiesow
e40ea028d0
Collection view selection on mobile devices
The tab-based selection of views in a collection doesn't really work on
mobile devices since it needs too much horizontal space. This leads to
text overflowing buttons as well as half of the tab bar disappearing
behind the right edge of the phone screen.

This patch modifies the navigation by keeping the current tabs in
desktop mode, but switching to a very compact dropdown on mobile
devices.

The functionality of both elements is identical.
2025-04-26 22:53:45 +02:00
Sean Morley
56bbbb0ffb Implement code changes to enhance functionality and improve performance 2025-04-26 11:22:17 -04:00
Sean Morley
228f79dbc3
Merge pull request #585 from lkiesow/node22
Upgrade Node.js to Version 22
2025-04-25 10:02:52 -04:00
Sean Morley
e44c153f9b
Merge pull request #576 from lkiesow/mobile-navbar
Improve overlapping on navbar on mobile devices
2025-04-25 10:00:49 -04:00
Lars Kiesow
3a8776c000
Show AdventureLog icon instead of text in mobile mode
This patch makes AdventureLog hide the text but show the app icon in
the navigation bar when in mobile mode.
2025-04-25 15:56:30 +02:00
Lars Kiesow
b8aa96b5b3
Improve overlapping on navbar on mobile devices
This patch makes it less likely for elements of the navigation bar to
overlap each other on mobile devices. It also makes spacing a bit more
homogeneous.

The patch basically just adjust some spacing as and hides the map icon
on mobile devices.
2025-04-25 15:56:20 +02:00
Lars Kiesow
be0e56728a
Upgrade Node.js to Version 22
This patch updates the Dockerfile to use Node.js 22 for running the
frontend. Given that the security support of Node.js 18 ends in 6 days
(30 Apr 2025), upgrading definitely makes sense.

Additionally, this also seems to fix the broken JSON generated by the
recently upgrraded version of @sveltejs/kit, thus fixing #584.
2025-04-25 14:19:07 +02:00
Sean Morley
85b4db87ec refactor: Simplify date handling by replacing updateLocalDates and updateUTCDates with updateLocalDate and updateUTCDate functions 2025-04-19 21:53:17 -04:00
Sean Morley
c12f94855d feat: Refactor date handling in TransportationModal and add utility functions for date conversion and validation 2025-04-19 21:44:40 -04:00
Sean Morley
6942f5e1bb feat: Add TimezoneSelector component and integrate Luxon for date handling 2025-04-18 23:06:36 -04:00
Sean Morley
7499722867 feat: Add server error handling and SVG asset for 500 error page 2025-04-18 21:55:30 -04:00
Sean Morley
1ea4022e80 Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-04-18 15:04:10 -04:00
Sean Morley
a8502884dc feat: Add tutorial video section to usage documentation 2025-04-18 15:04:04 -04:00
Sean Morley
b29461f29d
Merge pull request #575 from lkiesow/frontend-build-test
Simple Frontend Build Test
2025-04-18 11:51:49 -04:00
Sean Morley
983a038420
Update frontend-test.yml 2025-04-18 11:50:33 -04:00
Sean Morley
233d2be63f
Merge pull request #574 from lkiesow/transportation-card-badges
Prevent UI overlaps in transportation card
2025-04-18 11:49:52 -04:00
Sean Morley
64377c0300
Merge pull request #566 from lkiesow/dropdown-size
Fix rendering issue in adventure dropdown
2025-04-18 11:48:39 -04:00
Lars Kiesow
0f36f34bfb
Simple Frontend Build Test
This patch adds a simple test for checking if the frontend builds
properly. This is similar to the recently added backend check and will
be run automatically on pushes and pull requests.
2025-04-18 00:48:53 +02:00
Lars Kiesow
ea85c5fc5a Prevent UI overlaps in transportation card
Similar to #552 (658764f) the card title and badges of the
transportation card can overlap. This patch adjusts the transportation
card to list the badges below the title similar to the other cards.
2025-04-18 00:22:50 +02:00
Lars Kiesow
9a825e56e4 Harmonize language and theme picker interface
This patch adjusts the theme picker to more look and feel like the
language picker right next to it.
2025-04-16 00:45:29 +02:00
Sean Morley
9043ee9565
Merge pull request #570 from seanmorley15/dependabot/npm_and_yarn/frontend/npm_and_yarn-d34ec6d50c
chore(deps-dev): bump @sveltejs/kit from 2.8.3 to 2.20.6 in /frontend in the npm_and_yarn group across 1 directory
2025-04-15 09:33:39 -04:00
dependabot[bot]
a11400fa98
chore(deps-dev): bump @sveltejs/kit
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit).


Updates `@sveltejs/kit` from 2.8.3 to 2.20.6
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.20.6/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.20.6
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 23:21:27 +00:00
Lars Kiesow
44a260b5b6 Show Note preview on Card
Often, you end up having short notes or even just a simple link and it
is somewhat tedious to go into the details to retrieve the additional
information.

This patch displays a preview of the note content and a maximum of three
links on the notes card directly. This makes accessing information much
faster.

This fixes #562.
2025-04-15 01:05:02 +02:00
Lars Kiesow
49f7bf27e8 Fix rendering issue in adventure dropdown
This is a very simple patch fixing the rendering issue of the “Remove
from collection” option in the adventure dropdown.-

Together with #552, this should fix #539.
2025-04-14 18:44:15 +02:00
Sean Morley
dd01ada61e
Update docker.md
Fixes #560
2025-04-14 11:22:20 -04:00
Sean Morley
a974c8c275
Merge pull request #558 from lkiesow/test
Basic Integration Test for backend
2025-04-14 11:12:59 -04:00
Sean Morley
988cdc12e6
Merge pull request #557 from lkiesow/python-syntax-error
Fix Python Syntax Error
2025-04-14 11:11:48 -04:00
Lars Kiesow
548d43b563 Basic Integration Test for backend
This patch implements a very basic test for commits and pull requests to
be run on GitHub Actions. This does not yet check much, but would have
caught something like the recent syntax error.
2025-04-12 01:07:28 +02:00
Lars Kiesow
23426012af Fix Python Syntax Error
Commit 937c3c6a68 introduced a Python
syntax error, breaking the server. This fixes the issue by restoring the
probably accidental removal of one line of code.
2025-04-11 20:18:14 +02:00
Jannis Martensen
2b031f51ac fixed oicd not working on frontend login page 2025-04-11 12:35:05 +02:00
Sean Morley
d7496df100
Merge pull request #554 from andreatitolo/development
Adjusted Italian translation
2025-04-08 15:32:44 -04:00
Andrea Titolo
6f489b2734
Consistency in translating collections 2025-04-08 20:49:49 +02:00
Andrea Titolo
a09b3f379f
Update italian translation and terms 2025-04-08 19:26:11 +02:00
Sean Morley
937c3c6a68
Update backend/server/adventures/views/collection_view.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 19:17:26 -04:00
Sean Morley
43b8275fc1
Update documentation/docs/install/caddy.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 19:16:58 -04:00
Sean Morley
3b5240dffe
Update documentation/docs/troubleshooting/login_unresponsive.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 19:14:55 -04:00
Sean Morley
d2933854ff
Update documentation/docs/usage/usage.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-07 19:14:04 -04:00
Sean Morley
0ce101594f
Merge pull request #550 from lkiesow/collection-browser-navigation
Enable Browser Navigation in Collections
2025-04-06 20:27:25 -04:00
Sean Morley
b166058d36
Merge pull request #551 from larsl-net/dual-stack
fix: Reenable IPv6 for Backend
2025-04-06 20:26:36 -04:00
Sean Morley
15d34c88dc
Merge pull request #552 from lkiesow/lodging-card
Prevent UI overlaps in lodging card
2025-04-06 20:25:11 -04:00
Sean Morley
af2778ff61
Merge pull request #548 from lkiesow/users-reedirect
Prevent unnecessary redirect when requesting users
2025-04-06 20:23:53 -04:00
Sean Morley
2982a4044d
Merge pull request #547 from lkiesow/caddy
Document deployment using Caddy
2025-04-06 20:22:53 -04:00
Lars Kiesow
658764fb58
Prevent UI overlaps in lodging card
This patch prevents overlapping UI eelements in the ledging card. It
adjusts the UI to be more like the adventure cards when it comes to font
size and element placing.

This partly fixes #539
2025-04-07 01:42:12 +02:00
Lars Lehmann
847193a0ae
fix: Reenable IPv6 for Backend 2025-04-06 22:55:13 +02:00
Lars Kiesow
8531855f46 Enable Browser Navigation in Collections
If you are in a collection – e.g on “All Linked Items” – and go into an
adventure, going back will cause you to end up on the itinerary. This is
quite annoying if you have a number of options for a trip linked already
but whenever you go back, you have to scroll up again, click on “All
Linked Items”, and scroll down again to get to the next adventure in
line.

This patch makes AdventureLog remember the tab you were in and going
back and forth in the browser history will actually work.
2025-04-06 21:09:27 +02:00
Lars Kiesow
af8a9acbae
Prevent unnecessary redirect when requesting users
Opening the share dialog, the frontend is requesting `/auth/users/`
which is always redirected to `/auth/users`. That's an unnecessary extra
step.

This patch makes the front-end request `/auth/users` in the first place.
2025-04-06 14:37:37 +02:00
Lars Kiesow
8041f67ba1
Document deployment using Caddy
This patch adds documentation on how to run AdventureLog with Caddy as a
reverse proxy.

Signed-off-by: Lars Kiesow <lkiesow@uos.de>
2025-04-06 12:32:19 +02:00
Sean Morley
cd833884e8
Merge pull request #546 from eidsheim98/nb-locale
Norwegian translations
2025-04-01 16:41:26 -04:00
Nikolai Eidsheim
47c219affd Added Norwegian translation 2025-04-01 22:35:03 +02:00
Nikolai Eidsheim
cde293c4bd Merge remote-tracking branch 'origin/main' into nb-locale
Updating nb-locale branch
2025-04-01 21:03:47 +02:00
Sean Morley
04c3402e14 fix: Remove duplicate file extension validation for md and pdf formats 2025-03-23 16:40:55 -04:00
Sean Morley
b4c5e22662 fix: Correct file extension validation for gpx, md, and pdf formats 2025-03-23 16:40:08 -04:00
Sean Morley
7d5750049b Merge branch 'development' of github.com:seanmorley15/AdventureLog into development 2025-03-22 12:25:55 -04:00
Sean Morley
16a7772003 feat: Add additional adventure type and endpoint for sunrise/sunset information 2025-03-22 12:25:53 -04:00
Sean Morley
731b7e9db1
chore(docker): Fix supervisor logging to stdout 2025-03-22 10:21:26 -04:00
ClumsyAdmin
ca4ef79837 fix supervisor logging 2025-03-21 20:51:58 -04:00
Sean Morley
13d3b24ec2 chore: Reduce Gunicorn worker count from 4 to 2 for optimized resource usage 2025-03-21 20:27:46 -04:00
ClumsyAdmin
113d41ca30 Merge branch 'main' of github.com:ClumsyAdmin/AdventureLog 2025-03-21 20:25:40 -04:00
ClumsyAdmin
b71109fd09 potential fix: set Supervisor priorities to ensure Gunicorn starts before nginx to prevent 502 errors 2025-03-21 20:25:25 -04:00
Sean Morley
a3cd940065 feat: Pass collection data to adventure, transportation, lodging, and checklist components 2025-03-21 17:35:58 -04:00
Sean Morley
6eebd5b70a
chore(docker): Refactor service startup to use supervisord and foreground processes 2025-03-21 17:34:23 -04:00
Sean Morley
44ede92b92
Merge branch 'development' into main 2025-03-21 17:34:03 -04:00
Sean Morley
fe25f8e2c8 feat: Add start_date to collection ordering and enhance localization for itinerary features 2025-03-21 17:31:33 -04:00
Sean Morley
794df82ec6 feat: Enhance AdventureModal date handling for all-day events and improve localization in collections page 2025-03-21 16:30:03 -04:00
Sean Morley
db63b6e7d8 feat: Add usage guide for AdventureLog and enhance overview with personal message from the maintainer 2025-03-21 13:35:29 -04:00
ClumsyAdmin
4ccfa6e42c refactor docker startup to use supervisord 2025-03-21 12:02:23 -04:00
Sean Morley
f79b06f6b3 feat: Add troubleshooting guide for unresponsive login and registration, enhance collection modal alerts, and improve localization for itinerary features 2025-03-20 22:28:23 -04:00
Sean Morley
1042a3edcc refactor: Remove debug print statement from NoPasswordAuthBackend authentication method 2025-03-20 22:08:22 -04:00
Sean Morley
771579ef3d feat: Improve adventure date grouping to handle all-day events and enhance date formatting 2025-03-20 10:26:41 -04:00
Sean Morley
dbd417ca87
fix: Pass collection prop to various card components in the collections page 2025-03-19 13:50:32 -04:00
Sean Morley
6e1fbbfc3a feat: Pass collection prop to various card components in the collections page 2025-03-19 13:49:46 -04:00
Sean Morley
76ee4c73e0 feat: Add changelog for v0.9.0 and update configuration to include it in the documentation 2025-03-19 09:58:53 -04:00
Sean Morley
f554bb8777 feat: Enhance date handling in AdventureModal and related components for improved localization and all-day event support 2025-03-18 21:07:34 -04:00
Sean Morley
876c5e83b4 feat: Implement chronological itinerary path visualization with GeoJSON for adventures, transportation, and lodging 2025-03-18 18:16:25 -04:00
Sean Morley
1dc8e10758 feat: Add "all day" localization strings for multiple languages and enhance transportation date handling 2025-03-18 17:40:32 -04:00
Sean Morley
9fd2a142cb feat: Update Visit model to use DateTimeField for start and end dates, and enhance AdventureModal with datetime-local inputs 2025-03-18 14:04:31 -04:00
Nikolai Eidsheim
a899579a02 Started work on norwegian translations 2025-02-06 21:33:17 +00:00
163 changed files with 20457 additions and 8388 deletions

47
.env.example Normal file
View 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
View 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
View file

@ -1 +1,2 @@
github: seanmorley15
buy_me_a_coffee: seanmorley15

64
.github/workflows/backend-test.yml vendored Normal file
View 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
View 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
View file

@ -1,4 +1,5 @@
# Ignore everything in the .venv folder
.venv/
.vscode/settings.json
.pnpm-store/
.pnpm-store/
.env

13
.vscode/settings.json vendored
View file

@ -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"
]
}

View file

@ -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 💖

View file

@ -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"]

View file

@ -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

View file

@ -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;
}
}
}
}

View file

@ -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

View file

@ -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)

View file

@ -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

View 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

View file

@ -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()

View file

@ -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)

View file

@ -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),
),
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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'),
),
]

View file

@ -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),
]

View file

@ -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'),
),
]

View file

@ -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',
),
]

View file

@ -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'),
),
]

View file

@ -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',
),
]

View file

@ -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',
),
]

View file

@ -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:

View file

@ -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

View file

@ -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')

View 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'])

View file

@ -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

View file

@ -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:

View file

@ -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 *

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View 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)

View file

@ -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})

View file

@ -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)

View file

@ -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.'),
),
]

View file

@ -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):

View file

@ -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()

View file

@ -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', '')

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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'))

View file

@ -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')

View file

@ -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',
),
]

View file

@ -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"

View file

@ -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
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 809 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

After

Width:  |  Height:  |  Size: 637 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Before After
Before After

View file

@ -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:

View file

@ -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" },
],
},
});

View file

@ -0,0 +1,123 @@
# AdventureLog v0.10.0 - Trip Maps, Google Maps Integration & Quick Deploy Script
Released 06-10-2025
Hi everyone,
Im 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 youve 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 dont 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, weve 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 trips 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

View file

@ -0,0 +1,133 @@
# AdventureLog v0.9.0 - Smart Recommendations, Attachments, and Maps
Released 03-19-2025
Hi travelers! 🌍
Im 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. Lets dive into whats 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 💖
[![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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)**

View file

@ -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

View 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.

View file

@ -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

View 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.

View file

@ -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. Heres 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! 🎉

View file

@ -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

View 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.

View file

@ -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! 🌍

View 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!

View 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>

View file

@ -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 youve 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 youve visited or plan to visit with an organized list, filtering by visit status. Dive deeper into each countrys 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—dont 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 youre 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>

View file

@ -1,6 +1,6 @@
{
"devDependencies": {
"vitepress": "^1.5.0"
"vitepress": "^1.6.3"
},
"scripts": {
"docs:dev": "vitepress dev",

View file

@ -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

View file

@ -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"]

View file

@ -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

File diff suppressed because it is too large Load diff

View 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

View 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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

Some files were not shown because too many files have changed in this diff Show more