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:
@@ -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>
|
||||
|
||||
251
frontend/src/lib/components/ActivityCard.svelte
Normal file
251
frontend/src/lib/components/ActivityCard.svelte
Normal 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>
|
||||
@@ -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>
|
||||
415
frontend/src/lib/components/AttachmentDropdown.svelte
Normal file
415
frontend/src/lib/components/AttachmentDropdown.svelte
Normal 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>
|
||||
@@ -31,9 +31,9 @@
|
||||
section: 'main'
|
||||
},
|
||||
{
|
||||
path: '/adventures',
|
||||
path: '/locations',
|
||||
icon: MapMarker,
|
||||
label: 'navbar.my_adventures',
|
||||
label: 'locations.my_locations',
|
||||
section: 'main'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
284
frontend/src/lib/components/ImageDropdown.svelte
Normal file
284
frontend/src/lib/components/ImageDropdown.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
frontend/src/lib/components/MapStyleSelector.svelte
Normal file
42
frontend/src/lib/components/MapStyleSelector.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
327
frontend/src/lib/components/NewLocationModal.svelte
Normal file
327
frontend/src/lib/components/NewLocationModal.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
255
frontend/src/lib/components/StravaActivityCard.svelte
Normal file
255
frontend/src/lib/components/StravaActivityCard.svelte
Normal 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
|
||||
> {activity.average_cadence.toFixed(1)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.calories}
|
||||
<div class="badge badge-ghost">
|
||||
<span class="font-medium">{$t('adventures.calories')}:</span> {activity.calories}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.kudos_count > 0}
|
||||
<div class="badge badge-ghost">
|
||||
<span class="font-medium">Kudos:</span> {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
|
||||
> {activity.achievement_count}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.pr_count > 0}
|
||||
<div class="badge badge-warning badge-outline">
|
||||
<span class="font-medium">PRs:</span> {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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
153
frontend/src/lib/components/TrailCard.svelte
Normal file
153
frontend/src/lib/components/TrailCard.svelte
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
168
frontend/src/lib/components/WandererCard.svelte
Normal file
168
frontend/src/lib/components/WandererCard.svelte
Normal 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>
|
||||
831
frontend/src/lib/components/locations/LocationDetails.svelte
Normal file
831
frontend/src/lib/components/locations/LocationDetails.svelte
Normal 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>
|
||||
1394
frontend/src/lib/components/locations/LocationMedia.svelte
Normal file
1394
frontend/src/lib/components/locations/LocationMedia.svelte
Normal file
File diff suppressed because it is too large
Load Diff
460
frontend/src/lib/components/locations/LocationQuickStart.svelte
Normal file
460
frontend/src/lib/components/locations/LocationQuickStart.svelte
Normal 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>
|
||||
1550
frontend/src/lib/components/locations/LocationVisits.svelte
Normal file
1550
frontend/src/lib/components/locations/LocationVisits.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user