Activities, Trails, Wanderer + Strava Integration, UI Refresh, Devops Improvments, and more (#785)

* Implement code changes to enhance functionality and improve performance

* Update nl.json

Fix Dutch translations.

* feat(security): add Trivy security scans for Docker images and source code

* feat(security): restructure Trivy scans for improved clarity and organization

* fix(dependencies): update Django version to 5.2.2

* style(workflows): standardize quotes and fix typo in frontend-test.yml

* feat(workflows): add job names for clarity in backend and frontend test workflows

* refactor(workflows): remove path filters from pull_request and push triggers in backend and frontend workflows

* feat(workflows): add paths to push and pull_request triggers for backend and frontend workflows

* refactor(workflows): simplify trigger paths for backend and frontend workflows
fix(dependencies): add overrides for esbuild in frontend package.json

* fix(package): add missing pnpm overrides for esbuild in package.json

* fix(workflows): add missing severity parameter for Trivy filesystem scan

* fix(workflows): add missing severity parameter for Docker image scans in Trivy workflow

* fix(workflows): remove MEDIUM severity from Trivy scans in security workflow

* added-fix-image-deletion (#681)

* added-fix-image-deletion

* feat(commands): add image cleanup command to find and delete unused files

* fix(models): ensure associated AdventureImages are deleted and files cleaned up on Adventure deletion

* fix(models): ensure associated Attachment files are deleted and their filesystem cleaned up on Adventure deletion

---------

Co-authored-by: ferdousahmed <taninme@gmail.com>
Co-authored-by: Sean Morley

* Rename Adventures to Locations (#696)

* Refactor user_id to user in adventures and related models, views, and components

- Updated all instances of user_id to user in the adventures app, including models, serializers, views, and frontend components.
- Adjusted queries and filters to reflect the new user field naming convention.
- Ensured consistency across the codebase for user identification in adventures, collections, notes, and transportation entities.
- Modified frontend components to align with the updated data structure, ensuring proper access control and rendering based on user ownership.

* Refactor adventure-related views and components to use "Location" terminology

- Updated GlobalSearchView to replace AdventureSerializer with LocationSerializer.
- Modified IcsCalendarGeneratorViewSet to use LocationSerializer instead of AdventureSerializer.
- Created new LocationImageViewSet for managing location images, including primary image toggling and image deletion.
- Introduced LocationViewSet for managing locations with enhanced filtering, sorting, and sharing capabilities.
- Updated ReverseGeocodeViewSet to utilize LocationSerializer.
- Added ActivityTypesView to retrieve distinct activity types from locations.
- Refactored user views to replace AdventureSerializer with LocationSerializer.
- Updated frontend components to reflect changes from "adventure" to "location", including AdventureCard, AdventureLink, AdventureModal, and others.
- Adjusted API endpoints in frontend routes to align with new location-based structure.
- Ensured all references to adventures are replaced with locations across the codebase.

* refactor: rename adventures to locations across the application

- Updated localization files to replace adventure-related terms with location-related terms.
- Refactored TypeScript types and variables from Adventure to Location in various routes and components.
- Adjusted UI elements and labels to reflect the change from adventures to locations.
- Ensured all references to adventures in the codebase are consistent with the new location terminology.

* Refactor code structure for improved readability and maintainability

* feat: Implement location details page with server-side loading and deletion functionality

- Added +page.server.ts to handle server-side loading of additional location info.
- Created +page.svelte for displaying location details, including images, visits, and maps.
- Integrated GPX file handling and rendering on the map.
- Updated map route to link to locations instead of adventures.
- Refactored profile and search routes to use LocationCard instead of AdventureCard.

* docs: Update terminology from "Adventure" to "Location" and enhance project overview

* docs: Clarify collection examples in usage documentation

* feat: Enable credentials for GPX file fetch and add CORS_ALLOW_CREDENTIALS setting

* Refactor adventure references to locations across the backend and frontend

- Updated CategoryViewSet to reflect location context instead of adventures.
- Modified ChecklistViewSet to include locations in retrieval logic.
- Changed GlobalSearchView to search for locations instead of adventures.
- Adjusted IcsCalendarGeneratorViewSet to handle locations instead of adventures.
- Refactored LocationImageViewSet to remove unused import.
- Updated LocationViewSet to clarify public access for locations.
- Changed LodgingViewSet to reference locations instead of adventures.
- Modified NoteViewSet to prevent listing all locations.
- Updated RecommendationsViewSet to handle locations in parsing and response.
- Adjusted ReverseGeocodeViewSet to search through user locations.
- Updated StatsViewSet to count locations instead of adventures.
- Changed TagsView to reflect activity types for locations.
- Updated TransportationViewSet to reference locations instead of adventures.
- Added new translations for search results related to locations in multiple languages.
- Updated dashboard and profile pages to reflect location counts instead of adventure counts.
- Adjusted search routes to handle locations instead of adventures.

* Update banner image

* style: Update stats component background and border for improved visibility

* refactor: Rename AdventureCard and AdventureModal to LocationCard and LocationModal for consistency

* Import and Export Functionality (#698)

* feat(backup): add BackupViewSet for data export and import functionality

* Fixed frontend returning corrupt binary data

* feat(import): enhance import functionality with confirmation check and improved city/region/country handling

* Potential fix for code scanning alert no. 29: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Refactor response handling to use arrayBuffer instead of bytes

* Refactor image cleanup command to use LocationImage model and update import/export view to include backup and restore functionality

* Update backup export versioning and improve data restore warning message

* Enhance image navigation and localization support in modal components

* Refactor location handling in Immich integration components for consistency

* Enhance backup and restore functionality with improved localization and error handling

* Improve accessibility by adding 'for' attribute to backup file input label

---------

Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* refactor(serializers): rename Location to Adventure and update related fields

* refactor(serializers): rename Adventure to Location and update related fields

* chore(requirements): update pillow version to 11.3.0

* Add PT-BR translations (#739)

* Fixed frontend returning corrupt binary data

* fix(adventure): enhance collection ownership validation in AdventureSerializer (#723)

* Add PT-BR translations

Add translation for Brazilian Portuguese to the project;

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>

---------

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>
Co-authored-by: Christian Zäske <blitzdose@gmail.com>

* fix: update date formatting for adventure items to include timezone

* Image/attachment overhaul, activities, trails and integrations with Strava and Wanderer (#726)

* refactor(models, views, serializers): rename LocationImage and Attachment to ContentImage and ContentAttachment, update related references

* feat: Enhance collection sharing and location management features

- Implemented unsharing functionality in CollectionViewSet, including removal of user-owned locations from collections.
- Refactored ContentImageViewSet to support multiple content types and improved permission checks for image uploads.
- Added user ownership checks in LocationViewSet for delete operations.
- Enhanced collection management in the frontend to display both owned and shared collections separately.
- Updated Immich integration to handle access control based on location visibility and user permissions.
- Improved UI components to show creator information and manage collection links more effectively.
- Added loading states and error handling in collection fetching logic.

* feat: enhance transportation card and modal with image handling

- Added CardCarousel component to TransportationCard for image display.
- Implemented privacy indicator with Eye and EyeOff icons.
- Introduced image upload functionality in TransportationModal, allowing users to upload multiple images.
- Added image management features: remove image and set primary image.
- Updated Transportation and Location types to include images as ContentImage array.
- Enhanced UI for image upload and display in modal, including selected images preview and current images management.

* feat: update CardCarousel component to handle images, name, and icon props across various cards

* feat: add Discord link to AboutModal and update appVersion in config

* feat: add LocationQuickStart and LocationVisits components for enhanced location selection and visit management

- Implemented LocationQuickStart.svelte for searching and selecting locations on a map with reverse geocoding.
- Created LocationVisits.svelte to manage visit dates and notes for locations, including timezone handling and validation.
- Updated types to remove location property from Attachment type.
- Modified locations page to integrate NewLocationModal for creating and editing locations, syncing updates with adventures.

* feat: update button styles and add back and close functionality in location components

* Collection invite system

* feat: update CollectionSerializer to include 'shared_with' as a read-only field; update app version; add new background images and localization strings for invites

* feat: add Strava integration with OAuth flow and activity management

- Implemented IntegrationView for listing integrations including Immich, Google Maps, and Strava.
- Created StravaIntegrationView for handling OAuth authorization and token exchange.
- Added functionality to refresh Strava access tokens when needed.
- Implemented endpoints to fetch user activities from Strava and extract essential information.
- Added Strava logo asset and integrated it into the frontend settings page.
- Updated settings page to display Strava integration status.
- Enhanced location management to include trails with create, edit, and delete functionalities.
- Updated types and localization files to support new features.

* feat: enhance Strava integration with user-specific settings and management options; update localization strings

* feat: update Strava integration settings and add Wanderer logo; enhance user experience with active section management

* Add StravaActivity and Activity types to types.ts

- Introduced StravaActivity type to represent detailed activity data from Strava.
- Added Activity type to encapsulate user activities, including optional trail and GPX file information.
- Updated Location type to include an array of activities associated with each visit.

* feat: streamline location and activity management; enhance Strava import functionality and add activity handling in server actions

* feat: add ActivityCard component and update LocationVisits to use it; modify Activity type to reference trail as string

* feat: add geojson support to ActivitySerializer and ActivityCard; enhance location page with activity summaries and GPS tracks

* feat: add trails property to recommendation object in collection page

* feat: add Wanderer integration with authentication and management features

* feat: implement Wanderer integration with trail management and UI components; enhance settings for reauthentication

* feat: add measurement system field to CustomUser model and update related serializers, migrations, and UI components

* feat: add measurement system support across ActivityCard, StravaActivityCard, NewLocationModal, LocationVisits, and related utility functions

* feat: enhance Wanderer integration with trail data fetching and UI updates; add measurement system support

* feat: add TrailCard component for displaying trail details with measurement system support

* feat: add wanderer link support in TrailSerializer and TrailCard; update measurement system handling in location page

* feat: integrate memcached for caching in Wanderer services; update Docker, settings, and supervisord configurations

* feat: add activity statistics to user profile; include distance, moving time, elevation, and total activities

* feat: enhance import/export functionality to include trails and activities; update UI components and localization

* feat: integrate NewLocationModal across various components; update location handling and state management

* Refactor Location and Visit types: Replace visits structure in Location with Visit type and add location, created_at, and updated_at fields to Visit

* feat: enhance permissions and validation in activity, trail, and visit views; add unique constraint to CollectionInvite model

* feat: sync visits when updating adventures in collection page

* feat: add geojson support for attachments and refactor GPX handling in location page

* chore: remove unused dependencies from pnpm-lock.yaml

* feat: add Strava and Wanderer integration documentation and configuration options

* Add support for Japanese and Arabic languages in localization

* Add new localization strings for Russian, Swedish, and Chinese languages

- Updated translations in ru.json, sv.json, and zh.json to include new phrases related to collections, activities, and integrations.
- Added strings for leaving collections, loading collections, and quick start instructions.
- Included new sections for invites and Strava integration with relevant messages.
- Enhanced Google Maps integration descriptions for clarity.

* Add localization support for activity-related features and update UI labels

- Added new Russian, Swedish, and Chinese translations for activity statistics, achievements, and related terms.
- Updated UI components to use localized strings for activity statistics, distance, moving time, and other relevant fields.
- Enhanced user experience by ensuring all relevant buttons and labels are translated and accessible.

* fix: update appVersion to reflect the latest development version

* feat: add getActivityColor function and integrate activity color coding in map and location pages

* feat: add support for showing activities and visited cities on the map

* feat: update map page to display counts for visited cities and activities

* fix: remove debug print statement from IsOwnerOrSharedWithFullAccess permission class

* feat: add MapStyleSelector component and integrate basemap selection in map page

* feat: enhance basemap functions with 3D terrain support and update XYZ style handling

* feat: add management command to recalculate elevation data from GPX files and update activity view to handle elevation data extraction

* feat: update MapStyleSelector component and enhance basemap options for improved user experience

* feat: refactor activity model and admin to use sport_type, update serializers and components for improved activity handling

* feat: update Activity model string representation to use sport_type instead of type

* feat: update activity handling to use sport_type for color determination in map and location components

* feat: Add attachments support to Transportation and Lodging types

- Updated Transportation and Lodging types to include attachments array.
- Enhanced localization files for multiple languages to include new strings related to attachments, lodging, and transportation.
- Added error and success messages for attachment removal and upload information.
- Included new prompts for creating and updating lodging and transportation details across various languages.

* feat: Enhance activity statistics and breakdown by category in user profile

* feat: Add SPORT_CATEGORIES for better organization of sports types and update StatsViewSet to use it

* feat: Enhance CategoryDropdown for mobile responsiveness and add category creation functionality

* feat: Update inspirational quote in adventure log

* feat: Localize navigation labels in Navbar and add translation to en.json

* feat: Update navigation elements to use anchor tags for better accessibility and add new fields to signup form

* Translate login button text to support internationalization

* feat: Refactor location visit status logic and add utility function for visited locations count

* chore: Upgrade GitHub Actions and remove unused timezone import

* fix: Update Docker image tags in GitHub Actions workflow for consistency

* fix: Update Docker image build process to use BuildKit cache for improved performance

* chore: Remove unused imports from stats_view.py for cleaner code

* Increase background image opacity on login and signup pages for improved visibility

* fix: Add postgresql-client to runtime dependencies in Dockerfile

* fix: Update workflow files to include permissions for GitHub Actions

* fix: Update esbuild version to ^0.25.9 in package.json and pnpm-lock.yaml for compatibility

* chore: improve Chinese translation (#796)

* fix: update adventure log quote and remove unused activity type field

* fix: optimize import process by using get_or_create for visited cities and regions

* fix: update README to reflect changes from adventures to locations and enhance feature descriptions

* fix: update documentation to reflect changes from adventures to locations and enhance feature descriptions

* Update google_maps_integration.md (#743)

* Update google_maps_integration.md

Explain APIs needed for AdventureLogs versions.

Fixes #731 and #727

* Fix a typo google_maps_integration.md

---------

Co-authored-by: Sean Morley <98704938+seanmorley15@users.noreply.github.com>

* fix: update appVersion to reflect the main branch version

* fix: update image source for satellite map in documentation

* Update frontend/src/lib/components/NewLocationModal.svelte

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add localization updates for multiple languages

- Japanese (ja.json): Added new activity-related phrases and checklist terms.
- Korean (ko.json): Included activity breakdown and checklist enhancements.
- Dutch (nl.json): Updated activity descriptions and added checklist functionalities.
- Norwegian (no.json): Enhanced activity and checklist terminology.
- Polish (pl.json): Added new phrases for activities and checklist management.
- Brazilian Portuguese (pt-br.json): Updated activity-related terms and checklist features.
- Russian (ru.json): Included new phrases for activities and checklist management.
- Swedish (sv.json): Enhanced activity descriptions and checklist functionalities.
- Chinese (zh.json): Added new activity-related phrases and checklist terms.

* fix: enhance image upload handling to support immich_id

* Add "not_enabled" message for Strava integration in multiple languages

- Updated Spanish, French, Italian, Japanese, Korean, Dutch, Norwegian, Polish, Brazilian Portuguese, Russian, Swedish, and Chinese locale files to include a new message indicating that Strava integration is not enabled in the current instance.

---------

Signed-off-by: Lucas Zampieri <lzampier@redhat.com>
Co-authored-by: Ycer0n <37674033+Ycer0n@users.noreply.github.com>
Co-authored-by: taninme <5262715+taninme@users.noreply.github.com>
Co-authored-by: ferdousahmed <taninme@gmail.com>
Co-authored-by: Christian Zäske <blitzdose@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lucas Zampieri <lcasmz54@gmail.com>
Co-authored-by: pplulee <pplulee@live.cn>
Co-authored-by: Cathelijne Hornstra <github@hornstra.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sean Morley
2025-08-19 08:50:45 -04:00
committed by GitHub
parent 4e96e529f4
commit a3f0eda63f
220 changed files with 27763 additions and 6653 deletions

View File

@@ -33,146 +33,210 @@
}
</script>
<dialog id="about_modal" class="modal backdrop-blur-md bg-opacity-70">
<dialog id="about_modal" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="modal-box rounded-xl shadow-lg backdrop-blur-lg bg-white/80 dark:bg-gray-900/80 transition-transform duration-300 ease-out transform scale-100"
class="modal-box w-11/12 max-w-2xl 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"
>
<!-- Branding -->
<div class="text-center">
<h3
class="text-2xl font-extrabold text-gray-800 dark:text-white flex items-center justify-center"
>
{$t('about.about')} AdventureLog
<img src="/favicon.png" alt="Map Logo" class="w-12 h-12 ml-3 inline-block" />
</h3>
<p class="mt-2 text-gray-500 dark:text-gray-300 text-sm">
AdventureLog
<a
href={versionChangelog}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{appVersion}
</a>
</p>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<img src="/favicon.png" alt="AdventureLog" class="w-12 h-12" />
</div>
<div>
<h1 class="text-2xl font-bold text-primary">
{$t('about.about')} AdventureLog
</h1>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" on:click={close}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Links and Details -->
<div class="mt-4 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
Sean Morley
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.license')}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{$t('about.source_code')}
</a>
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{$t('about.message')}</p>
</div>
<!-- Content -->
<div class="space-y-4">
<!-- Version & Developer Info -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">{$t('about.version')}</div>
<div class="text-lg font-bold text-primary">{appVersion}</div>
<a
href={versionChangelog}
target="_blank"
rel="noopener noreferrer"
class="text-sm link link-primary"
>
{$t('about.view_changelog')}
</a>
</div>
<div>
<div class="text-sm text-base-content/60">{$t('about.developer')}</div>
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="text-lg font-semibold link link-primary"
>
Sean Morley
</a>
<div class="text-sm text-base-content/60">{$t('about.message')}</div>
</div>
</div>
</div>
</div>
<!-- Divider -->
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div>
<!-- Map Services -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold text-primary mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
/>
</svg>
{$t('about.attributions')}
</h3>
{#if integrations && integrations?.google_maps}
<div class="flex items-center gap-2">
<span class="text-sm text-base-content/60">{$t('about.nominatim_1')}</span>
<a
href="https://developers.google.com/maps/terms"
target="_blank"
rel="noopener noreferrer"
class="link link-primary font-semibold"
>
Google Maps Platform
</a>
</div>
{:else if integrations && !integrations?.google_maps}
<div class="flex items-center gap-2">
<span class="text-sm text-base-content/60">{$t('about.nominatim_1')}</span>
<a
href="https://operations.osmfoundation.org/policies/nominatim/"
target="_blank"
rel="noopener noreferrer"
class="link link-primary font-semibold"
>
OpenStreetMap Nominatim
</a>
</div>
{:else}
<div class="text-sm text-base-content/60">{$t('about.generic_attributions')}</div>
{/if}
<p class="text-sm text-base-content/60">{$t('about.other_attributions')}</p>
</div>
</div>
<!-- Liscense info -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<h3 class="font-bold text-primary mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{$t('about.license_info')}
</h3>
<p class="text-sm text-base-content/60 mb-2">
© {copyrightYear}
<a
href="https://seanmorley.com"
target="_blank"
rel="noopener noreferrer"
class="link link-primary"
>
Sean Morley
</a>
</p>
<p class="text-sm text-base-content/60">
{$t('about.license')}
</p>
<!-- OSS Acknowledgments -->
<div class="text-left">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
{$t('about.oss_attributions')}
</h3>
{#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"
href="https://github.com/seanmorley15/AdventureLog/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
class="link link-primary mt-2"
>
Google Maps
{$t('about.view_license')}
</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}
</div>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{$t('about.other_attributions')}</p>
<!-- Links -->
<div class="card bg-base-200/30 border border-base-300">
<div class="card-body p-4">
<div class="flex flex-wrap gap-3">
<a
href="https://github.com/seanmorley15/AdventureLog"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
GitHub →
</a>
<a
href="https://seanmorley.com/sponsor"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
{$t('about.sponsor')}
</a>
<!-- documentation -->
<a
href="https://adventurelog.app"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
{$t('navbar.documentation')}
</a>
<!-- discord -->
<a
href="https://discord.gg/wRbQ9Egr8C"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline btn-sm"
>
Discord →
</a>
</div>
</div>
</div>
</div>
<!-- Close Button -->
<div class="flex justify-center mt-6">
<button
class="px-6 py-2 text-sm font-medium text-white bg-primary rounded-full shadow-md hover:shadow-lg hover:scale-105 transform transition"
on:click={close}
>
<!-- Footer -->
<div class="flex items-center justify-between mt-6 pt-4 border-t border-base-300">
<div class="text-sm text-base-content/60">
{$t('about.thank_you')}
</div>
<button class="btn btn-primary btn-sm" on:click={close}>
{$t('about.close')}
</button>
</div>
</div>
</dialog>
<style>
.modal {
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease-in-out;
}
.modal-box {
max-width: 600px;
padding: 2rem;
animation: slideUp 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,251 @@
<script lang="ts">
import type { Activity, Trail, TransportationVisit, Visit } from '$lib/types';
import { t } from 'svelte-i18n';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import RunFastIcon from '~icons/mdi/run-fast';
import FileIcon from '~icons/mdi/file';
import TrashIcon from '~icons/mdi/trash-can';
import SpeedometerIcon from '~icons/mdi/speedometer';
import TrendingUpIcon from '~icons/mdi/trending-up';
import ClockIcon from '~icons/mdi/clock';
import CaloriesIcon from '~icons/mdi/fire';
import LocationIcon from '~icons/mdi/map-marker';
import { formatDateInTimezone } from '$lib/dateUtils';
import { getDistance, getElevation } from '$lib';
export let activity: Activity;
export let trails: Trail[];
export let visit: Visit | TransportationVisit;
export let measurementSystem: 'metric' | 'imperial' = 'metric';
export let readOnly: boolean = false;
$: trail = activity.trail ? trails.find((t) => t.id === activity.trail) : null;
function deleteActivity(visitId: string, activityId: string) {
dispatch('delete', { visitId, activityId });
}
function formatDuration(isoString: string | null): string {
if (!isoString) return '';
// Simple ISO 8601 duration parsing for display
const match = isoString.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return isoString;
const hours = parseInt(match[1] || '0');
const minutes = parseInt(match[2] || '0');
const seconds = parseInt(match[3] || '0');
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
}
function formatSpeed(speed: number | null): string {
if (!speed) return '';
const convertedSpeed = measurementSystem === 'imperial' ? speed * 2.237 : speed * 3.6;
return `${convertedSpeed.toFixed(1)} ${measurementSystem === 'imperial' ? 'mph' : 'km/h'}`;
}
</script>
<div class="bg-base-200/50 p-4 rounded-lg border border-base-300/50">
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<RunFastIcon class="w-4 h-4 text-success flex-shrink-0" />
<h5 class="font-semibold text-base truncate">{activity.name}</h5>
<div class="flex gap-1">
{#if activity.sport_type}
<span class="badge badge-outline badge-sm">{activity.sport_type}</span>
{/if}
</div>
</div>
</div>
{#if !readOnly}
<button
class="btn btn-error btn-xs tooltip tooltip-top ml-2"
data-tip="Delete Activity"
on:click={() => deleteActivity(visit.id, activity.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
{/if}
</div>
<!-- Main Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-3">
{#if activity.distance}
<div class="bg-base-100/50 p-2 rounded text-center">
<div class="text-lg font-bold text-primary">
{getDistance(measurementSystem, activity.distance)}
</div>
<div class="text-xs text-base-content/70">
{measurementSystem === 'imperial' ? 'miles' : 'km'}
</div>
</div>
{/if}
{#if activity.moving_time}
<div class="bg-base-100/50 p-2 rounded text-center">
<div class="text-lg font-bold text-secondary">
{formatDuration(activity.moving_time)}
</div>
<div class="text-xs text-base-content/70">{$t('adventures.moving_time')}</div>
</div>
{/if}
{#if activity.elevation_gain}
<div class="bg-base-100/50 p-2 rounded text-center">
<div class="text-lg font-bold text-success">
{getElevation(measurementSystem, activity.elevation_gain)}
</div>
<div class="text-xs text-base-content/70">
{measurementSystem === 'imperial' ? 'ft' : 'm'}
</div>
</div>
{/if}
{#if activity.average_speed}
<div class="bg-base-100/50 p-2 rounded text-center">
<div class="text-lg font-bold text-accent">
{formatSpeed(activity.average_speed)}
</div>
<div class="text-xs text-base-content/70">{$t('adventures.average_speed')}</div>
</div>
{/if}
</div>
<!-- Additional Details -->
<div class="space-y-2 text-xs text-base-content/80">
<!-- Time Details -->
{#if activity.elapsed_time || activity.rest_time}
<div class="flex items-center gap-1">
<ClockIcon class="w-3 h-3" />
<span class="flex gap-4">
{#if activity.elapsed_time}
<span>{$t('adventures.total')}: {formatDuration(activity.elapsed_time)}</span>
{/if}
{#if activity.rest_time}
<span>{$t('adventures.rest')}: {formatDuration(activity.rest_time)}</span>
{/if}
</span>
</div>
{/if}
<!-- Elevation Details -->
{#if activity.elev_high || activity.elev_low || activity.elevation_loss}
<div class="flex items-center gap-1">
<TrendingUpIcon class="w-3 h-3" />
<span class="flex gap-4">
{#if activity.elev_high}
<span
>{$t('adventures.high')}: {getElevation(
measurementSystem,
activity.elev_high
)}{measurementSystem === 'imperial' ? 'ft' : 'm'}</span
>
{/if}
{#if activity.elev_low}
<span
>{$t('adventures.low')}: {getElevation(
measurementSystem,
activity.elev_low
)}{measurementSystem === 'imperial' ? 'ft' : 'm'}</span
>
{/if}
{#if activity.elevation_loss}
<span
>{getElevation(measurementSystem, activity.elevation_loss)}{measurementSystem ===
'imperial'
? 'ft'
: 'm'}</span
>
{/if}
</span>
</div>
{/if}
<!-- Speed Details -->
{#if activity.max_speed}
<div class="flex items-center gap-1">
<SpeedometerIcon class="w-3 h-3" />
<span>{$t('adventures.max_speed')}: {formatSpeed(activity.max_speed)}</span>
</div>
{/if}
<!-- Performance Metrics -->
{#if activity.average_cadence || activity.calories}
<div class="flex items-center gap-4">
{#if activity.average_cadence}
<span>{$t('adventures.cadence')}: {activity.average_cadence} rpm</span>
{/if}
{#if activity.calories}
<span class="flex items-center gap-1">
<CaloriesIcon class="w-3 h-3" />
{activity.calories}
{$t('adventures.calories')}
</span>
{/if}
</div>
{/if}
<!-- Trail Information -->
{#if trail}
<div class="flex items-center gap-1">
<LocationIcon class="w-3 h-3" />
<span>{$t('adventures.trail')}: <span class="font-medium">{trail.name}</span></span>
</div>
{/if}
<!-- Date Information -->
{#if activity.start_date}
<div class="border-t border-base-300/50 pt-2">
<div>
Started: {formatDateInTimezone(
activity.start_date,
activity.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
)}
</div>
{#if activity.timezone}
<div class="text-xs text-base-content/60">
{$t('adventures.timezone')}: {activity.timezone}
</div>
{/if}
</div>
{/if}
<!-- Location Information -->
{#if activity.start_lat && activity.start_lng}
<div class="flex items-center gap-1">
<LocationIcon class="w-3 h-3" />
<span
>{$t('adventures.start')}: {activity.start_lat.toFixed(4)}, {activity.start_lng.toFixed(
4
)}</span
>
{#if activity.end_lat && activity.end_lng && (activity.end_lat !== activity.start_lat || activity.end_lng !== activity.start_lng)}
<span class="ml-2"
>{$t('adventures.end')}: {activity.end_lat.toFixed(4)}, {activity.end_lng.toFixed(
4
)}</span
>
{/if}
</div>
{/if}
<!-- GPX File -->
{#if activity.gpx_file}
<div class="flex items-center gap-1 pt-2 border-t border-base-300/50">
<FileIcon class="w-3 h-3" />
<a href={activity.gpx_file} target="_blank" class="link link-primary text-xs">
{$t('adventures.view_gpx')}
</a>
</div>
{/if}
</div>
</div>

View File

@@ -1,940 +0,0 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import type { Adventure, Attachment, Category, Collection } from '$lib/types';
import { addToast } from '$lib/toasts';
import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n';
export let 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; immich_id: string | null }[] = [];
let warningMessage: string = '';
let constrainDates: boolean = false;
let categories: Category[] = [];
const allowedFileTypes = [
'.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'
];
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, 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 = '';
let adventure: Adventure = {
id: '',
name: '',
visits: [],
link: null,
description: null,
activity_types: [],
rating: NaN,
is_public: false,
latitude: NaN,
longitude: NaN,
location: null,
images: [],
user_id: null,
category: {
id: '',
name: '',
display_name: '',
icon: '',
user_id: ''
},
attachments: []
};
export let adventureToEdit: Adventure | null = null;
adventure = {
id: adventureToEdit?.id || '',
name: adventureToEdit?.name || '',
link: adventureToEdit?.link || null,
description: adventureToEdit?.description || null,
activity_types: adventureToEdit?.activity_types || [],
rating: adventureToEdit?.rating || NaN,
is_public: adventureToEdit?.is_public || false,
latitude: adventureToEdit?.latitude || NaN,
longitude: adventureToEdit?.longitude || NaN,
location: adventureToEdit?.location || null,
images: adventureToEdit?.images || [],
user_id: adventureToEdit?.user_id || null,
visits: adventureToEdit?.visits || [],
is_visited: adventureToEdit?.is_visited || false,
category: adventureToEdit?.category || {
id: '',
name: '',
display_name: '',
icon: '',
user_id: ''
},
attachments: adventureToEdit?.attachments || []
};
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
let categoryFetch = await fetch('/api/categories/categories');
if (categoryFetch.ok) {
categories = await categoryFetch.json();
} else {
addToast('error', $t('adventures.category_fetch_error'));
}
// Check for Immich Integration
let res = await fetch('/api/integrations/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.error) {
immichIntegration = false;
} else if (data.id) {
immichIntegration = true;
copyImmichLocally = data.copy_locally || false;
} else {
immichIntegration = false;
}
}
});
let url: string = '';
let imageError: string = '';
let wikiImageError: string = '';
let triggerMarkVisted: boolean = false;
let isLoading: boolean = false;
images = adventure.images || [];
$: {
if (!adventure.rating) {
adventure.rating = NaN;
}
}
function deleteAttachment(event: CustomEvent<string>) {
adventure.attachments = adventure.attachments.filter(
(attachment) => attachment.id !== event.detail
);
}
let attachmentName: string = '';
let attachmentToEdit: Attachment | null = null;
async function editAttachment() {
if (attachmentToEdit) {
let res = await fetch(`/api/attachments/${attachmentToEdit.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: attachmentToEdit.name })
});
if (res.ok) {
let newAttachment = (await res.json()) as Attachment;
adventure.attachments = adventure.attachments.map((attachment) => {
if (attachment.id === newAttachment.id) {
return newAttachment;
}
return attachment;
});
attachmentToEdit = null;
addToast('success', $t('adventures.attachment_update_success'));
} else {
addToast('error', $t('adventures.attachment_update_error'));
}
}
}
let selectedFile: File | null = null;
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length) {
selectedFile = input.files[0];
}
}
async function uploadAttachment(event: Event) {
event.preventDefault();
if (!selectedFile) {
console.error('No files selected');
return;
}
const file = selectedFile;
const formData = new FormData();
formData.append('file', file);
formData.append('adventure', adventure.id);
formData.append('name', attachmentName);
try {
const res = await fetch('/adventures?/attachment', {
method: 'POST',
body: formData
});
if (res.ok) {
const newData = deserialize(await res.text()) as { data: Attachment };
adventure.attachments = [...adventure.attachments, newData.data];
addToast('success', $t('adventures.attachment_upload_success'));
attachmentName = '';
} else {
addToast('error', $t('adventures.attachment_upload_error'));
}
} catch (err) {
console.error(err);
addToast('error', $t('adventures.attachment_upload_error'));
} finally {
// Reset the file input for a new upload
if (fileInput) {
fileInput.value = '';
}
}
}
let imageSearch: string = adventure.name || '';
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
images = images.filter((image) => image.id !== id);
adventure.images = images;
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
let isDetails: boolean = true;
function saveAndClose() {
dispatch('save', adventure);
close();
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
images = images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
adventure.images = images;
} else {
console.error('Error in makePrimaryImage:', res);
}
}
async function handleMultipleFiles(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (const file of files) {
await uploadImage(file);
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
});
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,
immich_id: null
};
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
async function fetchImage() {
try {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
imageError = $t('adventures.no_image_url');
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
await uploadImage(file);
url = '';
} catch (e) {
imageError = $t('adventures.image_fetch_failed');
}
}
async function fetchWikiImage() {
let res = await fetch(`/api/generate/img/?name=${imageSearch}`);
let data = await res.json();
if (!res.ok) {
wikiImageError = $t('adventures.image_fetch_failed');
return;
}
if (data.source) {
let imageUrl = data.source;
let res = await fetch(imageUrl);
let blob = await res.blob();
let file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' });
wikiImageError = '';
let formData = new FormData();
formData.append('image', file);
formData.append('adventure', adventure.id);
let res2 = await fetch(`/adventures?/image`, {
method: 'POST',
body: formData
});
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,
immich_id: null
};
images = [...images, newImage];
adventure.images = images;
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
wikiImageError = $t('adventures.wiki_image_error');
}
}
}
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
async function generateDesc() {
let res = await fetch(`/api/generate/desc/?name=${adventure.name}`);
let data = await res.json();
if (data.extract?.length > 0) {
adventure.description = data.extract;
wikiError = '';
} else {
wikiError = $t('adventures.no_description_found');
}
}
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 == '') {
if (categories.some((category) => category.name === 'general')) {
adventure.category = categories.find(
(category) => category.name === 'general'
) as Category;
} else {
adventure.category = {
id: '',
name: 'general',
display_name: 'General',
icon: '🌍',
user_id: ''
};
}
}
// add this collection to the adventure
if (collection && collection.id) {
adventure.collections = [collection.id];
}
let res = await fetch('/api/adventures', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(adventure)
});
let data = await res.json();
if (data.id) {
adventure = data as Adventure;
isDetails = false;
warningMessage = '';
addToast('success', $t('adventures.adventure_created'));
} else {
warningMessage = findFirstValue(data) as string;
console.error(data);
addToast('error', $t('adventures.adventure_create_error'));
}
} else {
let res = await fetch(`/api/adventures/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(adventure)
});
let data = await res.json();
if (data.id) {
adventure = data as Adventure;
isDetails = false;
warningMessage = '';
addToast('success', $t('adventures.adventure_updated'));
} else {
warningMessage = Object.values(data)[0] as string;
addToast('error', $t('adventures.adventure_update_error'));
}
}
imageSearch = adventure.name;
isLoading = false;
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{adventureToEdit ? $t('adventures.edit_adventure') : $t('adventures.new_adventure')}
</h3>
{#if adventure.id === '' || isDetails}
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Grid layout for form fields -->
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3"> -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<div>
<label for="name">{$t('adventures.name')}<span class="text-red-500">*</span></label
><br />
<input
type="text"
id="name"
name="name"
bind:value={adventure.name}
class="input input-bordered w-full"
required
/>
</div>
<div>
<label for="link"
>{$t('adventures.category')}<span class="text-red-500">*</span></label
><br />
<CategoryDropdown bind:categories bind:selected_category={adventure.category} />
</div>
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={adventure.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(adventure.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 1)}
checked={adventure.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 2)}
checked={adventure.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 3)}
checked={adventure.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 4)}
checked={adventure.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (adventure.rating = 5)}
checked={adventure.rating === 5}
/>
{#if adventure.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (adventure.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
<div>
<div>
<label for="link">{$t('adventures.link')}</label><br />
<input
type="text"
id="link"
name="link"
bind:value={adventure.link}
class="input input-bordered w-full"
/>
</div>
</div>
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={adventure.description} />
<div class="mt-2">
<div class="tooltip tooltip-right" data-tip={$t('adventures.wiki_desc')}>
<button type="button" class="btn btn-neutral mt-2" on:click={generateDesc}
>{$t('adventures.generate_desc')}</button
>
</div>
<p class="text-red-500">{wikiError}</p>
</div>
</div>
{#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">
<span class="label-text">{$t('adventures.public_adventure')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
name="is_public"
bind:checked={adventure.is_public}
/>
</label>
</div>
</div>
{/if}
</div>
</div>
<LocationDropdown bind:item={adventure} bind:triggerMarkVisted {initialLatLng} />
<div class="collapse collapse-plus bg-base-200 mb-4 overflow-visible">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.tags')} ({adventure.activity_types?.length || 0})
</div>
<div class="collapse-content">
<input
type="text"
id="activity_types"
name="activity_types"
hidden
bind:value={adventure.activity_types}
class="input input-bordered w-full"
/>
<ActivityComplete bind:activities={adventure.activity_types} />
</div>
</div>
<DateRangeCollapse type="adventure" {collection} bind:visits={adventure.visits} />
<div>
<div class="mt-4">
{#if warningMessage != ''}
<div role="alert" class="alert alert-warning mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{$t('adventures.warning')}: {warningMessage}</span>
</div>
{/if}
<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>
</div>
{:else}
<div class="modal-action items-center">
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.attachments')} ({adventure.attachments?.length || 0})
</div>
<div class="collapse-content">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each adventure.attachments as attachment}
<AttachmentCard
{attachment}
on:delete={deleteAttachment}
allowEdit
on:edit={(e) => (attachmentToEdit = e.detail)}
/>
{/each}
</div>
<div class="flex gap-2 m-4">
<input
type="file"
id="fileInput"
class="file-input file-input-bordered w-full max-w-xs"
accept={allowedFileTypes.join(',')}
on:change={handleFileChange}
/>
<input
type="text"
class="input input-bordered w-full"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentName}
/>
<button class="btn btn-neutral" on:click={uploadAttachment}>
{$t('adventures.upload')}
</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) => {
e.preventDefault();
editAttachment();
}}
>
<div class="flex gap-2 m-4">
<input
type="text"
class="input input-bordered w-full"
placeholder={$t('adventures.attachment_name')}
bind:value={attachmentToEdit.name}
/>
<button type="submit" class="btn btn-neutral">{$t('transportation.edit')}</button>
</div>
</form>
{/if}
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.images')} ({adventure.images?.length || 0})
</div>
<div class="collapse-content">
<label for="image" class="block font-medium mb-2">
{$t('adventures.image')}
</label>
<form class="flex flex-col items-start gap-2">
<input
type="file"
name="image"
class="file-input file-input-bordered w-full max-w-sm"
bind:this={fileInput}
accept="image/*"
id="image"
multiple
on:change={handleMultipleFiles}
/>
<input type="hidden" name="adventure" value={adventure.id} id="adventure" />
</form>
<div class="mb-4">
<label for="url" class="block font-medium mb-2">
{$t('adventures.url')}
</label>
<div class="flex gap-2">
<input
type="text"
id="url"
name="url"
bind:value={url}
class="input input-bordered flex-1"
placeholder="Enter image URL"
/>
<button class="btn btn-neutral" type="button" on:click={fetchImage}>
{$t('adventures.fetch_image')}
</button>
</div>
</div>
<div class="mb-4">
<label for="name" class="block font-medium mb-2">
{$t('adventures.wikipedia')}
</label>
<div class="flex gap-2">
<input
type="text"
id="name"
name="name"
bind:value={imageSearch}
class="input input-bordered flex-1"
placeholder="Search Wikipedia for images"
/>
<button class="btn btn-neutral" type="button" on:click={fetchWikiImage}>
{$t('adventures.fetch_image')}
</button>
</div>
{#if wikiImageError}
<p class="text-red-500">{$t('adventures.wiki_image_error')}</p>
{/if}
</div>
{#if immichIntegration}
<ImmichSelect
{adventure}
on:fetchImage={(e) => {
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}
<div class="divider"></div>
{#if images.length > 0}
<h1 class="font-semibold text-xl mb-4">{$t('adventures.my_images')}</h1>
<div class="flex flex-wrap gap-4">
{#each images as image}
<div class="relative h-32 w-32">
<button
type="button"
class="absolute top-1 right-1 btn btn-error btn-xs z-10"
on:click={() => removeImage(image.id)}
>
</button>
{#if !image.is_primary}
<button
type="button"
class="absolute top-1 left-1 btn btn-success btn-xs z-10"
on:click={() => makePrimaryImage(image.id)}
>
<Star class="h-4 w-4" />
</button>
{:else}
<!-- crown icon -->
<div class="absolute top-1 left-1 bg-warning text-white rounded-full p-1 z-10">
<Crown class="h-4 w-4" />
</div>
{/if}
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover rounded-md shadow-md"
/>
</div>
{/each}
</div>
{:else}
<h1 class="font-semibold text-xl text-gray-500">{$t('adventures.no_images')}</h1>
{/if}
</div>
</div>
<div class="mt-4">
<button type="button" class="btn btn-primary w-full max-w-sm" on:click={saveAndClose}>
{$t('about.close')}
</button>
</div>
{/if}
{#if adventure.is_public && adventure.id}
<div class="bg-neutral p-4 mt-2 rounded-md shadow-sm text-neutral-content">
<p class=" font-semibold">{$t('adventures.share_adventure')}</p>
<div class="flex items-center justify-between">
<p class="text-card-foreground font-mono">
{window.location.origin}/adventures/{adventure.id}
</p>
<button
type="button"
on:click={() => {
navigator.clipboard.writeText(`${window.location.origin}/adventures/${adventure.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}
</div>
</dialog>

View File

@@ -0,0 +1,415 @@
<script lang="ts">
import type { Checklist, Lodging, Note, Transportation } from '$lib/types';
import { deserialize } from '$app/forms';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
export let object: Lodging | Transportation;
export let objectType: 'lodging' | 'transportation' | 'note' | 'checklist';
export let isAttachmentsUploading: boolean = false;
let attachmentInput: HTMLInputElement;
let attachmentFiles: File[] = [];
let editingAttachment: { id: string; name: string } | null = null;
function handleAttachmentChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
attachmentFiles = Array.from(target.files);
console.log('Attachments selected:', attachmentFiles.length);
if (object.id) {
// If object exists, upload immediately
uploadAttachments();
}
}
}
// Watch for external trigger to upload attachments
$: {
if (isAttachmentsUploading && attachmentFiles.length > 0 && object.id) {
// Immediately clear the trigger to prevent infinite loop
const filesToUpload = [...attachmentFiles];
attachmentFiles = []; // Clear immediately
if (attachmentInput) {
attachmentInput.value = '';
}
uploadAttachmentsFromList(filesToUpload);
}
}
async function uploadAttachments() {
if (attachmentFiles.length === 0) {
isAttachmentsUploading = false;
return;
}
const filesToUpload = [...attachmentFiles];
// Clear immediately to prevent re-triggering
attachmentFiles = [];
if (attachmentInput) {
attachmentInput.value = '';
}
await uploadAttachmentsFromList(filesToUpload);
}
async function uploadAttachmentsFromList(files: File[]) {
if (files.length === 0) {
isAttachmentsUploading = false;
return;
}
try {
// Upload all attachments concurrently
const uploadPromises = files.map((file) => uploadAttachment(file));
await Promise.all(uploadPromises);
} catch (error) {
} finally {
isAttachmentsUploading = false;
}
}
async function uploadAttachment(file: File): Promise<void> {
let formData = new FormData();
formData.append('file', file);
formData.append('object_id', object.id);
formData.append('content_type', objectType);
let res = await fetch(`/locations?/attachment`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as {
data: {
id: string;
file: string;
name: string;
extension: string;
size: number;
};
};
let newAttachment = {
id: newData.data.id,
file: newData.data.file,
name: newData.data.name,
extension: newData.data.extension,
size: newData.data.size,
user: '',
geojson: null
};
object.attachments = [...(object.attachments || []), newAttachment];
} else {
throw new Error(`Failed to upload ${file.name}`);
}
}
async function removeAttachment(id: string) {
let res = await fetch(`/api/attachments/${id}/`, {
method: 'DELETE'
});
if (res.status === 204) {
object.attachments = object.attachments.filter(
(attachment: { id: string }) => attachment.id !== id
);
addToast('success', $t('adventures.attachment_removed_success'));
} else {
addToast('error', $t('adventures.attachment_removed_error'));
console.error('Error removing attachment:', await res.text());
}
}
async function updateAttachmentName(attachmentId: string, newName: string) {
let res = await fetch(`/api/attachments/${attachmentId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: newName })
});
if (res.ok) {
object.attachments = object.attachments.map((attachment) => {
if (attachment.id === attachmentId) {
return { ...attachment, name: newName };
}
return attachment;
});
editingAttachment = null;
} else {
}
}
function startEditingName(attachment: { id: string; name: string }) {
editingAttachment = { id: attachment.id, name: attachment.name };
}
function cancelEditingName() {
editingAttachment = null;
}
function handleNameKeydown(event: KeyboardEvent, attachmentId: string) {
if (event.key === 'Enter') {
updateAttachmentName(attachmentId, editingAttachment?.name || '');
} else if (event.key === 'Escape') {
cancelEditingName();
}
}
function getFileIcon(filename: string): string {
const extension = filename.toLowerCase().split('.').pop() || '';
switch (extension) {
case 'pdf':
return 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9.5 11.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zM17 17H7l3-3.99 2 2.67L16 12l1 5z';
case 'doc':
case 'docx':
return 'M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z';
case 'xls':
case 'xlsx':
return 'M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20M8.93,12.22H10.66L12.03,14.71L13.4,12.22H15.13L13.15,15.31L15.13,18.4H13.4L12.03,15.91L10.66,18.4H8.93L10.91,15.31L8.93,12.22Z';
case 'txt':
return 'M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
return 'M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,19H5V5H19V19M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z';
default:
return 'M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z';
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Export function to check if attachments are ready to upload
export function hasAttachmentsToUpload(): boolean {
return attachmentFiles.length > 0;
}
</script>
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" />
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</div>
{$t('adventures.attachments')}
{#if isAttachmentsUploading}
<span class="loading loading-spinner loading-sm text-primary"></span>
{/if}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="attachment">
<span class="label-text font-medium">{$t('adventures.upload_attachment')}</span>
</label>
<input
type="file"
id="attachment"
name="attachment"
multiple
bind:this={attachmentInput}
on:change={handleAttachmentChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
disabled={isAttachmentsUploading}
/>
</div>
{#if attachmentFiles.length > 0 && !object.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_attachments')} ({attachmentFiles.length})
</h4>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
><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.attachments_upload_info')} {objectType}</span>
</div>
<div class="space-y-2 mt-3">
{#each attachmentFiles as file}
<div class="flex items-center gap-3 p-3 bg-base-200/60 rounded-lg">
<div class="p-2 bg-secondary/20 rounded-lg">
<svg class="w-4 h-4 text-secondary" fill="currentColor" viewBox="0 0 24 24">
<path d={getFileIcon(file.name)} />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content truncate">
{file.name}
</p>
<p class="text-xs text-base-content/60">
{formatFileSize(file.size)}
</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if object.id}
<div class="divider my-6"></div>
<!-- Current Attachments -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_attachments')}</h4>
{#if object.attachments && object.attachments.length > 0}
<div class="space-y-3">
{#each object.attachments as attachment}
<div
class="group relative flex items-center gap-4 p-4 bg-base-200/60 hover:bg-base-200 rounded-xl border border-base-300/50 transition-all duration-200 hover:shadow-sm"
>
<div class="p-3 bg-secondary/20 rounded-lg">
<svg class="w-6 h-6 text-secondary" fill="currentColor" viewBox="0 0 24 24">
<path d={getFileIcon(attachment.name || attachment.file)} />
</svg>
</div>
<div class="flex-1 min-w-0">
{#if editingAttachment?.id === attachment.id}
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
bind:value={editingAttachment.name}
on:keydown={(e) => handleNameKeydown(e, attachment.id)}
class="input input-sm input-bordered flex-1 bg-base-100"
placeholder="Enter attachment name"
autofocus
/>
<button
type="button"
class="btn btn-success btn-sm"
on:click={() =>
updateAttachmentName(attachment.id, editingAttachment?.name || '')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
<button
type="button"
class="btn btn-ghost btn-sm"
on:click={cancelEditingName}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{:else}
<div class="flex items-center gap-2">
<h5 class="text-sm font-semibold text-base-content truncate flex-1">
{attachment.name || attachment.file.split('/').pop() || 'Untitled'}
</h5>
<button
type="button"
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity"
on:click={() => startEditingName(attachment)}
title="Edit name"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-2">
<a
href={attachment.file}
target="_blank"
rel="noopener noreferrer"
class="btn btn-ghost btn-sm opacity-60 group-hover:opacity-100 transition-opacity"
title="Download"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</a>
<button
type="button"
class="btn btn-error btn-sm opacity-0 group-hover:opacity-100 transition-opacity"
on:click={() => removeAttachment(attachment.id)}
title="Remove"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_attachments')}
</div>
<p class="text-sm text-base-content/40">{$t('adventures.no_attachments_desc')}</p>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -31,9 +31,9 @@
section: 'main'
},
{
path: '/adventures',
path: '/locations',
icon: MapMarker,
label: 'navbar.my_adventures',
label: 'locations.my_locations',
section: 'main'
},
{

View File

@@ -1,59 +1,60 @@
<script lang="ts">
import type { Adventure } from '$lib/types';
import ImageDisplayModal from './ImageDisplayModal.svelte';
import { t } from 'svelte-i18n';
export let adventures: Adventure[] = [];
import type { ContentImage } from '$lib/types';
export let images: ContentImage[] = [];
export let name: string = '';
export let icon: string = '';
let currentSlide = 0;
let image_url: string | null = null;
let showImageModal = false;
let modalInitialIndex = 0;
$: adventure_images = adventures.flatMap((adventure) =>
adventure.images.map((image) => ({
image: image.image,
adventure: adventure,
is_primary: image.is_primary
}))
);
$: sortedImages = [...images].sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
$: {
if (adventure_images.length > 0) {
if (sortedImages.length > 0) {
currentSlide = 0;
}
}
$: {
// sort so that any image in adventure_images .is_primary is first
adventure_images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
}
function changeSlide(direction: string) {
if (direction === 'next' && currentSlide < adventure_images.length - 1) {
if (direction === 'next' && currentSlide < sortedImages.length - 1) {
currentSlide = currentSlide + 1;
} else if (direction === 'prev' && currentSlide > 0) {
currentSlide = currentSlide - 1;
}
}
function openImageModal(initialIndex: number = currentSlide) {
modalInitialIndex = initialIndex;
showImageModal = true;
}
function closeImageModal() {
showImageModal = false;
}
</script>
{#if image_url}
{#if showImageModal && sortedImages.length > 0}
<ImageDisplayModal
adventure={adventure_images[currentSlide].adventure}
image={image_url}
on:close={() => (image_url = null)}
images={sortedImages}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
{name}
/>
{/if}
<figure>
{#if adventure_images && adventure_images.length > 0}
{#if sortedImages && sortedImages.length > 0}
<div class="carousel w-full relative">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="carousel-item w-full block">
@@ -61,48 +62,123 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->
<a
on:click|stopPropagation={() => (image_url = adventure_images[currentSlide].image)}
class="cursor-pointer"
on:click|stopPropagation={() => openImageModal(currentSlide)}
class="cursor-pointer relative group"
>
<img
src={adventure_images[currentSlide].image}
class="w-full h-48 object-cover"
alt={adventure_images[currentSlide].adventure.name}
src={sortedImages[currentSlide].image}
class="w-full h-48 object-cover transition-all group-hover:brightness-110"
alt={name || 'Image'}
/>
<!-- Overlay indicator for multiple images -->
<!-- {#if sortedImages.length > 1}
<div
class="absolute top-3 right-3 bg-black/60 text-white px-2 py-1 rounded-lg text-xs font-medium"
>
{currentSlide + 1} / {sortedImages.length}
</div>
{/if} -->
<!-- Click to expand hint -->
<!-- <div
class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-all flex items-center justify-center"
>
<div
class="opacity-0 group-hover:opacity-100 transition-all bg-white/90 rounded-full p-2"
>
<svg
class="w-6 h-6 text-gray-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</div>
</div> -->
</a>
{#if adventure_images.length > 1}
{#if sortedImages.length > 1}
<div class="absolute inset-0 flex items-center justify-between pointer-events-none">
{#if currentSlide > 0}
<button
on:click|stopPropagation={() => changeSlide('prev')}
class="btn btn-circle btn-sm ml-2 pointer-events-auto"></button
class="btn btn-circle btn-sm mr-2 pointer-events-auto bg-neutral border-none text-neutral-content shadow-lg"
aria-label="Previous image"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{:else}
<div class="w-12"></div>
{/if}
{#if currentSlide < adventure_images.length - 1}
{#if currentSlide < sortedImages.length - 1}
<button
on:click|stopPropagation={() => changeSlide('next')}
class="btn btn-circle mr-2 btn-sm pointer-events-auto"></button
class="btn btn-circle btn-sm mr-2 pointer-events-auto bg-neutral border-none text-neutral-content shadow-lg"
aria-label="Next image"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{:else}
<div class="w-12"></div>
{/if}
</div>
<!-- Dot indicators at bottom -->
<!-- {#if sortedImages.length > 1}
<div class="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2">
{#each sortedImages as _, index}
<button
on:click|stopPropagation={() => (currentSlide = index)}
class="w-2 h-2 rounded-full transition-all pointer-events-auto {index ===
currentSlide
? 'bg-white shadow-lg'
: 'bg-white/50 hover:bg-white/80'}"
aria-label="Go to image {index + 1}"
/>
{/each}
</div>
{/if} -->
{/if}
</div>
</div>
{: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
<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> -->
<!-- Fallback with emoji icon as main image -->
<div class="w-full h-48 relative flex items-center justify-center">
{#if icon}
<!-- Clean background with emoji as the focal point -->
<div
class="w-full h-full bg-gradient-to-r from-success via-base to-primary flex items-center justify-center"
>
<div class="text-8xl select-none">
{icon}
</div>
</div>
{:else}
<!-- Original gradient fallback when no icon -->
<div class="w-full h-full bg-gradient-to-r from-success via-base to-primary"></div>
{/if}
</div>
{/if}
</figure>

View File

@@ -3,17 +3,23 @@
import type { Category } from '$lib/types';
import { t } from 'svelte-i18n';
export let categories: Category[] = [];
export let selected_category: Category | null = null;
export let searchTerm: string = '';
let new_category: Category = {
name: '',
display_name: '',
icon: '',
id: '',
user_id: '',
num_adventures: 0
user: '',
num_locations: 0
};
$: {
console.log('Selected category changed:', selected_category);
}
let categories: Category[] = [];
let isOpen: boolean = false;
let isEmojiPickerVisible: boolean = false;
@@ -33,6 +39,9 @@
function custom_category() {
new_category.name = new_category.display_name.toLowerCase().replace(/ /g, '_');
if (!new_category.icon) {
new_category.icon = '🌎'; // Default icon if none selected
}
selectCategory(new_category);
}
@@ -44,7 +53,15 @@
let dropdownRef: HTMLDivElement;
onMount(() => {
categories = categories.sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0));
const loadData = async () => {
await import('emoji-picker-element');
let res = await fetch('/api/categories');
categories = await res.json();
categories = categories.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0));
};
loadData();
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
@@ -55,67 +72,346 @@
document.removeEventListener('click', handleClickOutside);
};
});
onMount(async () => {
await import('emoji-picker-element');
});
</script>
<div class="mt-2 relative" bind:this={dropdownRef}>
<button type="button" class="btn btn-outline w-full text-left" on:click={toggleDropdown}>
{selected_category && selected_category.name
? selected_category.display_name + ' ' + selected_category.icon
: $t('categories.select_category')}
</button>
<div class="dropdown w-full" bind:this={dropdownRef}>
<!-- Main dropdown trigger -->
<div
tabindex="0"
role="button"
class="btn btn-outline w-full justify-between sm:h-auto h-12"
on:click={toggleDropdown}
>
<span class="flex items-center gap-2">
{#if selected_category && selected_category.name}
<span class="text-lg">{selected_category.icon}</span>
<span class="truncate">{selected_category.display_name}</span>
{:else}
<span class="text-base-content/70">{$t('categories.select_category')}</span>
{/if}
</span>
<svg
class="w-4 h-4 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
{#if isOpen}
<div class="absolute z-10 w-full mt-1 bg-base-300 rounded shadow-lg p-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered w-full max-w-xs"
bind:value={new_category.display_name}
/>
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered w-full max-w-xs"
bind:value={new_category.icon}
/>
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary">
{!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')}
{$t('adventures.emoji_picker')}
</button>
<button on:click={custom_category} type="button" class="btn btn-primary">
{$t('adventures.add')}
<!-- Mobile Modal Overlay (only on small screens) -->
<div class="fixed inset-0 bg-black/50 z-40 sm:hidden" on:click={() => (isOpen = false)}></div>
<!-- Mobile Bottom Sheet -->
<div
class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 rounded-t-2xl shadow-2xl border-t border-base-300 max-h-[90vh] flex flex-col sm:hidden"
>
<!-- Mobile Header -->
<div class="flex-shrink-0 bg-base-100 border-b border-base-300 p-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{$t('categories.select_category')}</h2>
<button class="btn btn-ghost btn-sm btn-circle" on:click={() => (isOpen = false)}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if isEmojiPickerVisible}
<div class="mt-2">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
<div class="flex flex-wrap gap-2 mt-2">
<!-- Sort the categories dynamically before rendering -->
{#each categories
.slice()
.sort((a, b) => (b.num_adventures || 0) - (a.num_adventures || 0)) as category}
<button
type="button"
class="btn btn-neutral flex items-center space-x-2"
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
<div class="flex-1 overflow-y-auto min-h-0">
<!-- Mobile Category Creator Section -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('categories.add_new_category')}
</h3>
<div class="space-y-3">
<div class="space-y-2">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered w-full h-12 text-base"
bind:value={new_category.display_name}
/>
<div class="join w-full">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered join-item flex-1 h-12 text-base"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn join-item h-12 w-12 text-lg"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
</div>
<button
on:click={custom_category}
type="button"
class="btn btn-primary h-12 w-full"
disabled={!new_category.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
{#if isEmojiPickerVisible}
<div class="p-3 rounded-lg border border-base-300 bg-base-50">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
</div>
<!-- Mobile Categories List -->
<div class="p-4">
<h3 class="font-semibold text-sm text-base-content/80 mb-3">
{$t('categories.select_category')}
</h3>
{#if categories.length > 0}
<div class="form-control mb-4">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered w-full h-12 text-base"
bind:value={searchTerm}
/>
</div>
<div class="space-y-2">
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button
type="button"
class="w-full text-left p-4 rounded-lg border border-base-300 hover:border-primary hover:bg-primary/5 transition-colors"
class:bg-primary={selected_category && selected_category.id === category.id}
class:text-primary-content={selected_category &&
selected_category.id === category.id}
class:border-primary={selected_category && selected_category.id === category.id}
on:click={() => selectCategory(category)}
>
<div class="flex items-center gap-3 w-full">
<span class="text-2xl flex-shrink-0">{category.icon}</span>
<div class="flex-1 min-w-0">
<div class="font-medium text-base truncate">{category.display_name}</div>
<div class="text-sm opacity-70 mt-1">
{category.num_locations}
{$t('locations.locations')}
</div>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Bottom safe area -->
<div class="flex-shrink-0 h-4"></div>
</div>
<!-- Desktop Dropdown -->
<div
class="dropdown-content z-[1] w-full mt-1 bg-base-300 rounded-box shadow-xl border border-base-300 max-h-96 overflow-y-auto hidden sm:block"
>
<!-- Desktop Category Creator Section -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('categories.add_new_category')}
</h3>
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="form-control">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered input-sm w-full"
bind:value={new_category.display_name}
/>
</div>
<div class="form-control">
<div class="input-group">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered input-sm flex-1"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button
on:click={custom_category}
type="button"
class="btn btn-primary btn-sm"
disabled={!new_category.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
{#if isEmojiPickerVisible}
<div class="p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
</div>
<!-- Desktop Categories List Section -->
<div class="p-4">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
{$t('categories.select_category')}
</h3>
{#if categories.length > 0}
<div class="form-control mb-3">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered input-sm w-full"
bind:value={searchTerm}
/>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-60 overflow-y-auto"
>
<span>{category.display_name} {category.icon} ({category.num_adventures})</span>
</button>
{/each}
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button
type="button"
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
class:btn-active={selected_category && selected_category.id === category.id}
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
>
<div class="flex items-center gap-2 w-full">
<span class="text-lg shrink-0">{category.icon}</span>
<div class="flex-1 text-left">
<div class="font-medium text-sm truncate">{category.display_name}</div>
<div class="text-xs text-base-content/60">
{category.num_locations}
{$t('locations.locations')}
</div>
</div>
</div>
</button>
{/each}
</div>
{#if categories.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())).length === 0}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_found')}</p>
</div>
{/if}
{:else}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_yet')}</p>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -8,7 +8,7 @@
let adventure_types: Category[] = [];
onMount(async () => {
let categoryFetch = await fetch('/api/categories/categories');
let categoryFetch = await fetch('/api/categories');
let categoryData = await categoryFetch.json();
adventure_types = categoryData;
console.log(categoryData);
@@ -60,7 +60,7 @@
/>
<span>
{type.display_name}
{type.icon} ({type.num_adventures})
{type.icon} ({type.num_locations})
</span>
</label>
</li>

View File

@@ -26,7 +26,7 @@
async function loadCategories() {
try {
const res = await fetch('/api/categories/categories');
const res = await fetch('/api/categories');
if (res.ok) {
categories = await res.json();
}
@@ -334,7 +334,7 @@
{#if isChanged}
<div class="alert alert-success mb-4">
<span>{$t('categories.update_after_refresh')}</span>
<span>{$t('categories.location_update_after_refresh')}</span>
</div>
{/if}

View File

@@ -99,7 +99,7 @@
<Launch class="w-5 h-5" />
{$t('notes.open')}
</button>
{#if checklist.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if checklist.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Checklist"

View File

@@ -6,6 +6,8 @@
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import CheckboxIcon from '~icons/mdi/checkbox-multiple-marked-outline';
export let checklist: Checklist | null = null;
export let collection: Collection;
export let user: User | null = null;
@@ -19,12 +21,14 @@
let warning: string | null = '';
let isReadOnly =
!(checklist && user?.uuid == checklist?.user_id) &&
!(checklist && user?.uuid == checklist?.user) &&
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
!!checklist;
let newStatus: boolean = false;
let newItem: string = '';
let initialName: string = checklist?.name || '';
function addItem() {
if (newItem.trim() == '') {
warning = $t('checklist.item_cannot_be_empty');
@@ -40,7 +44,7 @@
name: newItem,
is_checked: newStatus,
id: '',
user_id: '',
user: '',
checklist: 0,
created_at: '',
updated_at: ''
@@ -91,7 +95,7 @@
}
if (checklist && checklist.id) {
console.log('newNote', newChecklist);
console.log('newChecklist', newChecklist);
const res = await fetch(`/api/checklists/${checklist.id}`, {
method: 'PATCH',
headers: {
@@ -108,7 +112,7 @@
console.error('Failed to save checklist');
}
} else {
console.log('newNote', newChecklist);
console.log('newChecklist', newChecklist);
const res = await fetch(`/api/checklists/`, {
method: 'POST',
headers: {
@@ -124,145 +128,306 @@
} else {
let data = await res.json();
console.error('Failed to save checklist', data);
console.error('Failed to save checklist');
}
}
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{#if checklist?.id}
<p class="font-semibold text-md mb-2">
{$t('checklist.checklist_editor')}
</p>
{:else}
{$t('checklist.new_checklist')}
{/if}
</h3>
<div class="modal-action items-center">
<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 -->
<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">
<CheckboxIcon class="w-8 h-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{#if checklist?.id && !isReadOnly}
{$t('checklist.editing_checklist')}
{:else if !isReadOnly}
{$t('checklist.checklist_editor')}
{:else}
{$t('checklist.checklist_viewer')}
{/if}
</h1>
<p class="text-sm text-base-content/60">
{#if checklist?.id && !isReadOnly}
{$t('checklist.update_checklist_details')} "{initialName}"
{:else if !isReadOnly}
{$t('checklist.new_checklist')}
{:else}
{$t('checklist.viewing_checklist')} "{checklist?.name || ''}"
{/if}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit|preventDefault>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-1" checked />
<div class="collapse-title text-lg font-bold">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
class="input input-bordered w-full max-w-xs"
bind:value={newChecklist.name}
readonly={isReadOnly}
/>
</div>
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
class="label cursor-pointer flex items-start space-x-2"
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<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"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-4">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="text"
id="name"
name="name"
readonly={isReadOnly}
bind:value={newChecklist.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('checklist.enter_checklist_title')}
required
/>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Date Field -->
<div class="form-control">
<label class="label" for="date">
<span class="label-text font-medium">{$t('adventures.date')}</span>
</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}
<div class="flex items-center gap-2 mb-2">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
id="constrain_dates"
name="constrain_dates"
bind:checked={constrainDates}
/>
<span class="text-sm text-base-content/70"
>{$t('adventures.date_constrain')}</span
>
</div>
{/if}
<input
type="date"
id="date"
name="date"
readonly={isReadOnly}
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newChecklist.date}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Items Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<CheckboxIcon class="w-5 h-5 text-primary" />
</div>
{$t('checklist.items')}
{#if items.length > 0}
<div class="badge badge-primary badge-sm ml-2">{items.length}</div>
{/if}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<!-- Add New Item Section -->
{#if !isReadOnly}
<div class="form-control mb-6">
<label class="label" for="new-item">
<span class="label-text font-medium">{$t('checklist.add_new_item')}</span>
</label>
<div class="flex gap-3 items-center">
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<input
type="date"
id="date"
name="date"
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newChecklist.date}
class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/>
</div>
</div>
</div>
<!-- Items Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-2" checked />
<div class="collapse-title text-lg font-bold">
{$t('checklist.items')}
</div>
<div class="collapse-content">
{#if !isReadOnly}
<div class="form-control mb-2 flex flex-row">
<input type="checkbox" bind:checked={newStatus} class="checkbox mt-4 mr-2" />
<input
type="text"
id="new_item"
placeholder={$t('checklist.new_item')}
name="new_item"
bind:value={newItem}
class="input input-bordered w-full max-w-xs mt-1"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addItem();
}
}}
/>
<button
type="button"
class="btn btn-sm btn-primary absolute right-0 mt-2.5 mr-4"
on:click={addItem}
>
{$t('adventures.add')}
</button>
bind:checked={newStatus}
class="checkbox checkbox-primary"
/>
<div class="join flex-1">
<input
type="text"
id="new-item"
placeholder={$t('checklist.new_item')}
bind:value={newItem}
class="input input-bordered join-item flex-1 bg-base-100/80 focus:bg-base-100"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addItem();
}
}}
/>
<button type="button" class="btn btn-primary join-item" on:click={addItem}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
</div>
</div>
{/if}
<!-- Items List -->
{#if items.length > 0}
<div class="divider"></div>
<h2 class=" text-xl font-semibold mb-4 -mt-3">{$t('checklist.items')}</h2>
{/if}
{#each items as item, i}
<div class="form-control mb-2 flex flex-row">
<input
type="checkbox"
bind:checked={item.is_checked}
class="checkbox mt-4 mr-2"
readonly={isReadOnly}
/>
<input
type="text"
id="item_{i}"
name="item_{i}"
bind:value={item.name}
class="input input-bordered w-full max-w-xs mt-1"
readonly={isReadOnly}
/>
<button
type="button"
class="btn btn-sm btn-error absolute right-0 mt-2.5 mr-4"
on:click={() => removeItem(i)}
disabled={isReadOnly}
>
{$t('adventures.remove')}
</button>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-base-content/80">
{$t('checklist.current_items')}
</h3>
<div class="text-sm text-base-content/60">
{items.filter((item) => item.is_checked).length} / {items.length}
{$t('checklist.completed')}
</div>
</div>
<div class="max-h-64 overflow-y-auto space-y-2">
{#each items as item, i}
<div
class="flex items-center gap-3 p-4 bg-base-200/50 rounded-xl border border-base-300/50 group hover:bg-base-200/70 transition-colors"
>
<input
type="checkbox"
bind:checked={item.is_checked}
class="checkbox checkbox-primary"
readonly={isReadOnly}
/>
<input
type="text"
bind:value={item.name}
class="input input-ghost flex-1 bg-transparent focus:bg-base-100/80 {item.is_checked
? 'line-through text-base-content/50'
: ''}"
readonly={isReadOnly}
/>
{#if !isReadOnly}
<button
type="button"
class="btn btn-ghost btn-sm text-error opacity-0 group-hover:opacity-100 transition-opacity"
on:click={() => removeItem(i)}
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
</div>
{/each}
{:else if !isReadOnly}
<div class="text-center py-12 text-base-content/50">
<svg
class="w-16 h-16 mx-auto mb-4 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v6a2 2 0 002 2h6a2 2 0 002-2V7a2 2 0 00-2-2H9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 9l2 2 4-4"
/>
</svg>
<p class="text-lg font-medium">{$t('checklist.no_items_yet')}</p>
<p class="text-sm">{$t('checklist.add_your_first_item')}</p>
</div>
{/if}
</div>
</div>
<!-- Warning Messages -->
{#if warning}
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<div role="alert" class="alert alert-error mb-6 rounded-xl border border-error/20">
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -270,32 +435,51 @@
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{warning}</span>
<span class="font-medium">{warning}</span>
</div>
{/if}
<!-- Public Checklist Alert -->
{#if collection.is_public}
<div role="alert" class="alert mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<div role="alert" class="alert alert-info mb-6 rounded-xl border border-info/20">
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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('checklist.checklist_public')}</span>
<span class="font-medium">{$t('checklist.checklist_public')}</span>
</div>
{/if}
<div class="mt-4">
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}
>{$t('notes.save')}</button
><button class="btn btn-neutral" on:click={close}>{$t('about.close')}</button>
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4 border-t border-base-300/50">
<button type="button" class="btn btn-neutral-200" on:click={close}>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('about.close')}
</button>
{#if !isReadOnly}
<button type="button" class="btn btn-primary" on:click={save}>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
{/if}
</div>
</form>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type {
Adventure,
Location,
Transportation,
Lodging,
Note,
@@ -24,14 +24,14 @@
import Filter from '~icons/mdi/filter-variant';
// Component imports
import AdventureCard from './AdventureCard.svelte';
import LocationCard from './LocationCard.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 adventures: Location[] = [];
export let transportations: Transportation[] = [];
export let lodging: Lodging[] = [];
export let notes: Note[] = [];
@@ -45,7 +45,7 @@
let sortOption: string = 'name_asc';
// Filtered arrays
let filteredAdventures: Adventure[] = [];
let filteredAdventures: Location[] = [];
let filteredTransportations: Transportation[] = [];
let filteredLodging: Lodging[] = [];
let filteredNotes: Note[] = [];
@@ -256,7 +256,7 @@
<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-title text-xs">{$t('locations.locations')}</div>
<div class="stat-value text-sm text-info">{adventures.length}</div>
</div>
<div class="stat py-2 px-3">
@@ -380,7 +380,7 @@
on:click={() => (filterOption = 'adventures')}
>
<Adventures class="w-3 h-3" />
{$t('navbar.adventures')}
{$t('locations.locations')}
</button>
<button
class="tab tab-sm gap-2 {filterOption === 'transportation'
@@ -426,13 +426,13 @@
<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')}
{$t('adventures.linked_locations')}
</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
<LocationCard
{user}
on:edit={handleEditAdventure}
on:delete={handleDeleteAdventure}

View File

@@ -9,7 +9,7 @@
import ShareVariant from '~icons/mdi/share-variant';
import { goto } from '$app/navigation';
import type { Adventure, Collection, User } from '$lib/types';
import type { Location, Collection, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
@@ -20,6 +20,7 @@
import DeleteWarning from './DeleteWarning.svelte';
import ShareModal from './ShareModal.svelte';
import CardCarousel from './CardCarousel.svelte';
import ExitRun from '~icons/mdi/exit-run';
const dispatch = createEventDispatcher();
@@ -91,7 +92,11 @@
>
<!-- Image Carousel -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={collection.adventures} />
<CardCarousel
images={collection.locations.flatMap((location) => location.images)}
name={collection.name}
icon="📚"
/>
<!-- Badge Overlay -->
<div class="absolute top-4 left-4 flex flex-col gap-2">
@@ -119,8 +124,8 @@
<!-- Adventure Count -->
<p class="text-sm text-base-content/70">
{collection.adventures.length}
{$t('navbar.adventures')}
{collection.locations.length}
{$t('locations.locations')}
</p>
<!-- Date Range -->
@@ -170,7 +175,7 @@
<Launch class="w-4 h-4" />
{$t('adventures.open_details')}
</button>
{#if user && user.uuid == collection.user_id}
{#if user && user.uuid == collection.user}
<div class="dropdown dropdown-end">
<button type="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
@@ -241,6 +246,26 @@
{/if}
</ul>
</div>
{:else if user && collection.shared_with && collection.shared_with.includes(user.uuid)}
<!-- dropdown with leave button -->
<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"
>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => dispatch('leave', collection.id)}
>
<ExitRun class="w-4 h-4" />
{$t('adventures.leave_collection')}
</button>
</li>
</ul>
</div>
{/if}
</div>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Adventure, Collection } from '$lib/types';
import type { Location, Collection } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
@@ -12,19 +12,23 @@
import Search from '~icons/mdi/magnify';
import Clear from '~icons/mdi/close';
import Link from '~icons/mdi/link-variant';
import Share from '~icons/mdi/share-variant';
let collections: Collection[] = [];
let sharedCollections: Collection[] = [];
let allCollections: Collection[] = [];
let filteredCollections: Collection[] = [];
let searchQuery: string = '';
let loading = true;
export let linkedCollectionList: string[] | null = null;
// Search functionality following worldtravel pattern
$: {
if (searchQuery === '') {
filteredCollections = collections;
filteredCollections = allCollections;
} else {
filteredCollections = collections.filter((collection) =>
filteredCollections = allCollections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
@@ -36,28 +40,57 @@
modal.showModal();
}
let res = await fetch(`/api/collections/all/`, {
method: 'GET'
});
try {
// Fetch both own collections and shared collections
const [ownRes, sharedRes] = await Promise.all([
fetch(`/api/collections/all/`, { method: 'GET' }),
fetch(`/api/collections/shared`, { method: 'GET' })
]);
let result = await res.json();
const ownResult = await ownRes.json();
const sharedResult = await sharedRes.json();
if (result.type === 'success' && result.data) {
collections = result.data.adventures as Collection[];
} else {
collections = result as Collection[];
// Process own collections
if (ownResult.type === 'success' && ownResult.data) {
collections = ownResult.data.adventures as Collection[];
} else {
collections = ownResult as Collection[];
}
// Process shared collections
if (sharedResult.type === 'success' && sharedResult.data) {
sharedCollections = sharedResult.data.adventures as Collection[];
} else {
sharedCollections = sharedResult as Collection[];
}
// Don't combine collections - keep them separate
allCollections = collections;
// Move linked collections to the front for each collection type
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;
});
sharedCollections.sort((a, b) => {
const aLinked = linkedCollectionList?.includes(a.id);
const bLinked = linkedCollectionList?.includes(b.id);
return aLinked === bLinked ? 0 : aLinked ? -1 : 1;
});
}
} catch (error) {
console.error('Error fetching collections:', error);
// Fallback to empty arrays
collections = [];
sharedCollections = [];
allCollections = [];
filteredCollections = [];
} finally {
loading = false;
}
// 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() {
@@ -80,7 +113,23 @@
// Statistics following worldtravel pattern
$: linkedCount = linkedCollectionList ? linkedCollectionList.length : 0;
$: totalCollections = collections.length;
$: totalCollections = collections.length + sharedCollections.length;
$: ownCollectionsCount = collections.length;
$: sharedCollectionsCount = sharedCollections.length;
// Filtered collections for display
$: filteredOwnCollections =
searchQuery === ''
? collections
: collections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
$: filteredSharedCollections =
searchQuery === ''
? sharedCollections
: sharedCollections.filter((collection) =>
collection.name.toLowerCase().includes(searchQuery.toLowerCase())
);
</script>
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
@@ -106,7 +155,7 @@
{$t('adventures.my_collections')}
</h1>
<p class="text-sm text-base-content/60">
{filteredCollections.length}
{filteredOwnCollections.length + filteredSharedCollections.length}
{$t('worldtravel.of')}
{totalCollections}
{$t('navbar.collections')}
@@ -122,8 +171,15 @@
<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 class="stat-title text-xs">{$t('navbar.collections')}</div>
<div class="stat-value text-lg text-info">{ownCollectionsCount}</div>
</div>
<div class="stat py-2 px-4">
<div class="stat-title text-xs flex items-center gap-1">
<Share class="w-3 h-3" />
{$t('share.shared')}
</div>
<div class="stat-value text-lg text-warning">{sharedCollectionsCount}</div>
</div>
</div>
</div>
@@ -164,15 +220,20 @@
</div>
<!-- Main Content -->
<div class="px-2">
{#if filteredCollections.length === 0}
<div class="px-6">
{#if loading}
<div class="flex flex-col items-center justify-center py-16">
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
<p class="text-base-content/60">{$t('adventures.loading_collections')}</p>
</div>
{:else if filteredOwnCollections.length === 0 && filteredSharedCollections.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')}
{$t('adventures.no_collections_to_add_location')}
</h3>
<p class="text-base-content/50 text-center max-w-md mb-6">
{$t('collection.try_different_search')}
@@ -183,7 +244,7 @@
</button>
{:else}
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{$t('adventures.no_collections_found')}
{$t('adventures.no_collections_to_add_location')}
</h3>
<p class="text-base-content/50 text-center max-w-md">
{$t('adventures.create_collection_first')}
@@ -192,17 +253,69 @@
</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 class="space-y-8">
<!-- Own Collections Section -->
{#if filteredOwnCollections.length > 0}
<div>
<div class="flex items-center gap-2 mb-4">
<Collections class="w-5 h-5 text-primary" />
<h2 class="text-lg font-semibold text-base-content">
{$t('adventures.my_collections')}
</h2>
<div class="badge badge-primary badge-sm">
{filteredOwnCollections.length}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each filteredOwnCollections as collection}
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
user={null}
/>
{/each}
</div>
</div>
{/if}
<!-- Shared Collections Section -->
{#if filteredSharedCollections.length > 0}
<div>
<div class="flex items-center gap-2 mb-4">
<Share class="w-5 h-5 text-warning" />
<h2 class="text-lg font-semibold text-base-content">
{$t('navbar.shared_with_me')}
</h2>
<div class="badge badge-warning badge-sm">
{filteredSharedCollections.length}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each filteredSharedCollections as collection}
<div class="relative">
<CollectionCard
{collection}
type="link"
on:link={link}
bind:linkedCollectionList
on:unlink={unlink}
user={null}
/>
<!-- Shared badge overlay -->
<div class="absolute -top-2 -right-2 z-10">
<div class="badge badge-warning badge-sm gap-1 shadow-lg">
<Share class="w-3 h-3" />
{$t('share.shared')}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -17,7 +17,7 @@
description: collectionToEdit?.description || '',
start_date: collectionToEdit?.start_date || null,
end_date: collectionToEdit?.end_date || null,
user_id: collectionToEdit?.user_id || '',
user: collectionToEdit?.user || '',
is_public: collectionToEdit?.is_public || false,
adventures: collectionToEdit?.adventures || [],
link: collectionToEdit?.link || '',

View File

@@ -25,7 +25,12 @@
<!-- Content -->
<div class="card-body p-6 space-y-4">
<!-- Title -->
<h2 class="text-xl font-bold truncate">{country.name}</h2>
<a
href="/worldtravel/{country.country_code}"
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline block"
>
{country.name}
</a>
<!-- Info Badges -->
<div class="flex flex-wrap gap-2">

View File

@@ -172,365 +172,335 @@
}
</script>
<div class="collapse collapse-plus bg-base-200 mb-4 rounded-lg">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" />
<div class="collapse-title text-xl font-semibold">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<!-- Timezone Selector Section -->
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4">
<!-- Group Header -->
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3>
{#if type === 'transportation'}
<!-- Dual timezone selectors for transportation -->
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.departure_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.arrival_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
{:else}
<!-- Single timezone selector for other types -->
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
{/if}
<!-- All Day Toggle -->
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="all_day"
name="all_day"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
<!-- Constrain Dates Toggle -->
{#if collection?.start_date && collection?.end_date}
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
<!-- Dates Input Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Start Date -->
<div class="space-y-2">
<label for="date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.departure_date')
: type === 'lodging'
? $t('adventures.check_in')
: $t('adventures.start_date')}
</label>
{#if allDay}
<input
type="date"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
<!-- End Date -->
{#if localStartDate}
<div class="space-y-2">
<label for="end_date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.arrival_date')
: type === 'lodging'
? $t('adventures.check_out')
: $t('adventures.end_date')}
</label>
{#if allDay}
<input
type="date"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
{/if}
<!-- Notes (for adventures only) -->
{#if type === 'adventure'}
<div class="md:col-span-2">
<label for="note" class="text-sm font-medium block mb-1">
{$t('adventures.add_notes')}
</label>
<textarea
id="note"
name="note"
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
bind:value={note}
rows="4"
></textarea>
</div>
{/if}
{#if type === 'adventure'}
<button
class="btn btn-primary mb-2"
type="button"
on:click={() => {
const newVisit = createVisitObject();
// Ensure reactivity by assigning a *new* array
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
// Optionally clear the form
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
{$t('adventures.add')}
</button>
{/if}
</div>
<!-- Validation Message -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div role="alert" class="alert alert-error mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>{$t('adventures.invalid_date_range')}</span>
</div>
{$t('adventures.date_information')}
</div>
</div>
<div class="collapse-content bg-base-100/50 p-6">
<!-- Settings -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Settings</h3>
<div class="space-y-3">
{#if type === 'transportation'}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">Departure Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
<div>
<label class="label-text text-sm font-medium">Arrival Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
</div>
{:else}
<div>
<label class="label-text text-sm font-medium">Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">All Day</label>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
{#if collection?.start_date && collection?.end_date}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input
type="checkbox"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Date Selection -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Date Selection</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Departure Date'
: type === 'lodging'
? 'Check In'
: 'Start Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{#if localStartDate}
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Arrival Date'
: type === 'lodging'
? 'Check Out'
: 'End Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{/if}
</div>
{#if type === 'adventure'}
<div class="mt-4">
<label class="label-text text-sm font-medium">Notes</label>
<textarea
class="textarea textarea-bordered w-full mt-1"
rows="3"
placeholder="Add notes..."
bind:value={note}
></textarea>
</div>
<div class="flex justify-end mt-4">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
const newVisit = createVisitObject();
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
Add Visit
</button>
</div>
{/if}
</div>
</div>
<!-- Validation -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div class="alert alert-error mb-6">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Invalid date range</span>
</div>
{/if}
<!-- Visits List -->
{#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2">
<h3 class="text-xl font-semibold">
{$t('adventures.visits')}
</h3>
<div class="card bg-base-100 border border-base-300/50">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Visits</h3>
<!-- Visits List -->
{#if visits && visits.length === 0}
<p class="text-sm text-base-content opacity-70">
{$t('adventures.no_visits')}
</p>
{/if}
</div>
{#if visits && visits.length > 0}
<div class="space-y-4">
{#each visits as visit}
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
>
<p class="text-sm text-base-content font-medium">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''} {visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone(
visit.end_date,
visit.end_timezone
)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)} {formatDateInTimezone(
visit.end_date,
visit.timezone
)}
{:else}
{new Date(visit.start_date).toLocaleString()} {new Date(
visit.end_date
).toLocaleString()}
<!-- showe timezones badge -->
{/if}
{#if 'timezone' in visit && visit.timezone}
<span class="badge badge-outline ml-2">{visit.timezone}</span>
{/if}
</p>
<!-- -->
<!-- Display timezone information for transportation visits -->
{#if 'start_timezone' in visit && 'end_timezone' in visit && visit.start_timezone !== visit.end_timezone}
<p class="text-xs text-base-content">
{visit.start_timezone}{visit.end_timezone}
</p>
{/if}
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
{/if}
<div class="flex gap-2 mt-2">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
// Set timezone information if available
if ('start_timezone' in visit) {
// TransportationVisit
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
// Visit
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
// Update with timezone awareness
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
// remove it from visits
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
{$t('lodging.edit')}
</button>
<button
class="btn btn-error btn-sm"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
{$t('adventures.remove')}
</button>
</div>
{#if visits && visits.length === 0}
<div class="text-center py-8 text-base-content/60">
<p class="text-sm">No visits added yet</p>
</div>
{/each}
{/if}
{#if visits && visits.length > 0}
<div class="space-y-3">
{#each visits as visit}
<div class="p-3 bg-base-200/50 rounded-lg border border-base-300/30">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium mb-1">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm mr-2">All Day</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''}
{visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)}
{formatDateInTimezone(visit.end_date, visit.end_timezone)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)}
{formatDateInTimezone(visit.end_date, visit.timezone)}
{:else}
{new Date(visit.start_date).toLocaleString()}
{new Date(visit.end_date).toLocaleString()}
{/if}
</div>
{#if visit.notes}
<p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
{/if}
</div>
<div class="flex gap-2">
<button
class="btn btn-primary btn-xs"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if ('start_timezone' in visit) {
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
Edit
</button>
<button
class="btn btn-error btn-xs"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
Remove
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -1,47 +1,201 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { t } from 'svelte-i18n';
// Icons
import AlertTriangle from '~icons/mdi/alert';
import HelpCircle from '~icons/mdi/help-circle';
import InfoCircle from '~icons/mdi/information';
import Close from '~icons/mdi/close';
import Check from '~icons/mdi/check';
import Cancel from '~icons/mdi/cancel';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let isVisible = false;
export let title: string;
export let button_text: string;
export let description: string;
export let is_warning: boolean;
export let is_warning: boolean = false;
$: modalType = is_warning ? 'warning' : 'info';
$: iconComponent = is_warning ? AlertTriangle : HelpCircle;
$: colorScheme = getColorScheme(modalType);
function getColorScheme(type: string) {
switch (type) {
case 'warning':
return {
icon: 'text-warning',
iconBg: 'bg-warning/10',
border: 'border-warning/20',
button: 'btn-warning',
backdrop: 'bg-warning/5'
};
default:
return {
icon: 'text-info',
iconBg: 'bg-info/10',
border: 'border-info/20',
button: 'btn-primary',
backdrop: 'bg-info/5'
};
}
}
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal = document.getElementById('confirmation_modal') as HTMLDialogElement;
if (modal) {
modal.showModal();
setTimeout(() => (isVisible = true), 50);
}
});
function close() {
dispatch('close');
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
}, 150);
}
function confirm() {
dispatch('close');
dispatch('confirm');
isVisible = false;
setTimeout(() => {
modal?.close();
dispatch('close');
dispatch('confirm');
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
close();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === modal) {
close();
}
}
</script>
<dialog id="my_modal_1" class="modal {is_warning ? 'bg-primary' : ''}">
<!-- 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">{title}</h3>
<p class="py-1 mb-4">{description}</p>
<button class="btn btn-{is_warning ? 'warning' : 'primary'} mr-2" on:click={confirm}
>{button_text}</button
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog
id="confirmation_modal"
class="modal backdrop-blur-sm"
on:click={handleBackdropClick}
on:keydown={handleKeydown}
>
{#if isVisible}
<div
class="modal-box max-w-md relative overflow-hidden border-2 {colorScheme.border} bg-base-100/95 backdrop-blur-lg shadow-2xl"
transition:scale={{ duration: 150, easing: quintOut, start: 0.1 }}
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<button class="btn btn-neutral" on:click={close}>{$t('adventures.cancel')}</button>
</div>
<!-- Close button -->
<button
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 hover:bg-base-content/10 transition-colors"
on:click={close}
aria-label="Close modal"
>
<Close class="w-4 h-4" />
</button>
<!-- Content -->
<div class="flex flex-col items-center text-center pt-6 pb-2">
<!-- Icon -->
<div
class="w-16 h-16 rounded-full {colorScheme.iconBg} flex items-center justify-center mb-6 ring-4 ring-base-300/20"
>
<svelte:component this={iconComponent} class="w-8 h-8 {colorScheme.icon}" />
</div>
<!-- Title -->
<h3 id="modal-title" class="text-2xl font-bold text-base-content mb-3">
{title}
</h3>
<!-- Description -->
<p id="modal-description" class="text-base-content/70 leading-relaxed mb-8 max-w-sm">
{description}
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<button
class="btn {colorScheme.button} flex-1 gap-2 shadow-lg hover:shadow-xl transition-all duration-200"
on:click={confirm}
>
<Check class="w-4 h-4" />
{button_text}
</button>
<button
class="btn btn-neutral-200 flex-1 gap-2 hover:bg-base-content/10 transition-colors"
on:click={close}
>
<Cancel class="w-4 h-4" />
{$t('adventures.cancel')}
</button>
</div>
<!-- Subtle gradient overlay for depth -->
<div
class="absolute inset-0 bg-gradient-to-br from-white/5 via-transparent to-black/5 pointer-events-none"
></div>
<!-- Decorative elements -->
<div
class="absolute -top-20 -right-20 w-40 h-40 {colorScheme.iconBg} rounded-full opacity-20 blur-3xl"
></div>
<div
class="absolute -bottom-10 -left-10 w-32 h-32 {colorScheme.iconBg} rounded-full opacity-10 blur-2xl"
></div>
</div>
<!-- Enhanced backdrop -->
<div
class="fixed inset-0 {colorScheme.backdrop} -z-10"
transition:fade={{ duration: 200 }}
></div>
{/if}
</dialog>
<style>
/* Ensure modal appears above everything */
dialog {
z-index: 9999;
}
/* Custom backdrop blur effect */
dialog::backdrop {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.3);
}
/* Smooth modal entrance */
.modal-box {
transform-origin: center;
}
/* Enhanced button hover effects */
.btn:hover {
transform: translateY(-1px);
}
/* Focus styles for accessibility */
.btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>

View File

@@ -3,16 +3,23 @@
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
import type { Adventure } from '$lib/types';
import { t } from 'svelte-i18n';
import type { ContentImage } from '$lib/types';
export let images: ContentImage[] = [];
export let initialIndex: number = 0;
export let name: string = '';
export let location: string = '';
export let image: string;
export let adventure: Adventure | null = null;
let currentIndex = initialIndex;
let currentImage = images[currentIndex]?.image || '';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
// Set initial values
updateCurrentSlide(initialIndex);
});
function close() {
@@ -25,6 +32,10 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
} else if (event.key === 'ArrowLeft') {
previousSlide();
} else if (event.key === 'ArrowRight') {
nextSlide();
}
}
@@ -33,44 +44,223 @@
close();
}
}
function updateCurrentSlide(index: number) {
currentIndex = index;
currentImage = images[currentIndex]?.image || '';
}
function nextSlide() {
if (images.length > 0) {
const nextIndex = (currentIndex + 1) % images.length;
updateCurrentSlide(nextIndex);
}
}
function previousSlide() {
if (images.length > 0) {
const prevIndex = (currentIndex - 1 + images.length) % images.length;
updateCurrentSlide(prevIndex);
}
}
function goToSlide(index: number) {
updateCurrentSlide(index);
}
// Reactive statement to handle prop changes
$: if (images.length > 0 && currentIndex >= images.length) {
updateCurrentSlide(0);
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<dialog id="my_modal_1" class="modal" on:click={handleClickOutside}>
<dialog id="my_modal_1" class="modal backdrop-blur-sm" on:click={handleClickOutside}>
<!-- 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">
{#if adventure}
<div class="modal-header flex justify-between items-center mb-4">
<h3 class="font-bold text-2xl">{adventure.name}</h3>
<button class="btn btn-circle btn-neutral" on:click={close}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<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"
>
{#if images.length > 0 && currentImage}
<!-- Header -->
<div
class="flex justify-center items-center"
style="display: flex; justify-content: center; align-items: center;"
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"
>
<img
src={image}
alt={adventure.name}
style="max-width: 100%; max-height: 75vh; object-fit: contain;"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<svg
class="w-6 h-6 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-primary">
{name}
</h1>
{#if images.length > 1}
<p class="text-sm text-base-content/60">
{currentIndex + 1} of {images.length}
{$t('adventures.images')}
</p>
{/if}
</div>
</div>
<!-- Navigation indicators for multiple images -->
{#if images.length > 1}
<div class="hidden md:flex items-center gap-2">
<div class="flex gap-1">
{#each images as _, index}
<button
class="w-2 h-2 rounded-full transition-all {index === currentIndex
? 'bg-primary'
: 'bg-base-300 hover:bg-base-400'}"
on:click={() => goToSlide(index)}
/>
{/each}
</div>
</div>
{/if}
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Image Display Area -->
<div class="relative h-[75vh] flex justify-center items-center max-w-full">
<!-- Previous Button -->
{#if images.length > 1}
<button
class="absolute left-4 top-1/2 -translate-y-1/2 z-20 btn btn-circle btn-primary/80 hover:btn-primary"
on:click={previousSlide}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<!-- Main Image -->
<div class="flex justify-center items-center max-w-full">
<img
src={currentImage}
alt={name}
class="max-w-full max-h-[75vh] object-contain rounded-lg shadow-lg"
style="max-width: 100%; max-height: 75vh; object-fit: contain;"
/>
</div>
<!-- Next Button -->
{#if images.length > 1}
<button
class="absolute right-4 top-1/2 -translate-y-1/2 z-20 btn btn-circle btn-primary/80 hover:btn-primary"
on:click={nextSlide}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{/if}
</div>
<!-- Thumbnail Navigation (for multiple images) -->
{#if images.length > 1}
<div class="mt-6 px-2">
<div class="flex gap-2 overflow-x-auto pb-2">
{#each images as imageData, index}
<button
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all {index ===
currentIndex
? 'border-primary shadow-lg'
: 'border-base-300 hover:border-base-400'}"
on:click={() => goToSlide(index)}
>
<img src={imageData.image} alt={name} class="w-full h-full object-cover" />
</button>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
<div
class="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">
{#if location}
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{location}
</span>
{/if}
</div>
<div class="flex items-center gap-3">
{#if images.length > 1}
<div class="text-sm text-base-content/60">
{$t('adventures.image_modal_navigate')}
</div>
{/if}
<button class="btn btn-primary gap-2" on:click={close}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('about.close')}
</button>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import type { Checklist, Lodging, Note, Transportation } from '$lib/types';
import { deserialize } from '$app/forms';
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
export let object: Lodging | Transportation;
export let objectType: 'lodging' | 'transportation' | 'note' | 'checklist';
export let isImagesUploading: boolean = false;
let imageInput: HTMLInputElement;
let imageFiles: File[] = [];
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
imageFiles = Array.from(target.files);
console.log('Images selected:', imageFiles.length);
if (object.id) {
// If object exists, upload immediately
uploadImages();
}
}
}
// Watch for external trigger to upload images
$: {
if (isImagesUploading && imageFiles.length > 0 && object.id) {
// Immediately clear the trigger to prevent infinite loop
const filesToUpload = [...imageFiles];
imageFiles = []; // Clear immediately
if (imageInput) {
imageInput.value = '';
}
uploadImagesFromList(filesToUpload);
}
}
async function uploadImages() {
if (imageFiles.length === 0) {
isImagesUploading = false;
return;
}
const filesToUpload = [...imageFiles];
// Clear immediately to prevent re-triggering
imageFiles = [];
if (imageInput) {
imageInput.value = '';
}
await uploadImagesFromList(filesToUpload);
}
async function uploadImagesFromList(files: File[]) {
if (files.length === 0) {
isImagesUploading = false;
return;
}
console.log('Starting image upload for', files.length, 'files');
try {
// Upload all images concurrently
const uploadPromises = files.map((file) => uploadImage(file));
await Promise.all(uploadPromises);
} catch (error) {
console.error('Error uploading images:', error);
addToast('error', $t('adventures.image_upload_error'));
} finally {
isImagesUploading = false;
}
}
async function uploadImage(file: File): Promise<void> {
let formData = new FormData();
formData.append('image', file);
formData.append('object_id', object.id);
formData.append('content_type', objectType);
let res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
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,
immich_id: null
};
object.images = [...(object.images || []), newImage];
} else {
throw new Error(`Failed to upload ${file.name}`);
}
}
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
object.images = object.images.filter((image: { id: string }) => image.id !== id);
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
object.images = object.images.map((image) => {
if (image.id === image_id) {
return { ...image, is_primary: true };
} else {
return { ...image, is_primary: false };
}
});
} else {
console.error('Error in makePrimaryImage:', res);
}
}
// Export function to check if images are ready to upload
export function hasImagesToUpload(): boolean {
return imageFiles.length > 0;
}
</script>
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" />
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{$t('adventures.images')}
{#if isImagesUploading}
<span class="loading loading-spinner loading-sm text-primary"></span>
{/if}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
disabled={isImagesUploading}
/>
</div>
{#if imageFiles.length > 0 && !object.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_images')} ({imageFiles.length})
</h4>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
><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.image_upload_info')} {objectType}</span>
</div>
<ul class="list-disc pl-5 space-y-1 mt-2">
{#each imageFiles as file}
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
{/each}
</ul>
</div>
{/if}
{#if object.id}
<div class="divider my-6"></div>
<!-- Current Images -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
{#if object.images && object.images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each object.images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<!-- Image Controls -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm"
on:click={() => makePrimaryImage(image.id)}
title="Make Primary"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
></path>
</svg>
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 3l14 9-14 9V3z"
></path>
</svg>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">{$t('adventures.no_images_desc')}</p>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -1,91 +0,0 @@
<script lang="ts">
import { addToast } from '$lib/toasts';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
let url: string = '';
export let name: string | null = null;
let error = '';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
async function fetchImage() {
let res = await fetch(url);
let data = await res.blob();
if (!data) {
error = 'No image found at that URL.';
return;
}
let file = new File([data], 'image.jpg', { type: 'image/jpeg' });
close();
dispatch('image', { file });
}
async function fetchWikiImage() {
let res = await fetch(`/api/generate/img/?name=${name}`);
let data = await res.json();
if (data.source) {
let imageUrl = data.source;
let res = await fetch(imageUrl);
let blob = await res.blob();
let file = new File([blob], `${name}.jpg`, { type: 'image/jpeg' });
close();
dispatch('image', { file });
} else {
error = 'No image found for that Wikipedia article.';
}
}
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
}
}
</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">Image Fetcher with URL</h3>
<form>
<input
type="text"
class="input input-bordered w-full max-w-xs"
bind:value={url}
placeholder="Enter a URL"
/>
<button class="btn btn-primary" on:click={fetchImage}>Submit</button>
</form>
<h3 class="font-bold text-lg">Image Fetcher from Wikipedia</h3>
<form>
<input
type="text"
class="input input-bordered w-full max-w-xs"
bind:value={name}
placeholder="Enter a Wikipedia Article Name"
/>
<button class="btn btn-primary" on:click={fetchWikiImage}>Submit</button>
</form>
{#if error}
<p class="text-red-500">{error}</p>
{/if}
<button class="btn btn-primary" on:click={close}>Close</button>
</div>
</dialog>

View File

@@ -7,50 +7,139 @@
export let background: Background;
import { t } from 'svelte-i18n';
import AccountIcon from '~icons/mdi/account';
import LocationIcon from '~icons/mdi/map-marker';
import DiscordIcon from '~icons/mdi/discord';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal = document.getElementById('image_info_modal') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
});
function close() {
if (modal) {
modal.close();
}
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
close();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === modal) {
close();
}
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog id="image_info_modal" class="modal modal-open" on:click={handleBackdropClick}>
<!-- 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('settings.about_this_background')}<span class=" inline-block"></span>
</h3>
<div class="flex flex-col items-center">
{#if background.author != ''}
<p class="text-center mt-2">{$t('settings.photo_by')} {background.author}</p>
{/if}
{#if background.location != ''}
<p class="text-center">{$t('adventures.location')}: {background.location}</p>
{/if}
<p class="text-center mt-4">
<a
href="https://discord.gg/wRbQ9Egr8C"
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 hover:underline"
>
{$t('settings.join_discord')}
</a>
{$t('settings.join_discord_desc')}
</p>
<div class="modal-box w-full max-w-md" role="dialog" on:keydown={handleKeydown} tabindex="0">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold text-base-content">
{$t('settings.about_this_background')}
</h3>
<button class="btn btn-sm btn-circle btn-ghost" on:click={close} aria-label="Close">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Background Info -->
<div class="space-y-4">
<!-- Image Preview -->
{#if background.url}
<div class="w-full h-32 bg-base-200 rounded-lg overflow-hidden">
<img src={background.url} alt="Background preview" class="w-full h-full object-cover" />
</div>
{/if}
<!-- Author Info -->
{#if background.author && background.author.trim() !== ''}
<div class="flex items-center gap-3 p-3 bg-base-100 rounded-lg">
<div
class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0"
>
<AccountIcon class="w-4 h-4 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-base-content/70">{$t('settings.photo_by')}</p>
<p class="font-medium text-base-content">{background.author}</p>
</div>
</div>
{/if}
<!-- Location Info -->
{#if background.location && background.location.trim() !== ''}
<div class="flex items-center gap-3 p-3 bg-base-100 rounded-lg">
<div
class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0"
>
<LocationIcon class="w-4 h-4 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-base-content/70">{$t('adventures.location')}</p>
<p class="font-medium text-base-content">{background.location}</p>
</div>
</div>
{/if}
<!-- Community Info -->
<div class="bg-primary/5 rounded-lg p-4">
<div class="flex items-start gap-3">
<div
class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"
>
<DiscordIcon class="w-4 h-4 text-primary" />
</div>
<div>
<p class="font-medium text-base-content mb-1">{$t('settings.join_discord')}</p>
<p class="text-sm text-base-content/70 mb-3">
{$t('settings.join_discord_desc')}
</p>
<a
href="https://discord.gg/wRbQ9Egr8C"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary btn-sm"
>
<DiscordIcon class="w-4 h-4" />
{$t('settings.join_discord')}
</a>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-action mt-6">
<button class="btn btn-primary w-full" on:click={close}>
{$t('about.close')}
</button>
</div>
<button class="btn btn-primary" on:click={close}>{$t('about.close')}</button>
</div>
</dialog>
<style>
.modal-open {
pointer-events: auto;
visibility: visible;
opacity: 1;
}
</style>

View File

@@ -2,249 +2,374 @@
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg';
import type { Adventure, ImmichAlbum } from '$lib/types';
import Upload from '~icons/mdi/upload';
import type { ImmichAlbum } from '$lib/types';
import { debounce } from '$lib';
// Props
export let copyImmichLocally: boolean = false;
export let objectId: string = '';
export let contentType: string = 'location';
export let defaultDate: string = '';
// Component state
let immichImages: any[] = [];
let immichSearchValue: string = '';
let searchCategory: 'search' | 'date' | 'album' = 'date';
let immichError: string = '';
let immichNextURL: string = '';
let loading = false;
export let adventure: Adventure | null = null;
export let copyImmichLocally: boolean = false;
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
let selectedDate: string = defaultDate || new Date().toISOString().split('T')[0];
const dispatch = createEventDispatcher();
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
let selectedDate: string =
(adventure as Adventure | null)?.visits
.map((v) => new Date(v.end_date || v.start_date))
.sort((a, b) => +b - +a)[0]
?.toISOString()
?.split('T')[0] || '';
if (!selectedDate) {
selectedDate = new Date().toISOString().split('T')[0];
}
// Reactive statements
$: {
if (searchCategory === 'album' && currentAlbum) {
immichImages = [];
fetchAlbumAssets(currentAlbum);
} else if (searchCategory === 'date' && selectedDate) {
// Clear album selection when switching to date mode
if (currentAlbum) {
currentAlbum = '';
}
clearAlbumSelection();
searchImmich();
} else if (searchCategory === 'search') {
// Clear album selection when switching to search mode
if (currentAlbum) {
currentAlbum = '';
}
// Search will be triggered by the form submission or debounced search
clearAlbumSelection();
}
}
async function loadMoreImmich() {
// The next URL returned by our API is a absolute url to API, but we need to use the relative path, to use the frontend api proxy.
const url = new URL(immichNextURL);
immichNextURL = url.pathname + url.search;
return fetchAssets(immichNextURL, true);
}
async function saveImmichRemoteUrl(imageId: string) {
if (!adventure) {
console.error('No adventure provided to save the image URL');
return;
}
let res = await fetch('/api/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
immich_id: imageId,
adventure: adventure.id
})
});
if (res.ok) {
let data = await res.json();
if (!data.image) {
console.error('No image data returned from the server');
immichError = $t('immich.error_saving_image');
return;
}
dispatch('remoteImmichSaved', data);
} else {
let errorData = await res.json();
console.error('Error saving image URL:', errorData);
immichError = $t(errorData.message || 'immich.error_saving_image');
// Helper functions
function clearAlbumSelection() {
if (currentAlbum) {
currentAlbum = '';
}
}
async function fetchAssets(url: string, usingNext = false) {
loading = true;
try {
let res = await fetch(url);
immichError = '';
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.error('Error in handling fetchAsstes', errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
if (data.results && data.results.length > 0) {
if (usingNext) {
immichImages = [...immichImages, ...data.results];
} else {
immichImages = data.results;
}
} else {
immichError = $t('immich.no_items_found');
}
function buildQueryParams(): string {
const params = new URLSearchParams();
immichNextURL = data.next || '';
}
} finally {
loading = false;
}
}
async function fetchAlbumAssets(album_id: string) {
return fetchAssets(`/api/integrations/immich/albums/${album_id}`);
}
onMount(async () => {
let res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
let data = await res.json();
albums = data;
}
});
function buildQueryParams() {
let params = new URLSearchParams();
if (immichSearchValue && searchCategory === 'search') {
params.append('query', immichSearchValue);
} else if (selectedDate && searchCategory === 'date') {
params.append('date', selectedDate);
}
return params.toString();
}
// API functions
async function fetchAssets(url: string, usingNext = false): Promise<void> {
loading = true;
immichError = '';
try {
const res = await fetch(url);
if (!res.ok) {
const data = await res.json();
console.error('Error in fetchAssets:', data.message);
immichError = $t(data.code || 'immich.fetch_error');
return;
}
const data = await res.json();
if (data.results && data.results.length > 0) {
if (usingNext) {
immichImages = [...immichImages, ...data.results];
} else {
immichImages = data.results;
}
immichNextURL = data.next || '';
} else {
immichError = $t('immich.no_items_found');
immichNextURL = '';
}
} catch (error) {
console.error('Error fetching assets:', error);
immichError = $t('immich.fetch_error');
} finally {
loading = false;
}
}
async function fetchAlbumAssets(albumId: string): Promise<void> {
return fetchAssets(`/api/integrations/immich/albums/${albumId}`);
}
async function loadMoreImmich(): Promise<void> {
if (!immichNextURL) return;
// Convert absolute URL to relative path for frontend API proxy
const url = new URL(immichNextURL);
const relativePath = url.pathname + url.search;
return fetchAssets(relativePath, true);
}
async function saveImmichRemoteUrl(imageId: string): Promise<void> {
if (!objectId) {
console.error('No object ID provided to save the image URL');
immichError = $t('immich.error_no_object_id');
return;
}
try {
const res = await fetch('/api/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
immich_id: imageId,
object_id: objectId,
content_type: contentType
})
});
if (res.ok) {
const data = await res.json();
if (!data.image) {
console.error('No image data returned from the server');
immichError = $t('immich.error_saving_image');
return;
}
dispatch('remoteImmichSaved', data);
} else {
const errorData = await res.json();
console.error('Error saving image URL:', errorData);
immichError = $t(errorData.message || 'immich.error_saving_image');
}
} catch (error) {
console.error('Error in saveImmichRemoteUrl:', error);
immichError = $t('immich.error_saving_image');
}
}
// Event handlers
const searchImmich = debounce(() => {
_searchImmich();
}, 500); // Debounce the search function to avoid multiple requests on every key press
}, 500);
async function _searchImmich() {
async function _searchImmich(): Promise<void> {
immichImages = [];
return fetchAssets(`/api/integrations/immich/search/?${buildQueryParams()}`);
}
function handleSearchCategoryChange(category: 'search' | 'date' | 'album') {
searchCategory = category;
immichError = '';
if (category !== 'album') {
clearAlbumSelection();
}
}
function handleImageSelect(image: any) {
const currentDomain = window.location.origin;
const fullUrl = `${currentDomain}/immich/${image.id}`;
if (copyImmichLocally) {
dispatch('fetchImage', fullUrl);
} else {
saveImmichRemoteUrl(image.id);
}
}
// Lifecycle
onMount(async () => {
try {
const res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
const data = await res.json();
albums = data;
} else {
console.warn('Failed to fetch Immich albums');
}
} catch (error) {
console.error('Error fetching albums:', error);
}
});
</script>
<div class="mb-4">
<label for="immich" class="block font-medium mb-2">
{$t('immich.immich')}
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" />
</label>
<div class="mt-4">
<div class="join">
<input
on:click={() => (currentAlbum = '')}
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="search"
aria-label="Search"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="date"
aria-label="Show by date"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="album"
aria-label="Select Album"
/>
</div>
<div>
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich}>
<input
type="text"
placeholder="Type here"
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs"
/>
<button type="submit" class="btn btn-neutral mt-2">Search</button>
</form>
{:else if searchCategory === 'date'}
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center gap-2 mb-4">
<h4 class="font-medium text-lg">
{$t('immich.immich')}
</h4>
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
</div>
<!-- Search Category Tabs -->
<div class="tabs tabs-boxed w-fit">
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => handleSearchCategoryChange('search')}
>
{$t('navbar.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => handleSearchCategoryChange('date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => handleSearchCategoryChange('album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich} class="flex gap-2">
<input
type="text"
placeholder={$t('immich.image_search_placeholder') + '...'}
bind:value={immichSearchValue}
class="input input-bordered flex-1"
disabled={loading}
/>
<button
type="submit"
class="btn btn-primary"
class:loading
disabled={loading || !immichSearchValue.trim()}
>
{$t('navbar.search')}
</button>
</form>
{:else if searchCategory === 'date'}
<div class="flex items-center gap-2">
<label class="label" for="date-picker">
<span class="label-text">{$t('immich.select_date')}</span>
</label>
<input
id="date-picker"
type="date"
bind:value={selectedDate}
class="input input-bordered w-full max-w-xs mt-2"
class="input input-bordered w-full max-w-xs"
disabled={loading}
/>
{:else if searchCategory === 'album'}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
<option value="" disabled selected>Select an Album</option>
{#each albums as album}
</div>
{:else if searchCategory === 'album'}
<div class="flex items-center gap-2">
<label class="label" for="album-select">
<span class="label-text">{$t('immich.select_album')}</span>
</label>
<select
id="album-select"
class="select select-bordered w-full max-w-xs"
bind:value={currentAlbum}
disabled={loading}
>
<option value="" disabled>
{albums.length > 0 ? $t('immich.select_album') : $t('immich.loading_albums')}
</option>
{#each albums as album (album.id)}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
{/if}
</div>
</div>
{/if}
</div>
<p class="text-red-500">{immichError}</p>
<div class="flex flex-wrap gap-4 mr-4 mt-2">
<!-- Error Message -->
{#if immichError}
<div class="alert alert-error py-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{immichError}</span>
</div>
{/if}
<!-- Images Grid -->
<div class="relative">
<!-- Loading Overlay -->
{#if loading}
<div
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[100] w-24 h-24"
class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
>
<span class="loading loading-spinner w-24 h-24"></span>
<div class="flex flex-col items-center gap-2">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-sm text-base-content/70">{$t('immich.loading')}</span>
</div>
</div>
{/if}
{#each immichImages as image}
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}>
<!-- svelte-ignore a11y-img-redundant-alt -->
<img
src={`${image.image_url}`}
alt="Image from Immich"
class="h-24 w-24 object-cover rounded-md"
/>
<h4>
{image.fileCreatedAt?.split('T')[0] || 'Unknown'}
</h4>
<button
type="button"
class="btn btn-sm btn-primary"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
if (copyImmichLocally) {
dispatch('fetchImage', fullUrl);
} else {
saveImmichRemoteUrl(image.id);
}
}}
>
{$t('adventures.upload_image')}
<!-- Images Grid -->
{#if immichImages.length > 0}
<div
class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
class:opacity-50={loading}
>
{#each immichImages as image (image.id)}
<div
class="card bg-base-100 shadow-sm hover:shadow-md transition-all duration-200 border border-base-200"
>
<figure class="aspect-square overflow-hidden">
<img
src={image.image_url}
alt="Image from Immich"
class="w-full h-full object-cover hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
</figure>
<div class="card-body p-3">
<button
type="button"
class="btn btn-primary btn-sm w-full gap-2"
disabled={loading}
on:click={() => handleImageSelect(image)}
>
<Upload class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else if !loading && searchCategory !== 'search'}
<div class="bg-base-200/50 rounded-lg p-8 text-center">
<div class="text-base-content/60 mb-2">{$t('immich.no_images')}</div>
<div class="text-sm text-base-content/40">
{#if searchCategory === 'date'}
{$t('immich.try_different_date')}
{:else if searchCategory === 'album'}
{$t('immich.select_album_first')}
{/if}
</div>
</div>
{/if}
<!-- Load More Button -->
{#if immichNextURL && !loading}
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich} disabled={loading}>
{$t('immich.load_more')}
</button>
</div>
{/each}
{#if immichNextURL}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
{/if}
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import type { Adventure, Collection, User } from '$lib/types';
import type { Location, Collection, User } from '$lib/types';
const dispatch = createEventDispatcher();
import Launch from '~icons/mdi/launch';
@@ -31,19 +31,19 @@
let isCollectionModalOpen: boolean = false;
let isWarningModalOpen: boolean = false;
export let adventure: Adventure;
export let adventure: Location;
let displayActivityTypes: string[] = [];
let remainingCount = 0;
// Process activity types for display
$: {
if (adventure.activity_types) {
if (adventure.activity_types.length <= 3) {
displayActivityTypes = adventure.activity_types;
if (adventure.tags) {
if (adventure.tags.length <= 3) {
displayActivityTypes = adventure.tags;
remainingCount = 0;
} else {
displayActivityTypes = adventure.activity_types.slice(0, 3);
remainingCount = adventure.activity_types.length - 3;
displayActivityTypes = adventure.tags.slice(0, 3);
remainingCount = adventure.tags.length - 3;
}
}
}
@@ -62,6 +62,16 @@
}
}
// Creator avatar helpers
$: creatorInitials =
adventure.user?.first_name && adventure.user?.last_name
? `${adventure.user.first_name[0]}${adventure.user.last_name[0]}`
: adventure.user?.first_name?.[0] || adventure.user?.username?.[0] || '?';
$: creatorDisplayName = adventure.user?.first_name
? `${adventure.user.first_name} ${adventure.user.last_name || ''}`.trim()
: adventure.user?.username || 'Unknown User';
// Helper functions for display
function formatVisitCount() {
const count = adventure.visits.length;
@@ -77,11 +87,11 @@
}
async function deleteAdventure() {
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('adventures.adventure_delete_success'));
addToast('info', $t('adventures.location_delete_success'));
dispatch('delete', adventure.id);
} else {
console.log('Error deleting adventure');
@@ -98,7 +108,7 @@
updatedCollections.push(collectionId);
}
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@@ -109,16 +119,16 @@
if (res.ok) {
// Only update the adventure.collections after server confirms success
adventure.collections = updatedCollections;
addToast('info', `${$t('adventures.collection_link_success')}`);
addToast('info', `${$t('adventures.collection_link_location_success')}`);
} else {
addToast('error', `${$t('adventures.collection_link_error')}`);
addToast('error', `${$t('adventures.collection_link_location_error')}`);
}
}
async function removeFromCollection(event: CustomEvent<string>) {
let collectionId = event.detail;
if (!collectionId) {
addToast('error', `${$t('adventures.collection_remove_error')}`);
addToast('error', `${$t('adventures.collection_remove_location_error')}`);
return;
}
@@ -128,7 +138,7 @@
(c) => String(c) !== String(collectionId)
);
let res = await fetch(`/api/adventures/${adventure.id}`, {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
@@ -139,9 +149,9 @@
if (res.ok) {
// Only update adventure.collections after server confirms success
adventure.collections = updatedCollections;
addToast('info', `${$t('adventures.collection_remove_success')}`);
addToast('info', `${$t('adventures.collection_remove_location_success')}`);
} else {
addToast('error', `${$t('adventures.collection_remove_error')}`);
addToast('error', `${$t('adventures.collection_remove_location_error')}`);
}
}
}
@@ -166,9 +176,9 @@
{#if isWarningModalOpen}
<DeleteWarning
title={$t('adventures.delete_adventure')}
title={$t('adventures.delete_location')}
button_text="Delete"
description={$t('adventures.adventure_delete_confirm')}
description={$t('adventures.location_delete_confirm')}
is_warning={false}
on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteAdventure}
@@ -180,7 +190,7 @@
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={[adventure]} />
<CardCarousel images={adventure.images} icon={adventure.category?.icon} name={adventure.name} />
<!-- Status Overlay -->
<div class="absolute top-4 left-4 flex flex-col gap-2">
@@ -221,18 +231,43 @@
</div>
</div>
{/if}
<!-- Creator Avatar -->
{#if adventure.user && collection}
<div class="absolute bottom-4 right-4">
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
<div class="avatar">
<div class="w-8 h-8 rounded-full ring-2 ring-white/50 shadow-lg">
{#if adventure.user.profile_pic}
<img
src={adventure.user.profile_pic}
alt={creatorDisplayName}
class="rounded-full object-cover"
/>
{:else}
<div
class="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs shadow-lg"
>
{creatorInitials.toUpperCase()}
</div>
{/if}
</div>
</div>
</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-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline"
<a
href="/locations/{adventure.id}"
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline block"
>
{adventure.name}
</button>
</a>
<!-- Location -->
{#if adventure.location}
@@ -273,14 +308,14 @@
{#if type != 'link'}
<div class="flex justify-between items-center">
<button
class="btn btn-neutral btn-sm flex-1 mr-2"
on:click={() => goto(`/adventures/${adventure.id}`)}
class="btn btn-base-300 btn-sm flex-1 mr-2"
on:click={() => goto(`/locations/${adventure.id}`)}
>
<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))}
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == 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" />
@@ -293,11 +328,11 @@
<li>
<button on:click={editAdventure} class="flex items-center gap-2">
<FileDocumentEdit class="w-4 h-4" />
{$t('adventures.edit_adventure')}
{$t('adventures.edit_location')}
</button>
</li>
{#if user?.uuid == adventure.user_id}
{#if user?.uuid == adventure.user?.uuid}
<li>
<button
on:click={() => (isCollectionModalOpen = true)}
@@ -307,20 +342,34 @@
{$t('collection.manage_collections')}
</button>
</li>
{:else if collection && user && collection.user == user.uuid}
<li>
<button
on:click={() =>
removeFromCollection(
new CustomEvent('unlink', { detail: collection.id })
)}
class="flex items-center gap-2"
>
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if user.uuid == adventure.user?.uuid}
<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>
{/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}

View File

@@ -2,12 +2,12 @@
import { getBasemapUrl } from '$lib';
import { appVersion } from '$lib/config';
import { addToast } from '$lib/toasts';
import type { Adventure, Lodging, GeocodeSearchResult, Point, ReverseGeocode } from '$lib/types';
import type { Location, Lodging, GeocodeSearchResult, Point, ReverseGeocode } from '$lib/types';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
export let item: Adventure | Lodging;
export let item: Location | Lodging;
export let triggerMarkVisted: boolean = false;
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
@@ -224,59 +224,152 @@
}
</script>
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.location_information')}
<!-- Location Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
{$t('adventures.location_information')}
</div>
</div>
<div class="collapse-content">
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
<div>
<label for="latitude">{$t('adventures.location')}</label><br />
<div class="flex items-center">
<div class="collapse-content bg-base-100/50 p-6 space-y-6">
<!-- Location Name Input -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">{$t('adventures.display_name')}</span>
</label>
<div class="flex items-center gap-3">
<input
type="text"
id="location"
name="location"
bind:value={item.location}
class="input input-bordered w-full"
class="input input-bordered flex-1 bg-base-100/80 focus:bg-base-100"
placeholder="Boston, MA, US"
/>
{#if is_custom_location}
<button
class="btn btn-primary ml-2"
class="btn btn-primary gap-2"
type="button"
on:click={() => (item.location = reverseGeocodePlace?.display_name)}
>{$t('adventures.set_to_pin')}</button
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{$t('adventures.set_to_pin')}
</button>
{/if}
</div>
</div>
<div>
<form on:submit={geocode} class="mt-2">
<input
type="text"
placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full max-w-xs mb-2"
id="search"
name="search"
bind:value={query}
/>
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button>
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
>{$t('adventures.clear_map')}</button
>
<!-- Location Search -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
</label>
<form on:submit={geocode} class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
id="search"
name="search"
bind:value={query}
/>
</div>
<div class="flex gap-2">
<button class="btn btn-neutral gap-2" type="submit">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('navbar.search')}
</button>
<button class="btn btn-neutral btn-outline gap-2" type="button" on:click={clearMap}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_map')}
</button>
</div>
</form>
</div>
{#if places.length > 0}
<div class="mt-4 max-w-full">
<h3 class="font-bold text-lg mb-4">{$t('adventures.search_results')}</h3>
<div class="flex flex-wrap">
<!-- Search Results -->
{#if places.length > 0}
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{$t('adventures.search_results')}
</span>
</label>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 bg-base-100/80 border border-base-300 rounded-xl max-h-60 overflow-y-auto"
>
{#each places as place}
<button
type="button"
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
class="btn btn-ghost btn-sm h-auto min-h-0 p-3 justify-start text-left hover:bg-base-200"
on:click={() => {
markers = [
{
@@ -292,85 +385,232 @@
}
}}
>
<span>{place.name}</span>
<br />
<small class="text-xs text-neutral-300">{place.display_name}</small>
<div class="flex flex-col items-start w-full">
<span class="font-medium text-sm">{place.name}</span>
<small class="text-xs text-base-content/60 truncate w-full"
>{place.display_name}</small
>
</div>
</button>
{/each}
</div>
</div>
{:else if noPlaces}
<p class="text-error text-lg">{$t('adventures.no_results')}</p>
<div class="alert alert-error">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t('adventures.no_results')}</span>
</div>
{/if}
<!-- </div> -->
<div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<MapEvents on:click={addMarker} />
{#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
{#if reverseGeocodePlace}
<div class="mt-2 p-4 bg-neutral rounded-lg shadow-md">
<h3 class="text-lg font-bold mb-2">{$t('adventures.location_details')}</h3>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.display_name')}:</span>
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
</p>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.region')}:</span>
{reverseGeocodePlace.region}
{reverseGeocodePlace.region_visited ? '✅' : '❌'}
</p>
{#if reverseGeocodePlace.city}
<p class="mb-1">
<span class="font-semibold">{$t('adventures.city')}:</span>
{reverseGeocodePlace.city}
{reverseGeocodePlace.city_visited ? '✅' : '❌'}
</p>
{/if}
</div>
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
{/if}
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div role="alert" class="alert alert-info mt-2 flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current mr-2"
>
<!-- Map Container -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('worldtravel.interactive_map')}
</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4">
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg border border-base-300"
standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
>
<MapEvents on:click={addMarker} />
{#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
</div>
</div>
<!-- Location Details -->
{#if reverseGeocodePlace}
<div class="bg-gradient-to-r from-info/10 to-info/5 border border-info/20 rounded-xl p-6">
<h3 class="text-lg font-bold flex items-center gap-2 mb-4">
<div class="p-2 bg-info/10 rounded-lg">
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
{$t('adventures.location_details')}
</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.display_name')}:</span>
<span class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}
</span>
</div>
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.region')}:</span>
<span class="text-sm">{reverseGeocodePlace.region}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.region_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{#if reverseGeocodePlace.city}
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.city')}:</span>
<span class="text-sm">{reverseGeocodePlace.city}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.city_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Mark Visited Button -->
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary gap-2 mt-4" on:click={markVisited}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.mark_visited')}
</button>
{/if}
{/if}
</div>
<!-- Will be marked visited alert -->
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div class="alert alert-info mt-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
/>
</svg>
<div>
<h4 class="font-bold">{$t('adventures.location_will_be_marked')}</h4>
<div class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import type { Adventure, User } from '$lib/types';
import type { Location, User } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import AdventureCard from './AdventureCard.svelte';
import LocationCard from './LocationCard.svelte';
let modal: HTMLDialogElement;
// Icons - following the worldtravel pattern
@@ -17,8 +17,8 @@
import Public from '~icons/mdi/earth';
import Private from '~icons/mdi/lock';
let adventures: Adventure[] = [];
let filteredAdventures: Adventure[] = [];
let adventures: Location[] = [];
let filteredAdventures: Location[] = [];
let searchQuery: string = '';
let filterOption: string = 'all';
let isLoading: boolean = true;
@@ -69,7 +69,7 @@
modal.showModal();
}
let res = await fetch(`/api/adventures/all/?include_collections=true`, {
let res = await fetch(`/api/locations/all/?include_collections=true`, {
method: 'GET'
});
@@ -77,7 +77,7 @@
// Filter out adventures that are already linked to the collections
if (collectionId) {
adventures = newAdventures.filter((adventure: Adventure) => {
adventures = newAdventures.filter((adventure: Location) => {
return !(adventure.collections ?? []).includes(collectionId);
});
} else {
@@ -91,7 +91,7 @@
dispatch('close');
}
function add(event: CustomEvent<Adventure>) {
function add(event: CustomEvent<Location>) {
adventures = adventures.filter((a) => a.id !== event.detail.id);
dispatch('add', event.detail);
}
@@ -134,7 +134,7 @@
{filteredAdventures.length}
{$t('worldtravel.of')}
{totalAdventures}
{$t('navbar.adventures')}
{$t('locations.locations')}
</p>
</div>
</div>
@@ -252,7 +252,7 @@
</div>
{#if searchQuery || filterOption !== 'all'}
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
{$t('adventures.no_adventures_found')}
{$t('adventures.no_locations_found')}
</h3>
<p class="text-base-content/50 text-center max-w-md mb-6">
{$t('collection.try_different_search')}
@@ -274,7 +274,7 @@
<!-- 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} />
<LocationCard {user} type="link" {adventure} on:link={add} />
{/each}
</div>
{/if}

View File

@@ -10,6 +10,7 @@
import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
const dispatch = createEventDispatcher();
@@ -96,6 +97,20 @@
<div
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"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-4">
<!-- Header -->
<div class="flex flex-col gap-3">
@@ -156,7 +171,7 @@
</div>
<!-- Reservation Info -->
{#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="space-y-2">
{#if lodging.reservation_number}
<div class="flex items-center gap-2">
@@ -174,7 +189,7 @@
{/if}
<!-- Actions -->
{#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<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"

View File

@@ -9,29 +9,33 @@
import { isAllDay } from '$lib';
// @ts-ignore
import { DateTime } from 'luxon';
import ImageDropdown from './ImageDropdown.svelte';
import AttachmentDropdown from './AttachmentDropdown.svelte';
const dispatch = createEventDispatcher();
export let collection: Collection;
export let lodgingToEdit: Lodging | null = null;
let imageDropdownRef: any;
let attachmentDropdownRef: any;
// when this is true the image and attachment sections will create their upload requests
let isImagesUploading: boolean = false;
let isAttachmentsUploading: boolean = false;
let modal: HTMLDialogElement;
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
let fullStartDate: string = '';
let fullEndDate: string = '';
type LodgingType = {
value: string;
label: string;
};
let lodgingTimezone: string | undefined = lodging.timezone ?? undefined;
// Initialize hotel with values from lodgingToEdit or default values
function initializeLodging(lodgingToEdit: Lodging | null): Lodging {
return {
id: lodgingToEdit?.id || '',
user_id: lodgingToEdit?.user_id || '',
user: lodgingToEdit?.user || '',
name: lodgingToEdit?.name || '',
type: lodgingToEdit?.type || 'other',
description: lodgingToEdit?.description || '',
@@ -48,7 +52,9 @@
collection: lodgingToEdit?.collection || collection.id,
created_at: lodgingToEdit?.created_at || '',
updated_at: lodgingToEdit?.updated_at || '',
timezone: lodgingToEdit?.timezone || ''
timezone: lodgingToEdit?.timezone || '',
images: lodgingToEdit?.images || [],
attachments: lodgingToEdit?.attachments || []
};
}
@@ -88,7 +94,6 @@
lodging.timezone = lodgingTimezone || null;
// Auto-set end date if missing but start date exists
// If check_out is not set, we will set it to the next day at 9:00 AM in the lodging's timezone if it is a timed event. If it is an all-day event, we will set it to the next day at UTC 00:00:00.
if (lodging.check_in && !lodging.check_out) {
if (isAllDay(lodging.check_in)) {
// For all-day, just add one day and keep at UTC 00:00:00
@@ -104,212 +109,387 @@
}
}
// Create or update lodging...
const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`;
const method = lodging.id === '' ? 'POST' : 'PATCH';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lodging)
});
const data = await res.json();
if (data.id) {
lodging = data as Lodging;
const toastMessage =
lodging.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated';
addToast('success', $t(toastMessage));
dispatch('save', lodging);
} else {
const errorMessage =
lodging.id === ''
? 'adventures.adventure_create_error'
: 'adventures.adventure_update_error';
addToast('error', $t(errorMessage));
try {
// Create or update lodging first
const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`;
const method = lodging.id === '' ? 'POST' : 'PATCH';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lodging)
});
const data = await res.json();
if (data.id) {
lodging = data as Lodging;
// Now handle image uploads if there are any pending
if (imageDropdownRef?.hasImagesToUpload()) {
isImagesUploading = true;
// Wait for image upload to complete
await waitForUploadComplete();
}
// Similarly handle attachments if needed
if (attachmentDropdownRef?.hasAttachmentsToUpload()) {
isAttachmentsUploading = true;
// Wait for attachment upload to complete
await waitForAttachmentUploadComplete();
}
dispatch('save', lodging);
} else {
const errorMessage =
lodging.id === ''
? 'adventures.adventure_create_error'
: 'adventures.adventure_update_error';
addToast('error', $t(errorMessage));
}
} catch (error) {
console.error('Error saving lodging:', error);
addToast('error', $t('adventures.lodging_save_error'));
}
}
// Helper function to wait for image upload completion
async function waitForUploadComplete(): Promise<void> {
return new Promise((resolve) => {
const checkUpload = () => {
if (!isImagesUploading) {
resolve();
} else {
setTimeout(checkUpload, 100);
}
};
checkUpload();
});
}
// Helper function to wait for attachment upload completion
async function waitForAttachmentUploadComplete(): Promise<void> {
return new Promise((resolve) => {
const checkUpload = () => {
if (!isAttachmentsUploading) {
resolve();
} else {
setTimeout(checkUpload, 100);
}
};
checkUpload();
});
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h3>
<div class="modal-action items-center">
<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 -->
<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">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h1>
<p class="text-sm text-base-content/60">
{lodgingToEdit
? $t('lodging.update_lodging_details')
: $t('lodging.create_new_lodging')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={lodging.name}
class="input input-bordered w-full"
required
/>
</div>
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={lodging.type}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
<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"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
{#if lodging.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (lodging.rating = NaN)}
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="text"
id="name"
name="name"
bind:value={lodging.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_lodging_name')}
required
/>
</div>
<!-- Type Selection -->
<div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label>
<select
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={lodging.type}
>
{$t('adventures.remove')}
</button>
{/if}
<option disabled selected>{$t('transportation.select_type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input
type="number"
min="0"
max="5"
hidden
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
</div>
{#if lodging.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (lodging.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<!-- Lodging Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.lodging_information')}
</div>
<div class="collapse-content">
<!-- Reservation Number -->
<div>
<label for="date">
{$t('lodging.reservation_number')}
</label>
<div>
<input
type="text"
id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
{$t('adventures.lodging_information')}
</div>
<!-- Price -->
<div>
<label for="price">
{$t('adventures.price')}
</label>
<div>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Reservation Number -->
<div class="form-control">
<label class="label" for="reservation_number">
<span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
</label>
<input
type="text"
id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_reservation_number')}
/>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Price -->
<div class="form-control">
<label class="label" for="price">
<span class="label-text font-medium">{$t('adventures.price')}</span>
</label>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_price')}
/>
</div>
</div>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse
type="lodging"
bind:utcStartDate={lodging.check_in}
@@ -318,17 +498,41 @@
{collection}
/>
<!-- Location Information -->
<!-- Location Information Section -->
<LocationDropdown bind:item={lodging} />
<!-- Images Section -->
<ImageDropdown
bind:this={imageDropdownRef}
bind:object={lodging}
objectType="lodging"
bind:isImagesUploading
/>
<!-- Attachments Section -->
<AttachmentDropdown
bind:this={attachmentDropdownRef}
bind:object={lodging}
objectType="lodging"
bind:isAttachmentsUploading
/>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-neutral-200" on:click={close}>
{$t('about.close')}
</button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { basemapOptions, getBasemapLabel } from '$lib';
import MapIcon from '~icons/mdi/map';
export let basemapType: string = 'default';
</script>
<div class="dropdown dropdown-left">
<div tabindex="0" role="button" class="btn btn-sm btn-ghost gap-2 min-h-0 h-8 px-3">
<MapIcon class="w-4 h-4" />
<span class="text-xs font-medium">{getBasemapLabel(basemapType)}</span>
<svg class="w-3 h-3 fill-none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<ul class="dropdown-content z-20 menu p-2 shadow-lg bg-base-200 rounded-box w-48">
{#each basemapOptions as option}
<li>
<button
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md transition-colors {basemapType ===
option.value
? 'bg-primary/10 font-medium'
: ''}"
on:click={() => (basemapType = option.value)}
>
<span class="text-lg">{option.icon}</span>
<span>{option.label}</span>
{#if basemapType === option.value}
<svg class="w-4 h-4 ml-auto text-primary" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</li>
{/each}
</ul>
</div>

View File

@@ -60,7 +60,10 @@
pl: 'Polski',
ko: '한국어',
no: 'Norsk',
ru: 'Русский'
ru: 'Русский',
ja: '日本語',
ar: 'العربية',
'pt-br': 'Português (Brasil)'
};
const submitLocaleChange = (event: Event) => {
@@ -103,7 +106,7 @@
// Navigation items for better organization
const navigationItems = [
{ path: '/adventures', icon: MapMarker, label: 'navbar.adventures' },
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
{ path: '/map', icon: Map, label: 'navbar.map' },
@@ -134,18 +137,18 @@
<h3
class="text-sm font-semibold text-base-content/60 uppercase tracking-wide mb-2 px-2"
>
Navigation
{$t('navbar.navigation')}
</h3>
{#each navigationItems as item}
<li>
<button
<a
href={item.path}
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl"
on:click={() => goto(item.path)}
class:btn-active={$page.url.pathname === item.path}
>
<svelte:component this={item.icon} class="w-5 h-5" />
{$t(item.label)}
</button>
</a>
</li>
{/each}
</div>
@@ -157,7 +160,7 @@
<h3
class="text-sm font-semibold text-base-content/60 uppercase tracking-wide mb-2 px-2"
>
Search
{$t('navbar.search')}
</h3>
<form class="flex gap-2" on:submit={searchGo}>
<label class="input input-bordered flex items-center gap-2 flex-1">
@@ -207,15 +210,15 @@
<ul class="menu menu-horizontal gap-1">
{#each navigationItems as item}
<li>
<button
<a
href={item.path}
class="btn btn-ghost gap-2 rounded-xl transition-all duration-200 hover:bg-base-200"
class:bg-primary-10={$page.url.pathname === item.path}
class:text-primary={$page.url.pathname === item.path}
on:click={() => goto(item.path)}
>
<svelte:component this={item.icon} class="w-4 h-4" />
<span class="hidden xl:inline">{$t(item.label)}</span>
</button>
</a>
</li>
{/each}
</ul>

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import type { Collection, Location, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import LocationQuickStart from './locations/LocationQuickStart.svelte';
import LocationDetails from './locations/LocationDetails.svelte';
import LocationMedia from './locations/LocationMedia.svelte';
import LocationVisits from './locations/LocationVisits.svelte';
export let user: User | null = null;
export let collection: Collection | null = null;
export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let steps = [
{
name: $t('adventures.quick_start'),
selected: true,
requires_id: false
},
{
name: $t('adventures.details'),
selected: false,
requires_id: false
},
{
name: $t('settings.media'),
selected: false,
requires_id: true
},
{
name: $t('adventures.visits'),
selected: false,
requires_id: true
}
];
export let location: Location = {
id: '',
name: '',
visits: [],
link: null,
description: null,
tags: [],
rating: NaN,
is_public: false,
latitude: NaN,
longitude: NaN,
location: null,
images: [],
user: null,
category: {
id: '',
name: '',
display_name: '',
icon: '',
user: ''
},
attachments: [],
trails: []
};
export let locationToEdit: Location | null = null;
location = {
id: locationToEdit?.id || '',
name: locationToEdit?.name || '',
link: locationToEdit?.link || null,
description: locationToEdit?.description || null,
tags: locationToEdit?.tags || [],
rating: locationToEdit?.rating || NaN,
is_public: locationToEdit?.is_public || false,
latitude: locationToEdit?.latitude || NaN,
longitude: locationToEdit?.longitude || NaN,
location: locationToEdit?.location || null,
images: locationToEdit?.images || [],
user: locationToEdit?.user || null,
visits: locationToEdit?.visits || [],
is_visited: locationToEdit?.is_visited || false,
collections: locationToEdit?.collections || [],
category: locationToEdit?.category || {
id: '',
name: '',
display_name: '',
icon: '',
user: ''
},
trails: locationToEdit?.trails || [],
attachments: locationToEdit?.attachments || []
};
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
// Skip the quick start step if editing an existing location
if (!locationToEdit) {
steps[0].selected = true;
steps[1].selected = false;
} else {
steps[0].selected = false;
steps[1].selected = true;
}
if (initialLatLng) {
location.latitude = initialLatLng.lat;
location.longitude = initialLatLng.lng;
steps[1].selected = true;
steps[0].selected = false;
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<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 adventurelog 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">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{locationToEdit ? $t('adventures.edit_location') : $t('adventures.new_location')}
</h1>
<p class="text-sm text-base-content/60">
{locationToEdit
? $t('adventures.update_location_details')
: $t('adventures.create_new_location')}
</p>
</div>
</div>
<ul
class="timeline timeline-vertical timeline-compact sm:timeline-horizontal sm:timeline-normal"
>
{#each steps as step, index}
<li>
{#if index > 0}
<hr class="bg-base-300" />
{/if}
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 sm:h-5 sm:w-5 {step.selected
? 'text-primary'
: 'text-base-content/40'}"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5-5z"
clip-rule="evenodd"
/>
</svg>
</div>
<button
class="timeline-end timeline-box text-xs sm:text-sm px-2 py-1 sm:px-3 sm:py-2 {step.selected
? 'bg-primary text-primary-content'
: 'bg-base-200'} {step.requires_id && !location.id
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-primary/80 cursor-pointer'} transition-colors"
on:click={() => {
// Reset all steps
steps.forEach((s) => (s.selected = false));
// Select clicked step
steps[index].selected = true;
}}
disabled={step.requires_id && !location.id}
>
<span class="hidden sm:inline">{step.name}</span>
<span class="sm:hidden"
>{step.name.substring(0, 8)}{step.name.length > 8 ? '...' : ''}</span
>
</button>
{#if index < steps.length - 1}
<hr class="bg-base-300" />
{/if}
</li>
{/each}
</ul>
<!-- Close Button -->
{#if !location.id}
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{:else}
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{#if steps[0].selected}
<!-- Main Content -->
<LocationQuickStart
on:locationSelected={(e) => {
location.name = e.detail.name;
location.location = e.detail.location;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
steps[0].selected = false;
steps[1].selected = true;
}}
on:cancel={() => close()}
on:next={() => {
steps[0].selected = false;
steps[1].selected = true;
}}
/>
{/if}
{#if steps[1].selected}
<LocationDetails
currentUser={user}
initialLocation={location}
{collection}
bind:editingLocation={location}
on:back={() => {
steps[1].selected = false;
steps[0].selected = true;
}}
on:save={(e) => {
location.name = e.detail.name;
location.category = e.detail.category;
location.rating = e.detail.rating;
location.is_public = e.detail.is_public;
location.link = e.detail.link;
location.description = e.detail.description;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
location.location = e.detail.location;
location.tags = e.detail.tags;
location.user = e.detail.user;
location.id = e.detail.id;
steps[1].selected = false;
steps[2].selected = true;
}}
/>
{/if}
{#if steps[2].selected}
<LocationMedia
bind:images={location.images}
bind:attachments={location.attachments}
bind:trails={location.trails}
itemName={location.name}
userIsOwner={user?.uuid === location.user?.uuid}
on:back={() => {
steps[2].selected = false;
steps[1].selected = true;
}}
itemId={location.id}
on:next={() => {
steps[2].selected = false;
steps[3].selected = true;
}}
measurementSystem={user?.measurement_system || 'metric'}
/>
{/if}
{#if steps[3].selected}
<LocationVisits
bind:visits={location.visits}
bind:trails={location.trails}
objectId={location.id}
on:back={() => {
steps[3].selected = false;
steps[2].selected = true;
}}
on:close={() => close()}
measurementSystem={user?.measurement_system || 'metric'}
{collection}
/>
{/if}
</div>
</dialog>

View File

@@ -12,7 +12,7 @@
<img src={Lost} alt="Lost" class="w-1/2" />
</div>
<h1 class="mt-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
{$t('adventures.no_adventures_found')}
{$t('adventures.no_locations_found')}
</h1>
{#if !error}
<p class="mt-4 text-muted-foreground">

View File

@@ -124,7 +124,7 @@
<Launch class="w-5 h-5" />
{$t('notes.open')}
</button>
{#if note.user_id == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if note.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Adventure"

View File

@@ -20,7 +20,7 @@
let constrainDates: boolean = false;
let isReadOnly =
!(note && user?.uuid == note?.user_id) &&
!(note && user?.uuid == note?.user) &&
!(user && collection && collection.shared_with && collection.shared_with.includes(user.uuid)) &&
!!note;
@@ -117,144 +117,321 @@
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{#if note?.id && !isReadOnly}
<p class="font-semibold text-md mb-2">
{$t('notes.editing_note')}
{initialName}
</p>
{:else if !isReadOnly}
{$t('notes.note_editor')}
{:else}
{$t('notes.note_viewer')}
{/if}
</h3>
<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 -->
<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">
{#if isReadOnly}
<svg
class="w-8 h-8 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{:else}
<svg
class="w-8 h-8 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
{/if}
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{#if note?.id && !isReadOnly}
{$t('notes.editing_note')}
{:else if !isReadOnly}
{$t('notes.note_editor')}
{:else}
{$t('notes.note_viewer')}
{/if}
</h1>
<p class="text-sm text-base-content/60">
{#if note?.id && !isReadOnly}
{$t('notes.update_note_details')} "{initialName}"
{:else if !isReadOnly}
{$t('notes.create_new_note')}
{:else}
{$t('notes.viewing_note')} "{note?.name || ''}"
{/if}
</p>
</div>
</div>
<div class="modal-action items-center">
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit|preventDefault>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" id="collapse-plus-1" checked />
<div class="collapse-title text-lg font-bold">
{$t('adventures.basic_information')}
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
</div>
<div class="collapse-content">
<!-- Name Input -->
<div class="form-control mb-2">
<label for="name">{$t('adventures.name')}</label>
<input
type="text"
id="name"
readonly={isReadOnly}
class="input input-bordered w-full max-w-xs"
bind:value={newNote.name}
/>
</div>
<!-- Date Input -->
<div class="form-control mb-2">
<label for="content">{$t('adventures.date')}</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}<label
class="label cursor-pointer flex items-start space-x-2"
>
<span class="label-text">{$t('adventures.date_constrain')}</span>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="checkbox"
class="toggle toggle-primary"
id="constrain_dates"
name="constrain_dates"
on:change={() => (constrainDates = !constrainDates)}
/></label
>
{/if}
<input
type="date"
id="date"
name="date"
readonly={isReadOnly}
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newNote.date}
class="input input-bordered w-full max-w-xs mt-1"
/>
type="text"
id="name"
name="name"
readonly={isReadOnly}
bind:value={newNote.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('notes.enter_note_title')}
required
/>
</div>
<!-- Date Field -->
<div class="form-control">
<label class="label" for="date">
<span class="label-text font-medium">{$t('adventures.date')}</span>
</label>
{#if collection && collection.start_date && collection.end_date && !isReadOnly}
<div class="flex items-center gap-2 mb-2">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
id="constrain_dates"
name="constrain_dates"
bind:checked={constrainDates}
/>
<span class="text-sm text-base-content/70"
>{$t('adventures.date_constrain')}</span
>
</div>
{/if}
<input
type="date"
id="date"
name="date"
readonly={isReadOnly}
min={constrainDates ? collection.start_date : ''}
max={constrainDates ? collection.end_date : ''}
bind:value={newNote.date}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
</div>
<!-- Right Column - Links Section -->
<div class="space-y-4">
{#if !isReadOnly}
<div class="form-control">
<label class="label" for="new-link">
<span class="label-text font-medium">{$t('adventures.links')}</span>
</label>
<div class="join w-full">
<input
type="url"
id="new-link"
class="input input-bordered join-item flex-1 bg-base-100/80 focus:bg-base-100"
placeholder="https://example.com"
bind:value={newLink}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" class="btn btn-primary join-item" on:click={addLink}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
</div>
</div>
{/if}
<!-- Links List -->
{#if newNote.links.length > 0}
<div class="max-h-48 overflow-y-auto space-y-2">
{#each newNote.links as link, i}
<div
class="flex items-center gap-2 p-3 bg-base-200/50 rounded-xl border border-base-300/50"
>
<svg
class="w-4 h-4 text-primary flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
<a
href={link}
class="link link-primary text-sm truncate flex-1"
target="_blank"
rel="noopener noreferrer"
>
{link}
</a>
{#if !isReadOnly}
<button
type="button"
class="btn btn-ghost btn-xs text-error"
on:click={() => {
newNote.links = newNote.links.filter((_, index) => index !== i);
}}
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Content Textarea -->
<div>
<label for="content">{$t('notes.content')}</label><br />
{#if !isReadOnly}
<MarkdownEditor bind:text={newNote.content} editor_height={'h-72'} />
{:else if note}
<p class="text-sm text-muted-foreground" style="white-space: pre-wrap;"></p>
<article
class="prose overflow-auto h-full max-w-full p-4 border border-base-300 rounded-lg mb-4 mt-4"
<!-- Content Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
{$t('notes.content')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
{#if !isReadOnly}
<MarkdownEditor bind:text={newNote.content} editor_height={'h-96'} />
{:else if note}
<div
class="bg-base-100 border border-base-300/50 rounded-xl p-6 max-h-96 overflow-y-auto"
>
<article class="prose max-w-full">
{@html renderMarkdown(note.content || '')}
</article>
{/if}
</div>
<!-- Links Section -->
{#if !isReadOnly}
<div class="form-control mb-2">
<label for="content">{$t('adventures.links')}</label>
<input
type="url"
class="input input-bordered w-full mb-1"
placeholder="{$t('notes.add_a_link')} (e.g. https://example.com)"
bind:value={newLink}
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" class="btn btn-sm btn-primary mt-1" on:click={addLink}>
{$t('adventures.add')}
</button>
</div>
{/if}
<!-- Links List -->
{#if newNote.links.length > 0}
<ul class="list-none">
{#each newNote.links as link, i}
<li class="mb-4 flex justify-between items-center">
<a href={link} class="link link-primary" target="_blank">
{link}
</a>
<button
type="button"
class="btn btn-sm btn-error"
disabled={isReadOnly}
on:click={() => {
newNote.links = newNote.links.filter((_, index) => index !== i);
}}
>
{$t('adventures.remove')}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Warning Message -->
<!-- Warning Messages -->
{#if warning}
<div role="alert" class="alert alert-error mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<div role="alert" class="alert alert-error mb-6 rounded-xl border border-error/20">
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -262,37 +439,51 @@
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{warning}</span>
<span class="font-medium">{warning}</span>
</div>
{/if}
<!-- Public Note Alert -->
{#if collection.is_public}
<div role="alert" class="alert mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<div role="alert" class="alert alert-info mb-6 rounded-xl border border-info/20">
<svg class="h-6 w-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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('notes.note_public')}</span>
<span class="font-medium">{$t('notes.note_public')}</span>
</div>
{/if}
<!-- Action Buttons -->
<div class="mt-4">
<button class="btn btn-primary mr-1" disabled={isReadOnly} on:click={save}>
{$t('notes.save')}
</button><button class="btn btn-neutral" on:click={close}>
<div class="flex gap-3 justify-end pt-4 border-t border-base-300/50">
<button type="button" class="btn btn-neutral-200" on:click={close}>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('about.close')}
</button>
{#if !isReadOnly}
<button type="button" class="btn btn-primary" on:click={save}>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
{/if}
</div>
</form>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
// @ts-nocheck
import type { Adventure, GeocodeSearchResult, Point } from '$lib/types';
import type { Location, GeocodeSearchResult, Point } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
@@ -13,7 +13,7 @@
let markers: Point[] = [];
export let query: string | null = null;
export let adventure: Adventure;
export let adventure: Location;
if (query) {
geocode();
@@ -83,7 +83,7 @@
adventure.name = markers[0].name;
}
if (adventure.type == 'visited' || adventure.type == 'planned') {
adventure.activity_types = [...adventure.activity_types, markers[0].activity_type];
adventure.tags = [...adventure.tags, markers[0].activity_type];
}
dispatch('submit', adventure);
close();

View File

@@ -54,7 +54,12 @@
>
<div class="card-body p-6 space-y-4">
<!-- Header -->
<h2 class="text-xl font-bold truncate">{region.name}</h2>
<a
href="/worldtravel/{region.id.split('-')[0]}/{region.id}"
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline block"
>
{region.name}
</a>
<!-- Metadata Badges -->
<div class="flex flex-wrap gap-2">

View File

@@ -8,14 +8,22 @@
let modal: HTMLDialogElement;
import { t } from 'svelte-i18n';
import Share from '~icons/mdi/share';
import Clear from '~icons/mdi/close';
export let collection: Collection;
let allUsers: User[] = [];
// Extended user interface to include status
interface UserWithStatus extends User {
status?: 'available' | 'pending';
}
let sharedWithUsers: User[] = [];
let notSharedWithUsers: User[] = [];
let allUsers: UserWithStatus[] = [];
let sharedWithUsers: UserWithStatus[] = [];
let notSharedWithUsers: UserWithStatus[] = [];
async function share(user: User) {
// Send invite to user
async function sendInvite(user: User) {
let res = await fetch(`/api/collections/${collection.id}/share/${user.uuid}/`, {
method: 'POST',
headers: {
@@ -23,20 +31,20 @@
}
});
if (res.ok) {
sharedWithUsers = sharedWithUsers.concat(user);
if (collection.shared_with) {
collection.shared_with.push(user.uuid);
} else {
collection.shared_with = [user.uuid];
// Update user status to pending
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
if (userIndex !== -1) {
notSharedWithUsers[userIndex].status = 'pending';
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
}
notSharedWithUsers = notSharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
`${$t('share.shared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
);
addToast('success', `${$t('share.invite_sent')} ${user.first_name} ${user.last_name}`);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.invite_failed'));
}
}
// Unshare collection from user (remove from shared_with)
async function unshare(user: User) {
let res = await fetch(`/api/collections/${collection.id}/unshare/${user.uuid}/`, {
method: 'POST',
@@ -45,15 +53,44 @@
}
});
if (res.ok) {
notSharedWithUsers = notSharedWithUsers.concat(user);
// Move user from shared to not shared
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
notSharedWithUsers = [...notSharedWithUsers, { ...user, status: 'available' }];
// Update collection shared_with array
if (collection.shared_with) {
collection.shared_with = collection.shared_with.filter((u) => u !== user.uuid);
}
sharedWithUsers = sharedWithUsers.filter((u) => u.uuid !== user.uuid);
addToast(
'success',
`${$t('share.unshared')} ${collection.name} ${$t('share.with')} ${user.first_name} ${user.last_name}`
);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.unshare_failed'));
}
}
// Revoke pending invite
async function revokeInvite(user: User) {
let res = await fetch(`/api/collections/${collection.id}/revoke-invite/${user.uuid}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
// Update user status back to available
const userIndex = notSharedWithUsers.findIndex((u) => u.uuid === user.uuid);
if (userIndex !== -1) {
notSharedWithUsers[userIndex].status = 'available';
notSharedWithUsers = [...notSharedWithUsers]; // Trigger reactivity
}
addToast('success', `${$t('share.invite_revoked')} ${user.first_name} ${user.last_name}`);
} else {
const error = await res.json();
addToast('error', error.error || $t('share.revoke_failed'));
}
}
@@ -62,21 +99,38 @@
if (modal) {
modal.showModal();
}
let res = await fetch(`/auth/users`);
// Fetch users that can be shared with (includes status)
let res = await fetch(`/api/collections/${collection.id}/can-share/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
let data = await res.json();
allUsers = data;
sharedWithUsers = allUsers.filter((user) =>
(collection.shared_with ?? []).includes(user.uuid)
);
notSharedWithUsers = allUsers.filter(
(user) => !(collection.shared_with ?? []).includes(user.uuid)
);
console.log(sharedWithUsers);
console.log(notSharedWithUsers);
let users = await res.json();
allUsers = users.map((user: UserWithStatus) => ({
...user,
status: user.status || 'available'
}));
// Separate users based on sharing status
separateUsers();
}
});
function separateUsers() {
if (!collection.shared_with) {
collection.shared_with = [];
}
// Get currently shared users from allUsers that match shared_with UUIDs
sharedWithUsers = allUsers.filter((user) => collection.shared_with?.includes(user.uuid));
// Get not shared users (everyone else from allUsers)
notSharedWithUsers = allUsers.filter((user) => !collection.shared_with?.includes(user.uuid));
}
function close() {
dispatch('close');
}
@@ -86,6 +140,22 @@
dispatch('close');
}
}
// Handle user card actions
function handleUserAction(event: CustomEvent, action: string) {
const user = event.detail;
switch (action) {
case 'share':
sendInvite(user);
break;
case 'unshare':
unshare(user);
break;
case 'revoke':
revokeInvite(user);
break;
}
}
</script>
<dialog id="my_modal_1" class="modal">
@@ -97,12 +167,22 @@
on:keydown={handleKeydown}
>
<!-- Title -->
<div class="space-y-1">
<h3 class="text-2xl font-bold">
{$t('adventures.share')}
{collection.name}
</h3>
<p class="text-base-content/70">{$t('share.share_desc')}</p>
<!-- Header -->
<div class="flex items-center justify-between border-b border-base-300 pb-4 mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<Share class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="text-2xl font-bold text-primary">
{$t('adventures.share')} <span class="text-base-content">{collection.name}</span>
</h3>
<p class="text-sm text-base-content/60">{$t('share.share_desc')}</p>
</div>
</div>
<button class="btn btn-ghost btn-sm btn-square" on:click={close}>
<Clear class="w-5 h-5" />
</button>
</div>
<!-- Shared With Section -->
@@ -110,15 +190,14 @@
<h4 class="text-lg font-semibold mb-2">{$t('share.shared_with')}</h4>
{#if sharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each sharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:unshare={(event) => handleUserAction(event, 'unshare')}
/>
{/each}
</div>
@@ -129,25 +208,25 @@
<div class="divider"></div>
<!-- Not Shared With Section -->
<!-- Available Users Section -->
<div>
<h4 class="text-lg font-semibold mb-2">{$t('share.not_shared_with')}</h4>
<h4 class="text-lg font-semibold mb-2">{$t('share.available_users')}</h4>
{#if notSharedWithUsers.length > 0}
<div
class="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 max-h-80 overflow-y-auto pr-2"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-[22rem] overflow-y-auto pr-2"
>
{#each notSharedWithUsers as user}
<UserCard
{user}
shared_with={collection.shared_with}
sharing={true}
on:share={(event) => share(event.detail)}
on:unshare={(event) => unshare(event.detail)}
on:share={(event) => handleUserAction(event, 'share')}
on:revoke={(event) => handleUserAction(event, 'revoke')}
/>
{/each}
</div>
{:else}
<p class="text-neutral-content italic">{$t('share.no_users_shared')}</p>
<p class="text-neutral-content italic">{$t('share.no_available_users')}</p>
{/if}
</div>

View File

@@ -0,0 +1,255 @@
<script lang="ts">
import { formatDateInTimezone } from '$lib/dateUtils';
import type { StravaActivity } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
const dispatch = createEventDispatcher();
export let activity: StravaActivity;
export let measurementSystem: 'metric' | 'imperial' = 'metric';
interface SportConfig {
color: string;
icon: string;
name: string;
}
const sportTypeConfig: Record<string, SportConfig> = {
StandUpPaddling: { color: 'info', icon: '🏄', name: 'Stand Up Paddling' },
Run: { color: 'success', icon: '🏃', name: 'Running' },
Ride: { color: 'warning', icon: '🚴', name: 'Cycling' },
Swim: { color: 'primary', icon: '🏊', name: 'Swimming' },
Hike: { color: 'accent', icon: '🥾', name: 'Hiking' },
Walk: { color: 'neutral', icon: '🚶', name: 'Walking' },
default: { color: 'secondary', icon: '⚡', name: 'Activity' }
};
function getTypeConfig(type: string): SportConfig {
return sportTypeConfig[type] || sportTypeConfig.default;
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
return `${minutes}m ${secs}s`;
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatPace(seconds: number, system: 'metric' | 'imperial'): string {
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const unit = system === 'metric' ? 'km' : 'mi';
return `${minutes}:${secs.toString().padStart(2, '0')}/${unit}`;
}
function convertElevation(
meters: number,
system: 'metric' | 'imperial'
): { value: number; unit: string } {
if (system === 'imperial') {
return { value: meters * 3.28084, unit: 'ft' };
}
return { value: meters, unit: 'm' };
}
function handleImportActivity() {
dispatch('import', activity);
}
$: typeConfig = getTypeConfig(activity.sport_type);
$: distance =
measurementSystem === 'metric'
? { value: activity.distance_km, unit: 'km' }
: { value: activity.distance_miles, unit: 'mi' };
$: speed =
measurementSystem === 'metric'
? { value: activity.average_speed_kmh, unit: 'km/h' }
: { value: activity.average_speed_mph, unit: 'mph' };
$: maxSpeed =
measurementSystem === 'metric'
? { value: activity.max_speed_kmh, unit: 'km/h' }
: { value: activity.max_speed_mph, unit: 'mph' };
$: elevation = convertElevation(activity.total_elevation_gain, measurementSystem);
$: paceSeconds =
measurementSystem === 'metric' ? activity.pace_per_km_seconds : activity.pace_per_mile_seconds;
</script>
<div class="card bg-base-50 border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
<!-- Activity Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="text-2xl" aria-label="Sport icon">{typeConfig.icon}</div>
<div>
<h3 class="font-semibold text-lg">{activity.name}</h3>
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="badge badge-{typeConfig.color} badge-sm">{typeConfig.name}</span>
<span></span>
<span
>{formatDateInTimezone(
activity.start_date,
activity.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
)} ({activity.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone})</span
>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
on:click={handleImportActivity}
class="btn btn-success btn-sm btn-circle"
aria-label={$t('adventures.import_activity')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
</button>
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-sm btn-circle"
aria-label={$t('adventures.activity_options')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zM12 13a1 1 0 110-2 1 1 0 010 2zM12 20a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</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"
>
<li>
<a href={activity.export_gpx} target="_blank" rel="noopener noreferrer">
{$t('adventures.export_gpx')}
</a>
</li>
<li>
<a href={activity.export_original} target="_blank" rel="noopener noreferrer">
{$t('adventures.export_original')}
</a>
</li>
<li>
<a
href="https://www.strava.com/activities/{activity.id}"
target="_blank"
rel="noopener noreferrer"
>
{$t('adventures.view_on') + ' Strava'}
</a>
</li>
</ul>
</div>
</div>
</div>
<!-- Main Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-xs">{$t('adventures.distance')}</div>
<div class="stat-value text-lg">{distance.value.toFixed(2)}</div>
<div class="stat-desc">{distance.unit}</div>
</div>
<div class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-xs">{$t('adventures.time')}</div>
<div class="stat-value text-lg">{formatTime(activity.moving_time)}</div>
<div class="stat-desc">{$t('adventures.moving_time')}</div>
</div>
<div class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-xs">{$t('adventures.avg_speed')}</div>
<div class="stat-value text-lg">{speed.value.toFixed(1)}</div>
<div class="stat-desc">{speed.unit}</div>
</div>
<div class="stat bg-base-100 rounded-lg p-3">
<div class="stat-title text-xs">{$t('adventures.elevation')}</div>
<div class="stat-value text-lg">{elevation.value.toFixed(0)}</div>
<div class="stat-desc">{elevation.unit} {$t('adventures.gain')}</div>
</div>
</div>
<!-- Additional Stats -->
<div class="flex flex-wrap gap-2 text-sm">
{#if activity.average_cadence}
<div class="badge badge-ghost">
<span class="font-medium">{$t('adventures.cadence')}:</span
>&nbsp;{activity.average_cadence.toFixed(1)}
</div>
{/if}
{#if activity.calories}
<div class="badge badge-ghost">
<span class="font-medium">{$t('adventures.calories')}:</span>&nbsp;{activity.calories}
</div>
{/if}
{#if activity.kudos_count > 0}
<div class="badge badge-ghost">
<span class="font-medium">Kudos:</span>&nbsp;{activity.kudos_count}
</div>
{/if}
{#if activity.achievement_count > 0}
<div class="badge badge-success badge-outline">
<span class="font-medium">{$t('adventures.achievements')}:</span
>&nbsp;{activity.achievement_count}
</div>
{/if}
{#if activity.pr_count > 0}
<div class="badge badge-warning badge-outline">
<span class="font-medium">PRs:</span>&nbsp;{activity.pr_count}
</div>
{/if}
</div>
<!-- Footer with pace and max speed -->
{#if paceSeconds}
<div class="flex justify-between items-center mt-3 pt-3 border-t border-base-300">
<div class="text-sm">
<span class="font-medium">{$t('adventures.pace')}:</span>
{formatPace(paceSeconds, measurementSystem)}
</div>
<div class="text-sm">
<span class="font-medium">{$t('adventures.max_speed')}:</span>
{maxSpeed.value.toFixed(1)}
{maxSpeed.unit}
</div>
</div>
{/if}
</div>
</div>
<style>
.stat {
min-height: auto;
}
.stat-value {
font-size: 1.25rem;
line-height: 1.75rem;
}
</style>

View File

@@ -17,6 +17,18 @@
export let is_enabled: boolean;
let reauthError: boolean = false;
// import Account from '~icons/mdi/account';
import Clear from '~icons/mdi/close';
import Check from '~icons/mdi/check-circle';
import Copy from '~icons/mdi/content-copy';
import Error from '~icons/mdi/alert-circle';
import Key from '~icons/mdi/key';
import QrCode from '~icons/mdi/qrcode';
import Security from '~icons/mdi/security';
import Warning from '~icons/mdi/alert';
import Shield from '~icons/mdi/shield-account';
import Backup from '~icons/mdi/backup-restore';
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
@@ -113,72 +125,214 @@
}
</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" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('settings.enable_mfa')}</h3>
<div
class="modal-box w-11/12 max-w-4xl 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 -->
<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-warning/10 rounded-xl">
<Shield class="w-8 h-8 text-warning" />
</div>
<div>
<h1 class="text-3xl font-bold text-warning bg-clip-text">
{$t('settings.enable_mfa')}
</h1>
<p class="text-sm text-base-content/60">
{$t('settings.secure_your_account')}
</p>
</div>
</div>
{#if qrCodeDataUrl}
<div class="mb-4 flex items-center justify-center mt-2">
<img src={qrCodeDataUrl} alt="QR Code" class="w-64 h-64" />
<!-- Status Badge -->
<div class="hidden md:flex items-center gap-2">
<div class="badge badge-warning badge-lg gap-2">
<Security class="w-4 h-4" />
{is_enabled ? $t('settings.enabled') : $t('settings.setup_required')}
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<Clear class="w-5 h-5" />
</button>
</div>
{/if}
<div class="flex items-center justify-center mb-6">
</div>
<!-- Main Content -->
<div class="px-2">
<!-- QR Code Section -->
{#if qrCodeDataUrl}
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body items-center text-center">
<h3 class="card-title text-xl mb-4 flex items-center gap-2">
<QrCode class="w-6 h-6 text-primary" />
{$t('settings.scan_qr_code')}
</h3>
<div class="p-4 bg-white rounded-xl border border-base-300 mb-4">
<img src={qrCodeDataUrl} alt="QR Code" class="w-64 h-64" />
</div>
<p class="text-base-content/60 max-w-md">
{$t('settings.scan_with_authenticator_app')}
</p>
</div>
</div>
{/if}
<!-- Secret Key Section -->
{#if secret}
<div class="flex items-center">
<input
type="text"
placeholder={secret}
class="input input-bordered w-full max-w-xs"
readonly
/>
<button class="btn btn-primary ml-2" on:click={() => copyToClipboard(secret)}
>{$t('settings.copy')}</button
>
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4 flex items-center gap-2">
<Key class="w-5 h-5 text-secondary" />
{$t('settings.manual_entry')}
</h3>
<div class="flex items-center gap-3">
<div class="flex-1">
<input
type="text"
value={secret}
class="input input-bordered w-full font-mono text-sm bg-base-100/80"
readonly
/>
</div>
<button class="btn btn-secondary gap-2" on:click={() => copyToClipboard(secret)}>
<Copy class="w-4 h-4" />
{$t('settings.copy')}
</button>
</div>
</div>
</div>
{/if}
<!-- Verification Code Section -->
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4 flex items-center gap-2">
<Shield class="w-5 h-5 text-success" />
{$t('settings.verify_setup')}
</h3>
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">
{$t('settings.authenticator_code')}
</span>
</label>
<input
type="text"
placeholder={$t('settings.enter_6_digit_code')}
class="input input-bordered bg-base-100/80 font-mono text-center text-lg tracking-widest"
bind:value={first_code}
maxlength="6"
/>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text-alt text-base-content/60">
{$t('settings.enter_code_from_app')}
</span>
</label>
</div>
</div>
</div>
<!-- Recovery Codes Section -->
{#if recovery_codes.length > 0}
<div class="card bg-base-200/50 border border-base-300/50 mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg flex items-center gap-2">
<Backup class="w-5 h-5 text-info" />
{$t('settings.recovery_codes')}
</h3>
<button
class="btn btn-info btn-sm gap-2"
on:click={() => copyToClipboard(recovery_codes.join(', '))}
>
<Copy class="w-4 h-4" />
{$t('settings.copy_all')}
</button>
</div>
<div class="alert alert-warning mb-4">
<Warning class="w-5 h-5" />
<div>
<h4 class="font-semibold">{$t('settings.important')}</h4>
<p class="text-sm">{$t('settings.recovery_codes_desc')}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{#each recovery_codes as code, index}
<div class="relative group">
<input
type="text"
value={code}
class="input input-bordered input-sm w-full font-mono text-center bg-base-100/80 pr-10"
readonly
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity btn btn-ghost btn-xs"
on:click={() => copyToClipboard(code)}
>
<Copy class="w-3 h-3" />
</button>
<span
class="absolute -top-2 -left-2 bg-base-content text-base-100 rounded-full w-5 h-5 text-xs flex items-center justify-center font-bold"
>
{index + 1}
</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Error Message -->
{#if reauthError}
<div class="alert alert-error mb-6">
<Error class="w-5 h-5" />
<div>
<h4 class="font-semibold">{$t('settings.error_occurred')}</h4>
<p class="text-sm">{$t('settings.reset_session_error')}</p>
</div>
</div>
{/if}
</div>
<input
type="text"
placeholder={$t('settings.authenticator_code')}
class="input input-bordered w-full max-w-xs"
bind:value={first_code}
/>
<div class="recovery-codes-container">
{#if recovery_codes.length > 0}
<h3 class="mt-4 text-center font-bold text-lg">{$t('settings.recovery_codes')}</h3>
<p class="text-center text-lg mb-2">
{$t('settings.recovery_codes_desc')}
</p>
<button
class="btn btn-primary ml-2"
on:click={() => copyToClipboard(recovery_codes.join(', '))}>{$t('settings.copy')}</button
>
{/if}
<div class="recovery-codes-grid flex flex-wrap">
{#each recovery_codes as code}
<div
class="recovery-code-item flex items-center justify-center m-2 w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
>
<input type="text" value={code} class="input input-bordered w-full" readonly />
</div>
{/each}
<!-- Footer Actions -->
<div
class="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">
{is_enabled
? $t('settings.mfa_already_enabled')
: $t('settings.complete_setup_to_enable')}
</div>
<div class="flex items-center gap-3">
{#if !is_enabled && first_code.length >= 6}
<button class="btn btn-success gap-2" on:click={sendTotp}>
<Shield class="w-4 h-4" />
{$t('settings.enable_mfa')}
</button>
{/if}
<button class="btn btn-primary gap-2" on:click={close}>
<Check class="w-4 h-4" />
{$t('about.close')}
</button>
</div>
</div>
</div>
{#if reauthError}
<div class="alert alert-error mt-4">
{$t('settings.reset_session_error')}
</div>
{/if}
{#if !is_enabled}
<button class="btn btn-primary mt-4" on:click={sendTotp}>{$t('settings.enable_mfa')}</button>
{/if}
<button class="btn btn-primary mt-4" on:click={close}>{$t('about.close')}</button>
</div>
</dialog>

View File

@@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
export let activities: string[] | undefined | null;
export let tags: string[] | undefined | null;
let allActivities: string[] = [];
let allTags: string[] = [];
let inputVal: string = '';
if (activities == null || activities == undefined) {
activities = [];
if (tags == null || tags == undefined) {
tags = [];
}
onMount(async () => {
@@ -20,30 +20,29 @@
});
let data = await res.json();
if (data && data.activities) {
allActivities = data.activities;
allTags = data.activities;
}
});
function addActivity() {
if (inputVal && activities) {
if (inputVal && tags) {
const trimmedInput = inputVal.trim().toLocaleLowerCase();
if (trimmedInput && !activities.includes(trimmedInput)) {
activities = [...activities, trimmedInput];
if (trimmedInput && !tags.includes(trimmedInput)) {
tags = [...tags, trimmedInput];
inputVal = '';
}
}
}
function removeActivity(item: string) {
if (activities) {
activities = activities.filter((activity) => activity !== item);
if (tags) {
tags = tags.filter((activity) => activity !== item);
}
}
$: filteredItems = allActivities.filter(function (activity) {
$: filteredItems = allTags.filter(function (activity) {
return (
activity.toLowerCase().includes(inputVal.toLowerCase()) &&
(!activities || !activities.includes(activity))
activity.toLowerCase().includes(inputVal.toLowerCase()) && (!tags || !tags.includes(activity))
);
});
</script>
@@ -88,8 +87,8 @@
<div class="mt-2">
<ul class="space-y-2">
{#if activities}
{#each activities as activity}
{#if tags}
{#each tags as activity}
<li class="flex items-center justify-between bg-base-200 p-2 rounded">
{activity}
<button

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import type { Trail } from '$lib/types';
import { t } from 'svelte-i18n';
// Icons (only those used)
import Calendar from '~icons/mdi/calendar';
import Camera from '~icons/mdi/camera';
import Clock from '~icons/mdi/clock';
import MapPin from '~icons/mdi/map-marker';
import TrendingUp from '~icons/mdi/trending-up';
import Users from '~icons/mdi/account-supervisor';
export let trail: Trail;
export let measurementSystem: 'metric' | 'imperial' = 'metric';
function getDistance(meters: number) {
return measurementSystem === 'imperial'
? `${(meters * 0.000621371).toFixed(2)} mi`
: `${(meters / 1000).toFixed(2)} km`;
}
function getElevation(meters: number) {
return measurementSystem === 'imperial'
? `${(meters * 3.28084).toFixed(1)} ft`
: `${meters.toFixed(1)} m`;
}
function getDuration(minutes: number) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
function formatDate(date: string | number | Date) {
return new Date(date).toLocaleDateString();
}
</script>
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<!-- Trail Name -->
<h2 class="text-xl font-bold leading-tight mb-1">{trail.name}</h2>
<!-- Provider + Created Date -->
<div class="flex items-center gap-2 mb-2">
{#if trail.provider}
<span class="badge badge-outline badge-sm">{trail.provider}</span>
{/if}
<span class="text-sm opacity-70">
{$t('adventures.created')}: {formatDate(trail.created_at)}
</span>
</div>
{#if trail.wanderer_data}
<div class="mb-4 space-y-3">
<!-- Trail Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-2 text-sm">
<MapPin class="w-3 h-3 text-base-content/60" />
<span class="text-base-content/80">
{getDistance(trail.wanderer_data.distance)}
</span>
</div>
{#if trail.wanderer_data.duration > 0}
<div class="flex items-center gap-2 text-sm">
<Clock class="w-3 h-3 text-base-content/60" />
<span class="text-base-content/80">
{getDuration(trail.wanderer_data.duration)}
</span>
</div>
{/if}
{#if trail.wanderer_data.elevation_gain > 0}
<div class="flex items-center gap-2 text-sm">
<TrendingUp class="w-3 h-3 text-base-content/60" />
<span class="text-base-content/80">
{getElevation(trail.wanderer_data.elevation_gain)}
{$t('adventures.gain')}
</span>
</div>
{/if}
<div class="flex items-center gap-2 text-sm">
<Calendar class="w-3 h-3 text-base-content/60" />
<span class="text-base-content/80">
{formatDate(trail.wanderer_data.date)}
</span>
</div>
</div>
<!-- Difficulty + Likes -->
{#if trail.wanderer_data.difficulty}
<div class="flex items-center gap-2">
<span
class="badge badge-sm"
class:badge-success={trail.wanderer_data.difficulty === 'easy'}
class:badge-warning={trail.wanderer_data.difficulty === 'moderate'}
class:badge-error={trail.wanderer_data.difficulty === 'hard'}
>
{trail.wanderer_data.difficulty}
</span>
{#if trail.wanderer_data.like_count > 0}
<div class="flex items-center gap-1 text-xs text-base-content/60">
<Users class="w-3 h-3" />
{$t('adventures.likes')}: {trail.wanderer_data.like_count}
</div>
{/if}
</div>
{/if}
<!-- Description -->
{#if trail.wanderer_data.description}
<div class="text-sm text-base-content/70 leading-relaxed">
{@html trail.wanderer_data.description}
</div>
{/if}
<!-- Location -->
{#if trail.wanderer_data.location}
<div class="text-xs text-base-content/60 flex items-center gap-1">
<MapPin class="w-3 h-3" />
{trail.wanderer_data.location}
</div>
{/if}
<!-- Photos -->
{#if trail.wanderer_data.photos?.length > 0}
<div class="flex items-center gap-1 text-xs text-base-content/60">
<Camera class="w-3 h-3" />
{$t('adventures.photos')}: {trail.wanderer_data.photos.length}
</div>
{/if}
</div>
{/if}
</div>
{#if trail.link || trail.wanderer_link}
<a
href={trail.wanderer_link || trail.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-primary"
>
🔗 {$t('adventures.view_trail')}
</a>
{/if}
</div>
</div>
</div>

View File

@@ -10,6 +10,10 @@
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
@@ -112,6 +116,43 @@
<div
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"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel
images={transportation.images}
icon={getTransportationIcon(transportation.type)}
name={transportation.name}
/>
<!-- Privacy Indicator -->
<div class="absolute top-4 right-4">
<div
class="tooltip tooltip-left"
data-tip={transportation.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 transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
@@ -192,7 +233,7 @@
</div>
<!-- Actions -->
{#if transportation.user_id === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
{#if transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<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"

View File

@@ -13,9 +13,19 @@
import DateRangeCollapse from './DateRangeCollapse.svelte';
import { getBasemapUrl } from '$lib';
import ImageDropdown from './ImageDropdown.svelte';
import AttachmentDropdown from './AttachmentDropdown.svelte';
export let collection: Collection;
export let transportationToEdit: Transportation | null = null;
let imageDropdownRef: any;
let attachmentDropdownRef: any;
// when this is true the image and attachment sections will create their upload requests
let isImagesUploading: boolean = false;
let isAttachmentsUploading: boolean = false;
// Initialize transportation object
let transportation: Transportation = {
id: transportationToEdit?.id || '',
@@ -29,7 +39,7 @@
flight_number: transportationToEdit?.flight_number || '',
from_location: transportationToEdit?.from_location || '',
to_location: transportationToEdit?.to_location || '',
user_id: transportationToEdit?.user_id || '',
user: transportationToEdit?.user || '',
is_public: transportationToEdit?.is_public || false,
collection: transportationToEdit?.collection || collection.id,
created_at: transportationToEdit?.created_at || '',
@@ -40,7 +50,9 @@
destination_longitude: transportationToEdit?.destination_longitude || NaN,
start_timezone: transportationToEdit?.start_timezone || '',
end_timezone: transportationToEdit?.end_timezone || '',
distance: null
distance: null,
images: transportationToEdit?.images || [],
attachments: transportationToEdit?.attachments || []
};
let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
@@ -161,6 +173,10 @@
Math.round(transportation.destination_longitude * 1e6) / 1e6;
}
if (!transportation.type) {
transportation.type = 'other';
}
// Use the stored UTC dates for submission
const submissionData = {
...transportation
@@ -182,11 +198,31 @@
if (data.id) {
transportation = data as Transportation;
addToast('success', $t('adventures.adventure_created'));
addToast('success', $t('adventures.location_created'));
// Handle image uploads after transportation is created
// Now handle image uploads if there are any pending
if (imageDropdownRef?.hasImagesToUpload()) {
console.log('Triggering image upload...');
isImagesUploading = true;
// Wait for image upload to complete
await waitForUploadComplete();
}
// Similarly handle attachments if needed
if (attachmentDropdownRef?.hasAttachmentsToUpload()) {
console.log('Triggering attachment upload...');
isAttachmentsUploading = true;
// Wait for attachment upload to complete
await waitForAttachmentUploadComplete();
}
dispatch('save', transportation);
} else {
console.error(data);
addToast('error', $t('adventures.adventure_create_error'));
addToast('error', $t('adventures.location_create_error'));
}
} else {
let res = await fetch(`/api/transportations/${transportation.id}`, {
@@ -200,156 +236,281 @@
if (data.id) {
transportation = data as Transportation;
addToast('success', $t('adventures.adventure_updated'));
addToast('success', $t('adventures.location_updated'));
dispatch('save', transportation);
} else {
addToast('error', $t('adventures.adventure_update_error'));
addToast('error', $t('adventures.location_update_error'));
}
}
}
// Helper function to wait for image upload completion
async function waitForUploadComplete(): Promise<void> {
return new Promise((resolve) => {
const checkUpload = () => {
if (!isImagesUploading) {
resolve();
} else {
setTimeout(checkUpload, 100);
}
};
checkUpload();
});
}
// Helper function to wait for attachment upload completion
async function waitForAttachmentUploadComplete(): Promise<void> {
return new Promise((resolve) => {
const checkUpload = () => {
if (!isAttachmentsUploading) {
resolve();
} else {
setTimeout(checkUpload, 100);
}
};
checkUpload();
});
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h3>
<div class="modal-action items-center">
<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 adventurelog 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">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h1>
<p class="text-sm text-base-content/60">
{transportationToEdit
? $t('transportation.update_transportation_details')
: $t('transportation.create_new_transportation')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Type selection -->
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={transportation.type}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
<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"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
{#if transportation.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (transportation.rating = NaN)}
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_transportation_name')}
required
/>
</div>
<!-- Type Selection -->
<div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label>
<select
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={transportation.type}
>
{$t('adventures.remove')}
</button>
{/if}
<option disabled selected>{$t('transportation.select_type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input
type="number"
min="0"
max="5"
hidden
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
</div>
{#if transportation.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (transportation.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse
type="transportation"
bind:utcStartDate={transportation.date}
@@ -359,124 +520,193 @@
{collection}
/>
<!-- Flight Information -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<!-- Location/Flight Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if transportation?.type == 'plane'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
{/if}
</svg>
</div>
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
</div>
</div>
<div class="collapse-content">
<div class="collapse-content bg-base-100/50 pt-4 p-6">
{#if transportation?.type == 'plane'}
<!-- Flight Number -->
<div class="mb-4">
<label for="flight_number" class="label">
<span class="label-text">{$t('transportation.flight_number')}</span>
</label>
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportation.flight_number}
class="input input-bordered w-full"
/>
<!-- Flight-specific fields -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Flight Number -->
<div class="form-control">
<label class="label" for="flight_number">
<span class="label-text font-medium">{$t('transportation.flight_number')}</span>
</label>
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportation.flight_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_flight_number')}
/>
</div>
</div>
<!-- Starting Airport -->
<!-- Airport Fields (if locations not set) -->
{#if !transportation.from_location || !transportation.to_location}
<div class="mb-4">
<label for="starting_airport" class="label">
<span class="label-text">{$t('adventures.starting_airport')}</span>
</label>
<input
type="text"
id="starting_airport"
bind:value={starting_airport}
name="starting_airport"
class="input input-bordered w-full"
placeholder={$t('transportation.starting_airport_desc')}
/>
<label for="ending_airport" class="label">
<span class="label-text">{$t('adventures.ending_airport')}</span>
</label>
<input
type="text"
id="ending_airport"
bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full"
placeholder={$t('transportation.ending_airport_desc')}
/>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="form-control">
<label class="label" for="starting_airport">
<span class="label-text font-medium">{$t('adventures.starting_airport')}</span
>
</label>
<input
type="text"
id="starting_airport"
bind:value={starting_airport}
name="starting_airport"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.starting_airport_desc')}
/>
</div>
<div class="form-control">
<label class="label" for="ending_airport">
<span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
</label>
<input
type="text"
id="ending_airport"
bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.ending_airport_desc')}
/>
</div>
</div>
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if}
{/if}
{#if transportation.from_location && transportation.to_location}
<!-- Location Fields (for all types or when flight locations are set) -->
{#if transportation?.type != 'plane' || (transportation.from_location && transportation.to_location)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
<div class="form-control">
<label class="label" for="from_location">
<span class="label-text font-medium">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_from_location')}
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
<div class="form-control">
<label class="label" for="to_location">
<span class="label-text font-medium">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_to_location')}
/>
</div>
</div>
{#if transportation?.type != 'plane'}
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if}
{:else}
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
/>
</div>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
Fetch Location Information
</button>
{/if}
<div class="mt-4">
<!-- Map Section -->
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4 mb-6">
<div class="mb-4">
<h4 class="font-semibold text-base-content flex items-center gap-2">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('adventures.route_map')}
</h4>
</div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
@@ -497,35 +727,71 @@
{/if}
</MapLibre>
</div>
<!-- Clear Location Button -->
{#if transportation.from_location || transportation.to_location}
<button
type="button"
class="btn btn-error btn-sm mt-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
starting_airport = '';
ending_airport = '';
transportation.origin_latitude = NaN;
transportation.origin_longitude = NaN;
transportation.destination_latitude = NaN;
transportation.destination_longitude = NaN;
}}
>
{$t('adventures.clear_location')}
</button>
<div class="flex justify-start">
<button
type="button"
class="btn btn-error btn-sm gap-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
starting_airport = '';
ending_airport = '';
transportation.origin_latitude = NaN;
transportation.origin_longitude = NaN;
transportation.destination_latitude = NaN;
transportation.destination_longitude = NaN;
}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_location')}
</button>
</div>
{/if}
</div>
</div>
<!-- Images Section -->
<ImageDropdown
bind:this={imageDropdownRef}
bind:object={transportation}
objectType="transportation"
bind:isImagesUploading
/>
<!-- Attachments Section -->
<AttachmentDropdown
bind:this={attachmentDropdownRef}
bind:object={transportation}
objectType="transportation"
bind:isAttachmentsUploading
/>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-neutral-200" on:click={close}>
{$t('about.close')}
</button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div>
</form>
</div>

View File

@@ -10,8 +10,23 @@
export let sharing: boolean = false;
export let shared_with: string[] | undefined = undefined;
export let user: User & { status?: 'available' | 'pending' };
export let user: User;
$: isShared = shared_with?.includes(user.uuid) || false;
$: isPending = user.status === 'pending';
$: isAvailable = user.status === 'available';
function handleShare() {
dispatch('share', user);
}
function handleUnshare() {
dispatch('unshare', user);
}
function handleRevoke() {
dispatch('revoke', user);
}
</script>
<div
@@ -44,6 +59,23 @@
{#if user.is_staff}
<div class="badge badge-outline badge-primary mt-2">{$t('settings.admin')}</div>
{/if}
<!-- Status Badge for sharing mode -->
{#if sharing}
{#if isPending}
<div class="badge badge-warning badge-sm mt-2">
{$t('share.pending')}
</div>
{:else if isShared}
<div class="badge badge-success badge-sm mt-2">
{$t('share.shared')}
</div>
{:else if isAvailable}
<div class="badge badge-ghost badge-sm mt-2">
{$t('share.available')}
</div>
{/if}
{/if}
</div>
<!-- Join Date -->
@@ -65,14 +97,18 @@
>
{$t('adventures.view_profile')}
</button>
{:else if shared_with && !shared_with.includes(user.uuid)}
<button class="btn btn-sm btn-success w-full" on:click={() => dispatch('share', user)}>
{$t('adventures.share')}
</button>
{:else}
<button class="btn btn-sm btn-error w-full" on:click={() => dispatch('unshare', user)}>
{:else if isShared}
<button class="btn btn-sm btn-error w-full" on:click={handleUnshare}>
{$t('adventures.remove')}
</button>
{:else if isPending}
<button class="btn btn-sm btn-warning btn-outline w-full" on:click={handleRevoke}>
{$t('share.revoke_invite')}
</button>
{:else if isAvailable}
<button class="btn btn-sm btn-success w-full" on:click={handleShare}>
{$t('share.send_invite')}
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
const dispatch = createEventDispatcher();
import MountainIcon from '~icons/mdi/mountain';
import MapPinIcon from '~icons/mdi/map-marker';
import CalendarIcon from '~icons/mdi/calendar';
import CameraIcon from '~icons/mdi/camera';
import FileIcon from '~icons/mdi/file';
import LinkIcon from '~icons/mdi/link-variant';
import type { WandererTrail } from '$lib/types';
export let trail: WandererTrail;
// Helper functions
/**
* @param {number} distanceInMeters
*/
function formatDistance(distanceInMeters: number) {
const miles = (distanceInMeters * 0.000621371).toFixed(1);
return `${miles} mi`;
}
/**
* @param {number} elevationInMeters
*/
function formatElevation(elevationInMeters: number) {
const feet = Math.round(elevationInMeters * 3.28084);
return `${feet}ft`;
}
/**
* @param {string} difficulty
*/
function getDifficultyBadgeClass(difficulty: string) {
switch (difficulty?.toLowerCase()) {
case 'easy':
return 'badge-success';
case 'moderate':
return 'badge-warning';
case 'difficult':
return 'badge-error';
default:
return 'badge-outline';
}
}
/**
* @param {string | number | Date} dateString
*/
function formatDate(dateString: string | number | Date) {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString();
}
/**
* @param {string} html
*/
function stripHtml(html: string) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
</script>
<div class="bg-base-200/50 p-4 rounded-lg shadow-sm">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<!-- Header with trail name and difficulty -->
<div class="flex items-center gap-2 mb-2">
<MountainIcon class="w-4 h-4 text-primary flex-shrink-0" />
<h5 class="font-semibold text-base truncate">{trail.name}</h5>
<span class="badge {getDifficultyBadgeClass(trail.difficulty)} badge-sm">
{trail.difficulty}
</span>
</div>
<!-- Location -->
{#if trail.location}
<div class="flex items-center gap-1 mb-2">
<MapPinIcon class="w-3 h-3 text-base-content/60" />
<span class="text-sm text-base-content/70">{trail.location}</span>
</div>
{/if}
<!-- Trail stats -->
<div class="text-xs text-base-content/70 space-y-1">
{#if trail.distance}
<div class="flex items-center gap-4">
<span>{$t('adventures.distance')}: {formatDistance(trail.distance)}</span>
{#if trail.duration > 0}
<span>{$t('adventures.duration')}: {Math.round(trail.duration / 60)} min</span>
{/if}
</div>
{/if}
{#if trail.elevation_gain > 0 || trail.elevation_loss > 0}
<div class="flex items-center gap-4">
{#if trail.elevation_gain > 0}
<span class="text-success">{formatElevation(trail.elevation_gain)}</span>
{/if}
{#if trail.elevation_loss > 0}
<span class="text-error">{formatElevation(trail.elevation_loss)}</span>
{/if}
</div>
{/if}
{#if trail.waypoints && trail.waypoints.length > 0}
<div>
Waypoints: {trail.waypoints.length}
</div>
{/if}
{#if trail.created}
<div class="flex items-center gap-1">
<CalendarIcon class="w-3 h-3" />
<span>{$t('adventures.created')}: {formatDate(trail.created)}</span>
</div>
{/if}
{#if trail.photos && trail.photos.length > 0}
<div class="flex items-center gap-1">
<CameraIcon class="w-3 h-3" />
<span>{$t('adventures.photos')}: {trail.photos.length}</span>
</div>
{/if}
{#if trail.gpx}
<div class="flex items-center gap-1">
<FileIcon class="w-3 h-3" />
<a href={trail.gpx} target="_blank" class="link link-primary text-xs">
{$t('adventures.view_gpx')}
</a>
</div>
{/if}
</div>
<!-- Description preview -->
{#if trail.description}
<div class="mt-3 pt-2 border-t border-base-300">
<p class="text-xs text-base-content/60 line-clamp-2">
{stripHtml(trail.description).substring(0, 150)}...
</p>
</div>
{/if}
</div>
<!-- button to link trail to activity -->
<div class="flex-shrink-0 ml-4">
<button
class="btn btn-primary btn-xs tooltip tooltip-top"
on:click={() => dispatch('link', trail)}
aria-label="Link Trail to Activity"
>
<LinkIcon class="w-3 h-3" />
</button>
</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

@@ -0,0 +1,831 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { number, t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
import CategoryDropdown from '../CategoryDropdown.svelte';
import type { Collection, Location } from '$lib/types';
// Icons
import SearchIcon from '~icons/mdi/magnify';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import MapIcon from '~icons/mdi/map';
import CheckIcon from '~icons/mdi/check';
import ClearIcon from '~icons/mdi/close';
import PinIcon from '~icons/mdi/map-marker';
import InfoIcon from '~icons/mdi/information';
import StarIcon from '~icons/mdi/star';
import LinkIcon from '~icons/mdi/link';
import TextIcon from '~icons/mdi/text';
import CategoryIcon from '~icons/mdi/tag';
import PublicIcon from '~icons/mdi/earth';
import GenerateIcon from '~icons/mdi/lightning-bolt';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
import SaveIcon from '~icons/mdi/content-save';
import type { Category, User } from '$lib/types';
import MarkdownEditor from '../MarkdownEditor.svelte';
import TagComplete from '../TagComplete.svelte';
const dispatch = createEventDispatcher();
// Location selection properties
let searchQuery = '';
let searchResults: any[] = [];
let selectedLocation: any = null;
let mapCenter: [number, number] = [-74.5, 40];
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
// Enhanced location data
let locationData: {
city?: { name: string; id: string; visited: boolean };
region?: { name: string; id: string; visited: boolean };
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
} | null = null;
// Form data properties
let location: {
name: string;
category: Category | null;
rating: number;
is_public: boolean;
link: string;
description: string;
latitude: number | null;
longitude: number | null;
location: string;
tags: string[];
collections?: string[];
} = {
name: '',
category: null,
rating: NaN,
is_public: false,
link: '',
description: '',
latitude: null,
longitude: null,
location: '',
tags: [],
collections: []
};
let user: User | null = null;
let locationToEdit: Location | null = null;
let wikiError = '';
let isGeneratingDesc = false;
let ownerUser: User | null = null;
// Props (would be passed in from parent component)
export let initialLocation: any = null;
export let currentUser: any = null;
export let editingLocation: any = null;
export let collection: Collection | null = null;
$: user = currentUser;
$: locationToEdit = editingLocation;
// Location selection functions
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = results.map((result: any) => ({
id: result.name + result.lat + result.lon,
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
type: result.type,
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
async function selectSearchResult(searchResult: any) {
selectedLocation = searchResult;
selectedMarker = { lng: searchResult.lng, lat: searchResult.lat };
mapCenter = [searchResult.lng, searchResult.lat];
mapZoom = 14;
searchResults = [];
searchQuery = searchResult.name;
// Update form data
if (!location.name) location.name = searchResult.name;
location.latitude = searchResult.lat;
location.longitude = searchResult.lng;
location.name = searchResult.name;
await performDetailedReverseGeocode(searchResult.lat, searchResult.lng);
}
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (results && results.length > 0) {
const result = results[0];
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
};
searchQuery = result.name;
if (!location.name) location.name = result.name;
} else {
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
if (!location.name) location.name = selectedLocation.name;
}
location.latitude = lat;
location.longitude = lng;
location.location = selectedLocation.location;
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
if (!location.name) location.name = selectedLocation.name;
location.latitude = lat;
location.longitude = lng;
location.location = selectedLocation.location;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
location.location = data.display_name;
} else {
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
function clearLocationSelection() {
selectedLocation = null;
selectedMarker = null;
locationData = null;
searchQuery = '';
searchResults = [];
location.latitude = null;
location.longitude = null;
location.location = '';
mapCenter = [-74.5, 40];
mapZoom = 2;
}
async function generateDesc() {
if (!location.name) return;
isGeneratingDesc = true;
wikiError = '';
try {
// Mock Wikipedia API call - replace with actual implementation
const response = await fetch(`/api/generate/desc/?name=${encodeURIComponent(location.name)}`);
if (response.ok) {
const data = await response.json();
location.description = data.extract || '';
} else {
wikiError = `${$t('adventures.wikipedia_error') || 'Error fetching description from Wikipedia'}`;
}
} catch (error) {
wikiError = `${$t('adventures.wikipedia_error') || ''}`;
} finally {
isGeneratingDesc = false;
}
}
async function handleSave() {
if (!location.name || !location.category) {
return;
}
// round latitude and longitude to 6 decimal places
if (location.latitude !== null && typeof location.latitude === 'number') {
location.latitude = parseFloat(location.latitude.toFixed(6));
}
if (location.longitude !== null && typeof location.longitude === 'number') {
location.longitude = parseFloat(location.longitude.toFixed(6));
}
if (collection && collection.id) {
location.collections = [collection.id];
}
// either a post or a patch depending on whether we're editing or creating
if (locationToEdit && locationToEdit.id) {
let res = await fetch(`/api/locations/${locationToEdit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(location)
});
let updatedLocation = await res.json();
location = updatedLocation;
} else {
let res = await fetch(`/api/locations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(location)
});
let newLocation = await res.json();
location = newLocation;
}
dispatch('save', {
...location
});
}
function handleBack() {
dispatch('back');
}
onMount(async () => {
if (initialLocation.latitude && initialLocation.longitude) {
selectedMarker = {
lng: initialLocation.longitude,
lat: initialLocation.latitude
};
location.latitude = initialLocation.latitude;
location.longitude = initialLocation.longitude;
mapCenter = [initialLocation.longitude, initialLocation.latitude];
mapZoom = 14;
selectedLocation = {
name: initialLocation.name || '',
lat: initialLocation.latitude,
lng: initialLocation.longitude,
location: initialLocation.location || '',
type: 'point',
category: initialLocation.category || null
};
selectedMarker = {
lng: Number(initialLocation.longitude),
lat: Number(initialLocation.latitude)
};
// trigger reverse geocoding to populate location data
await performDetailedReverseGeocode(initialLocation.latitude, initialLocation.longitude);
}
});
onMount(() => {
if (initialLocation && typeof initialLocation === 'object') {
// Only update location properties if they don't already have values
// This prevents overwriting user selections
if (!location.name) location.name = initialLocation.name || '';
if (!location.link) location.link = initialLocation.link || '';
if (!location.description) location.description = initialLocation.description || '';
if (Number.isNaN(location.rating)) location.rating = initialLocation.rating || NaN;
if (location.is_public === false) location.is_public = initialLocation.is_public || false;
// Only set category if location doesn't have one or if initialLocation has a valid category
if (!location.category || !location.category.id) {
if (initialLocation.category && initialLocation.category.id) {
location.category = initialLocation.category;
}
}
if (initialLocation.tags && Array.isArray(initialLocation.tags)) {
location.tags = initialLocation.tags;
}
if (initialLocation.location) {
location.location = initialLocation.location;
}
if (initialLocation.user) {
ownerUser = initialLocation.user;
}
}
searchQuery = initialLocation.name || '';
return () => {
clearTimeout(searchTimeout);
};
});
</script>
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
<div class="max-w-full mx-auto space-y-6">
<!-- Basic Information Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-primary/10 rounded-lg">
<InfoIcon class="w-5 h-5 text-primary" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.basic_information')}</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium">
{$t('adventures.name')} <span class="text-error">*</span>
</span>
</label>
<input
type="text"
id="name"
bind:value={location.name}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="Enter location name"
required
/>
</div>
<!-- Category Field -->
<div class="form-control">
<label class="label" for="category">
<span class="label-text font-medium">
{$t('adventures.category')} <span class="text-error">*</span>
</span>
</label>
{#if (user && ownerUser && user.uuid == ownerUser.uuid) || !ownerUser}
<CategoryDropdown bind:selected_category={location.category} />
{:else}
<div
class="flex items-center gap-3 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
{#if location.category?.icon}
<span class="text-xl flex-shrink-0">{location.category.icon}</span>
{/if}
<span class="font-medium">
{location.category?.display_name || location.category?.name}
</span>
</div>
{/if}
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<div
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
<div class="rating">
<input
type="radio"
name="rating"
id="rating"
class="rating-hidden"
checked={Number.isNaN(location.rating)}
/>
{#each [1, 2, 3, 4, 5] as star}
<input
type="radio"
name="rating"
class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = star)}
checked={location.rating === star}
/>
{/each}
</div>
{#if !Number.isNaN(location.rating)}
<button
type="button"
class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (location.rating = NaN)}
>
<ClearIcon class="w-4 h-4" />
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
bind:value={location.link}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="https://example.com"
/>
</div>
<!-- Public Toggle -->
{#if !locationToEdit || (locationToEdit.collections && locationToEdit.collections.length === 0)}
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4" for="is_public">
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
bind:checked={location.is_public}
/>
<div>
<span class="label-text font-medium">{$t('adventures.public_location')}</span>
<p class="text-sm text-base-content/60">
{$t('adventures.public_location_description')}
</p>
</div>
</label>
</div>
{/if}
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<MarkdownEditor bind:text={location.description} editor_height="h-32" />
<div class="flex items-center gap-4 mt-3">
<button
type="button"
class="btn btn-neutral btn-sm gap-2"
on:click={generateDesc}
disabled={!location.name || isGeneratingDesc}
>
{#if isGeneratingDesc}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<GenerateIcon class="w-4 h-4" />
{/if}
{$t('adventures.generate_desc')}
</button>
{#if wikiError}
<div class="alert alert-error alert-sm">
<InfoIcon class="w-4 h-4" />
<span class="text-sm">{wikiError}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-warning/10 rounded-lg">
<CategoryIcon class="w-5 h-5 text-warning" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.tags')} ({location.tags?.length || 0})</h2>
</div>
<div class="space-y-4">
<!-- Hidden input for form submission (same as old version) -->
<input
type="text"
id="tags"
name="tags"
hidden
bind:value={location.tags}
class="input input-bordered w-full"
/>
<!-- Use the same ActivityComplete component as the old version -->
<TagComplete bind:tags={location.tags} />
</div>
</div>
</div>
<!-- Location Selection Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-secondary/10 rounded-lg">
<MapIcon class="w-5 h-5 text-secondary" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.location_map')}</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Search & Controls -->
<div class="space-y-4">
<!-- Location Display Name Input -->
<div class="form-control">
<label class="label" for="location-display">
<span class="label-text font-medium">{$t('adventures.location_display_name')}</span>
</label>
<input
type="text"
id="location-display"
bind:value={location.location}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="Enter location display name"
/>
</div>
<!-- Search Input -->
<div class="form-control">
<label class="label" for="search-location">
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon class="w-4 h-4 text-base-content/40" />
</div>
<input
type="text"
id="search-location"
bind:value={searchQuery}
on:input={handleSearchInput}
placeholder="Enter city, location, or landmark..."
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
class:input-primary={selectedLocation}
/>
{#if searchQuery && !selectedLocation}
<button
class="absolute inset-y-0 right-0 pr-3 flex items-center"
on:click={clearLocationSelection}
>
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
</button>
{/if}
</div>
</div>
<!-- Search Results -->
{#if isSearching}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60"
>{$t('adventures.searching')}...</span
>
</div>
{:else if searchResults.length > 0}
<div class="space-y-2">
<div class="label">
<span class="label-text text-sm font-medium"
>{$t('adventures.search_results')}</span
>
</div>
<div class="max-h-48 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
on:click={() => selectSearchResult(result)}
>
<div class="flex items-start gap-3">
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{result.name}</div>
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
{#if result.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Current Location Button -->
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">{$t('adventures.or')}</div>
</div>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
{$t('adventures.use_current_location')}
</button>
<!-- Selected Location Display -->
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-4 h-4 text-success" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-success mb-1">
{$t('adventures.location_selected')}
</h4>
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
<!-- Geographic Tags -->
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">
{#if locationData.city}
<div class="badge badge-info badge-sm gap-1">
🏙️ {locationData.city.name}
</div>
{/if}
{#if locationData.region}
<div class="badge badge-warning badge-sm gap-1">
🗺️ {locationData.region.name}
</div>
{/if}
{#if locationData.country}
<div class="badge badge-success badge-sm gap-1">
🌎 {locationData.country.name}
</div>
{/if}
</div>
{/if}
</div>
<button class="btn btn-ghost btn-sm" on:click={clearLocationSelection}>
<ClearIcon class="w-4 h-4" />
</button>
</div>
</div>
</div>
{/if}
</div>
<!-- Map -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="label">
<span class="label-text font-medium">{$t('worldtravel.interactive_map')}</span>
</div>
{#if isReverseGeocoding}
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-sm text-base-content/60"
>{$t('worldtravel.getting_location_details')}...</span
>
</div>
{/if}
</div>
<div class="relative">
<MapLibre
bind:this={mapComponent}
style={getBasemapUrl()}
class="w-full h-80 rounded-lg border border-base-300"
center={mapCenter}
zoom={mapZoom}
standardControls
>
<MapEvents on:click={handleMapClick} />
{#if selectedMarker}
<Marker
lngLat={[selectedMarker.lng, selectedMarker.lat]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
>
<PinIcon class="w-5 h-5 text-primary-content" />
</Marker>
{/if}
</MapLibre>
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 text-center">
{$t('adventures.click_on_map')}
</p>
{/if}
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<button class="btn btn-neutral-200 gap-2" on:click={handleBack}>
<ArrowLeftIcon class="w-5 h-5" />
{$t('adventures.back')}
</button>
<button
class="btn btn-primary gap-2"
disabled={!location.name || !location.category || isReverseGeocoding}
on:click={handleSave}
>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-sm"></span>
{$t('adventures.processing')}...
{:else}
<SaveIcon class="w-5 h-5" />
{$t('adventures.continue')}
{/if}
</button>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
// Icons
import SearchIcon from '~icons/mdi/magnify';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import MapIcon from '~icons/mdi/map';
import CheckIcon from '~icons/mdi/check';
import ClearIcon from '~icons/mdi/close';
import PinIcon from '~icons/mdi/map-marker';
const dispatch = createEventDispatcher();
let searchQuery = '';
let searchResults: any[] = [];
let selectedLocation: any = null;
let mapCenter: [number, number] = [-74.5, 40]; // Default center
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
// Enhanced location data from reverse geocoding
let locationData: {
city?: { name: string; id: string; visited: boolean };
region?: { name: string; id: string; visited: boolean };
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
} | null = null;
// Search for locations using your custom API
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = results.map((result: any) => ({
id: result.name + result.lat + result.lon, // Create a unique ID
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
type: result.type,
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
// Debounced search
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
// Select a location from search results
async function selectSearchResult(location: any) {
selectedLocation = location;
selectedMarker = { lng: location.lng, lat: location.lat };
mapCenter = [location.lng, location.lat];
mapZoom = 14;
searchResults = [];
searchQuery = location.name;
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(location.lat, location.lng);
}
// Handle map click to place marker
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
// Reverse geocode to get location name and detailed data
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
// Reverse geocode coordinates to get location name using your API
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
// Using a coordinate-based search query for reverse geocoding
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (results && results.length > 0) {
const result = results[0];
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
};
searchQuery = result.name;
} else {
// Fallback if no results from API
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
}
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
// Perform detailed reverse geocoding to get city, region, country data
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false // You might want to check this from your backend
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
selectedLocation.location = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
} else {
console.warn('Detailed reverse geocoding failed:', response.status);
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
// Use current location
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
// Continue with selected location
function continueWithLocation() {
if (selectedLocation && selectedMarker) {
dispatch('locationSelected', {
name: selectedLocation.name,
latitude: selectedMarker.lat,
longitude: selectedMarker.lng,
location: selectedLocation.location,
type: selectedLocation.type,
category: selectedLocation.category,
// Include the enhanced geographical data
city: locationData?.city,
region: locationData?.region,
country: locationData?.country,
display_name: locationData?.display_name,
location_name: locationData?.location_name
});
} else {
dispatch('next');
}
}
// Clear selection
function clearSelection() {
selectedLocation = null;
selectedMarker = null;
locationData = null;
searchQuery = '';
searchResults = [];
mapCenter = [-74.5, 40];
mapZoom = 2;
}
onMount(() => {
return () => {
clearTimeout(searchTimeout);
};
});
</script>
<div class="space-y-6">
<!-- Search Section -->
<div class="card bg-base-200/50 border border-base-300">
<div class="card-body p-6">
<div class="space-y-4">
<!-- Search Input -->
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">
{$t('adventures.search_location') || 'Search for a location'}
</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon class="w-5 h-5 text-base-content/40" />
</div>
<input
type="text"
bind:value={searchQuery}
on:input={handleSearchInput}
placeholder={$t('adventures.search_placeholder') ||
'Enter city, location, or landmark...'}
class="input input-bordered w-full pl-10 pr-4"
class:input-primary={selectedLocation}
/>
{#if searchQuery && !selectedLocation}
<button
class="absolute inset-y-0 right-0 pr-3 flex items-center"
on:click={clearSelection}
>
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
</button>
{/if}
</div>
</div>
<!-- Search Results -->
{#if isSearching}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60">{$t('adventures.searching')}...</span>
</div>
{:else if searchResults.length > 0}
<div class="space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text text-sm font-medium">{$t('adventures.search_results')}</span>
</label>
<div class="max-h-48 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
on:click={() => selectSearchResult(result)}
>
<div class="flex items-start gap-3">
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{result.name}</div>
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
{#if result.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Current Location Button -->
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">OR</div>
</div>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
{$t('adventures.use_current_location') || 'Use Current Location'}
</button>
</div>
</div>
</div>
<!-- Map Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold flex items-center gap-2">
<MapIcon class="w-5 h-5" />
{$t('adventures.select_on_map') || 'Select on Map'}
</h3>
{#if selectedMarker}
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
<ClearIcon class="w-4 h-4" />
Clear
</button>
{/if}
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 mb-4">
{$t('adventures.click_map') || 'Click on the map to select a location'}
</p>
{/if}
{#if isReverseGeocoding}
<div class="flex items-center justify-center py-2 mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60"
>{$t('adventures.getting_location_details')}...</span
>
</div>
{/if}
<div class="relative">
<MapLibre
bind:this={mapComponent}
style={getBasemapUrl()}
class="w-full h-80 rounded-lg border border-base-300"
center={mapCenter}
zoom={mapZoom}
standardControls
>
<MapEvents on:click={handleMapClick} />
{#if selectedMarker}
<Marker
lngLat={[selectedMarker.lng, selectedMarker.lat]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
>
<PinIcon class="w-5 h-5 text-primary-content" />
</Marker>
{/if}
</MapLibre>
</div>
</div>
</div>
<!-- Selected Location Display -->
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-5 h-5 text-success" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-success mb-1">{$t('adventures.location_selected')}</h4>
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
{#if selectedLocation.category}
<p class="text-xs text-base-content/50 capitalize">
{selectedLocation.category}{selectedLocation.type || 'location'}
</p>
{/if}
<!-- Geographic Tags -->
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">
{#if locationData.city}
<div class="badge badge-info badge-sm gap-1">
🏙️ {locationData.city.name}
</div>
{/if}
{#if locationData.region}
<div class="badge badge-warning badge-sm gap-1">
🗺️ {locationData.region.name}
</div>
{/if}
{#if locationData.country}
<div class="badge badge-success badge-sm gap-1">
🌎 {locationData.country.name}
</div>
{/if}
</div>
{/if}
{#if locationData?.display_name}
<p class="text-xs text-base-content/50 mt-2">
{locationData.display_name}
</p>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex gap-3 pt-4">
<button class="btn btn-neutral-200 flex-1" on:click={() => dispatch('cancel')}>
{$t('adventures.cancel') || 'Cancel'}
</button>
<button class="btn btn-primary flex-1" on:click={continueWithLocation}>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-xs"></span>
{$t('adventures.getting_location_details') || 'Getting details...'}
{:else}
{$t('adventures.continue')}
{/if}
</button>
</div>
</div>

File diff suppressed because it is too large Load Diff