* 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>
1551 lines
50 KiB
Svelte
1551 lines
50 KiB
Svelte
<script lang="ts">
|
||
import type {
|
||
Collection,
|
||
StravaActivity,
|
||
Trail,
|
||
Activity,
|
||
Visit,
|
||
TransportationVisit
|
||
} from '$lib/types';
|
||
import TimezoneSelector from '../TimezoneSelector.svelte';
|
||
import { t } from 'svelte-i18n';
|
||
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
|
||
import { onMount } from 'svelte';
|
||
import { isAllDay, SPORT_TYPE_CHOICES } from '$lib';
|
||
import { createEventDispatcher } from 'svelte';
|
||
import { deserialize } from '$app/forms';
|
||
|
||
// Icons
|
||
import CalendarIcon from '~icons/mdi/calendar';
|
||
import ClockIcon from '~icons/mdi/clock';
|
||
import MapMarkerIcon from '~icons/mdi/map-marker';
|
||
import PlusIcon from '~icons/mdi/plus';
|
||
import EditIcon from '~icons/mdi/pencil';
|
||
import TrashIcon from '~icons/mdi/delete';
|
||
import AlertIcon from '~icons/mdi/alert';
|
||
import CheckIcon from '~icons/mdi/check';
|
||
import SettingsIcon from '~icons/mdi/cog';
|
||
import ArrowLeftIcon from '~icons/mdi/arrow-left';
|
||
import RunFastIcon from '~icons/mdi/run-fast';
|
||
import LoadingIcon from '~icons/mdi/loading';
|
||
import UploadIcon from '~icons/mdi/upload';
|
||
import FileIcon from '~icons/mdi/file';
|
||
import CloseIcon from '~icons/mdi/close';
|
||
import StravaActivityCard from '../StravaActivityCard.svelte';
|
||
import ActivityCard from '../ActivityCard.svelte';
|
||
|
||
// Props
|
||
export let collection: Collection | null = null;
|
||
export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
export let selectedEndTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
export let utcStartDate: string | null = null;
|
||
export let utcEndDate: string | null = null;
|
||
export let note: string | null = null;
|
||
export let visits: Visit[] | null = null;
|
||
export let objectId: string;
|
||
export let trails: Trail[] = [];
|
||
export let measurementSystem: 'metric' | 'imperial' = 'metric';
|
||
|
||
const dispatch = createEventDispatcher();
|
||
|
||
// Types
|
||
|
||
// Component state
|
||
let allDay: boolean = false;
|
||
let localStartDate: string = '';
|
||
let localEndDate: string = '';
|
||
let fullStartDate: string = '';
|
||
let fullEndDate: string = '';
|
||
let constrainDates: boolean = false;
|
||
let isEditing = false;
|
||
let visitIdEditing: string | null = null;
|
||
|
||
// Activity management state
|
||
let stravaEnabled: boolean = false;
|
||
let visitActivities: { [visitId: string]: StravaActivity[] } = {};
|
||
let loadingActivities: { [visitId: string]: boolean } = {};
|
||
let expandedVisits: { [visitId: string]: boolean } = {};
|
||
let uploadingActivity: { [visitId: string]: boolean } = {};
|
||
let showActivityUpload: { [visitId: string]: boolean } = {};
|
||
let pendingStravaImport: { [visitId: string]: StravaActivity | null } = {};
|
||
|
||
// Activity form state
|
||
let activityForm = {
|
||
name: '',
|
||
sport_type: 'Run',
|
||
distance: null as number | null,
|
||
moving_time: '',
|
||
elapsed_time: '',
|
||
elevation_gain: null as number | null,
|
||
elevation_loss: null as number | null,
|
||
start_date: '',
|
||
calories: null as number | null,
|
||
gpx_file: null as File | null,
|
||
trail: null as string | null,
|
||
elev_high: null as number | null,
|
||
elev_low: null as number | null,
|
||
rest_time: null as number | null,
|
||
average_speed: null as number | null,
|
||
max_speed: null as number | null,
|
||
average_cadence: null as number | null,
|
||
start_lat: null as number | null,
|
||
start_lng: null as number | null,
|
||
end_lat: null as number | null,
|
||
end_lng: null as number | null,
|
||
timezone: undefined as string | undefined
|
||
};
|
||
|
||
function getTypeConfig() {
|
||
return {
|
||
startLabel: 'Start Date',
|
||
endLabel: 'End Date',
|
||
icon: CalendarIcon,
|
||
color: 'primary'
|
||
};
|
||
}
|
||
|
||
// Reactive constraints
|
||
$: constraintStartDate = allDay
|
||
? fullStartDate && fullStartDate.includes('T')
|
||
? fullStartDate.split('T')[0]
|
||
: ''
|
||
: fullStartDate || '';
|
||
$: constraintEndDate = allDay
|
||
? fullEndDate && fullEndDate.includes('T')
|
||
? fullEndDate.split('T')[0]
|
||
: ''
|
||
: fullEndDate || '';
|
||
|
||
// Set the full date range for constraining purposes
|
||
$: if (collection && collection.start_date && collection.end_date) {
|
||
fullStartDate = `${collection.start_date}T00:00`;
|
||
fullEndDate = `${collection.end_date}T23:59`;
|
||
}
|
||
|
||
// Update local display dates whenever timezone or UTC dates change
|
||
$: if (!isEditing) {
|
||
if (allDay) {
|
||
localStartDate = utcStartDate?.substring(0, 10) ?? '';
|
||
localEndDate = utcEndDate?.substring(0, 10) ?? '';
|
||
} else {
|
||
const start = updateLocalDate({
|
||
utcDate: utcStartDate,
|
||
timezone: selectedStartTimezone
|
||
}).localDate;
|
||
|
||
const end = updateLocalDate({
|
||
utcDate: utcEndDate,
|
||
timezone: selectedEndTimezone
|
||
}).localDate;
|
||
|
||
localStartDate = start;
|
||
localEndDate = end;
|
||
}
|
||
}
|
||
|
||
// Helper functions
|
||
function formatDateInTimezone(utcDate: string, timezone: string): string {
|
||
try {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
timeZone: timezone,
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: true
|
||
}).format(new Date(utcDate));
|
||
} catch {
|
||
return new Date(utcDate).toLocaleString();
|
||
}
|
||
}
|
||
|
||
function formatDuration(seconds: number): string {
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const secs = seconds % 60;
|
||
|
||
if (hours > 0) {
|
||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function parseDuration(duration: string): number {
|
||
const parts = duration.split(':').map(Number);
|
||
if (parts.length === 3) {
|
||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
} else if (parts.length === 2) {
|
||
return parts[0] * 60 + parts[1];
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Event handlers
|
||
function handleLocalDateChange() {
|
||
utcStartDate = updateUTCDate({
|
||
localDate: localStartDate,
|
||
timezone: selectedStartTimezone,
|
||
allDay
|
||
}).utcDate;
|
||
|
||
utcEndDate = updateUTCDate({
|
||
localDate: localEndDate,
|
||
timezone: selectedEndTimezone,
|
||
allDay
|
||
}).utcDate;
|
||
}
|
||
|
||
function handleAllDayToggle() {
|
||
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: selectedEndTimezone,
|
||
allDay
|
||
}).utcDate;
|
||
|
||
localStartDate = updateLocalDate({
|
||
utcDate: utcStartDate,
|
||
timezone: selectedStartTimezone
|
||
}).localDate;
|
||
|
||
localEndDate = updateLocalDate({
|
||
utcDate: utcEndDate,
|
||
timezone: selectedEndTimezone
|
||
}).localDate;
|
||
}
|
||
|
||
async function addVisit() {
|
||
// If editing an existing visit, patch instead of creating new
|
||
if (visitIdEditing) {
|
||
const response = await fetch(`/api/visits/${visitIdEditing}/`, {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
start_date: utcStartDate,
|
||
end_date: utcEndDate,
|
||
notes: note,
|
||
timezone: selectedStartTimezone
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
const updatedVisit: Visit = await response.json();
|
||
visits = visits ? [...visits, updatedVisit] : [updatedVisit];
|
||
dispatch('visitAdded', updatedVisit);
|
||
visitIdEditing = null;
|
||
} else {
|
||
const errorText = await response.text();
|
||
}
|
||
} else {
|
||
// post to /api/visits for new visit
|
||
const response = await fetch('/api/visits/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
object_id: objectId,
|
||
start_date: utcStartDate,
|
||
end_date: utcEndDate,
|
||
notes: note,
|
||
timezone: selectedStartTimezone,
|
||
location: objectId
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
const newVisit: Visit = await response.json();
|
||
visits = visits ? [...visits, newVisit] : [newVisit];
|
||
dispatch('visitAdded', newVisit);
|
||
} else {
|
||
const errorText = await response.text();
|
||
alert(`Failed to add visit: ${errorText}`);
|
||
}
|
||
}
|
||
|
||
// Reset form fields
|
||
note = '';
|
||
localStartDate = '';
|
||
localEndDate = '';
|
||
utcStartDate = null;
|
||
utcEndDate = null;
|
||
}
|
||
|
||
// Activity management functions
|
||
async function loadActivitiesForVisit(visit: Visit | TransportationVisit) {
|
||
if (!stravaEnabled) return;
|
||
|
||
loadingActivities[visit.id] = true;
|
||
loadingActivities = { ...loadingActivities };
|
||
|
||
try {
|
||
let startDate = new Date(visit.start_date);
|
||
let endDate = new Date(visit.end_date);
|
||
|
||
if (isAllDay(visit.start_date) && visit.end_date.includes('T00:00:00')) {
|
||
endDate = new Date(visit.end_date.replace('T00:00:00', 'T23:59:59'));
|
||
}
|
||
|
||
startDate.setHours(startDate.getHours() - 12);
|
||
endDate.setHours(endDate.getHours() + 12);
|
||
|
||
const bufferedStart = startDate.toISOString();
|
||
const bufferedEnd = endDate.toISOString();
|
||
|
||
const response = await fetch(
|
||
`/api/integrations/strava/activities/?start_date=${bufferedStart}&end_date=${bufferedEnd}`,
|
||
{
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
}
|
||
);
|
||
|
||
if (response.ok) {
|
||
const apiRes = await response.json();
|
||
const filtered = apiRes.activities;
|
||
visitActivities[visit.id] = filtered;
|
||
visitActivities = { ...visitActivities };
|
||
} else {
|
||
console.error('Failed to load activities for visit:', await response.text());
|
||
visitActivities[visit.id] = [];
|
||
visitActivities = { ...visitActivities };
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading activities for visit:', error);
|
||
visitActivities[visit.id] = [];
|
||
visitActivities = { ...visitActivities };
|
||
} finally {
|
||
loadingActivities[visit.id] = false;
|
||
loadingActivities = { ...loadingActivities };
|
||
}
|
||
}
|
||
|
||
function toggleVisitActivities(visit: Visit | TransportationVisit) {
|
||
const isExpanded = expandedVisits[visit.id];
|
||
|
||
if (!isExpanded) {
|
||
expandedVisits[visit.id] = true;
|
||
expandedVisits = { ...expandedVisits };
|
||
|
||
if (!visitActivities[visit.id]) {
|
||
loadActivitiesForVisit(visit);
|
||
}
|
||
} else {
|
||
expandedVisits[visit.id] = false;
|
||
expandedVisits = { ...expandedVisits };
|
||
}
|
||
}
|
||
|
||
function showActivityUploadForm(visitId: string) {
|
||
showActivityUpload[visitId] = true;
|
||
showActivityUpload = { ...showActivityUpload };
|
||
|
||
// Reset form
|
||
activityForm = {
|
||
name: '',
|
||
sport_type: 'Run',
|
||
distance: null,
|
||
moving_time: '',
|
||
elapsed_time: '',
|
||
elevation_gain: null,
|
||
elevation_loss: null,
|
||
start_date: '',
|
||
calories: null,
|
||
gpx_file: null,
|
||
trail: null,
|
||
elev_high: null,
|
||
elev_low: null,
|
||
rest_time: null,
|
||
average_speed: null,
|
||
max_speed: null,
|
||
average_cadence: null,
|
||
start_lat: null,
|
||
start_lng: null,
|
||
end_lat: null,
|
||
end_lng: null,
|
||
timezone: undefined
|
||
};
|
||
}
|
||
|
||
function hideActivityUploadForm(visitId: string) {
|
||
showActivityUpload[visitId] = false;
|
||
showActivityUpload = { ...showActivityUpload };
|
||
|
||
// Clear pending import
|
||
delete pendingStravaImport[visitId];
|
||
pendingStravaImport = { ...pendingStravaImport };
|
||
}
|
||
|
||
function handleGpxFileChange(event: Event) {
|
||
const target = event.target as HTMLInputElement;
|
||
if (target.files && target.files[0]) {
|
||
activityForm.gpx_file = target.files[0];
|
||
}
|
||
}
|
||
|
||
async function uploadActivity(visitId: string) {
|
||
if (!activityForm.name.trim()) {
|
||
alert($t('adventures.activity_name_required'));
|
||
return;
|
||
}
|
||
|
||
// If this is a Strava import, require GPX file
|
||
if (pendingStravaImport[visitId] && !activityForm.gpx_file) {
|
||
alert($t('strava.gpx_required'));
|
||
return;
|
||
}
|
||
|
||
uploadingActivity[visitId] = true;
|
||
uploadingActivity = { ...uploadingActivity };
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
|
||
// Add basic activity data
|
||
formData.append('visit', visitId);
|
||
formData.append('name', activityForm.name);
|
||
if (activityForm.sport_type) formData.append('sport_type', activityForm.sport_type);
|
||
if (activityForm.distance) formData.append('distance', activityForm.distance.toString());
|
||
if (activityForm.moving_time) {
|
||
const seconds = parseDuration(activityForm.moving_time);
|
||
formData.append('moving_time', `PT${seconds}S`);
|
||
}
|
||
if (activityForm.elapsed_time) {
|
||
const seconds = parseDuration(activityForm.elapsed_time);
|
||
formData.append('elapsed_time', `PT${seconds}S`);
|
||
}
|
||
if (activityForm.elevation_gain)
|
||
formData.append('elevation_gain', activityForm.elevation_gain.toString());
|
||
if (activityForm.elevation_loss)
|
||
formData.append('elevation_loss', activityForm.elevation_loss.toString());
|
||
if (activityForm.start_date)
|
||
formData.append('start_date', formatUTCDate(activityForm.start_date));
|
||
|
||
if (activityForm.calories) formData.append('calories', activityForm.calories.toString());
|
||
if (activityForm.trail) formData.append('trail', activityForm.trail);
|
||
if (activityForm.elev_high) formData.append('elev_high', activityForm.elev_high.toString());
|
||
if (activityForm.elev_low) formData.append('elev_low', activityForm.elev_low.toString());
|
||
if (activityForm.rest_time) formData.append('rest_time', activityForm.rest_time.toString());
|
||
if (activityForm.average_speed)
|
||
formData.append('average_speed', activityForm.average_speed.toString());
|
||
if (activityForm.max_speed) formData.append('max_speed', activityForm.max_speed.toString());
|
||
if (activityForm.average_cadence)
|
||
formData.append('average_cadence', activityForm.average_cadence.toString());
|
||
if (activityForm.start_lat !== null)
|
||
formData.append('start_lat', activityForm.start_lat.toString());
|
||
if (activityForm.start_lng !== null)
|
||
formData.append('start_lng', activityForm.start_lng.toString());
|
||
if (activityForm.end_lat !== null)
|
||
formData.append('end_lat', activityForm.end_lat.toString());
|
||
if (activityForm.end_lng !== null)
|
||
formData.append('end_lng', activityForm.end_lng.toString());
|
||
if (activityForm.timezone) {
|
||
formData.append('timezone', activityForm.timezone);
|
||
}
|
||
|
||
// Add GPX file if provided
|
||
if (activityForm.gpx_file) {
|
||
formData.append('gpx_file', activityForm.gpx_file);
|
||
}
|
||
|
||
// Add external service ID if this is a Strava import
|
||
if (pendingStravaImport[visitId]) {
|
||
formData.append('external_service_id', pendingStravaImport[visitId].id.toString());
|
||
}
|
||
|
||
const response = await fetch('/locations?/activity', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
const newActivityResponse = deserialize(await response.text()) as { data: Activity };
|
||
const newActivity = newActivityResponse.data as Activity;
|
||
|
||
// Update the visit's activities array
|
||
if (visits) {
|
||
visits = visits.map((visit) => {
|
||
if (visit.id === visitId) {
|
||
return {
|
||
...visit,
|
||
activities: [...(visit.activities || []), newActivity]
|
||
};
|
||
}
|
||
return visit;
|
||
});
|
||
}
|
||
|
||
// Hide the upload form
|
||
hideActivityUploadForm(visitId);
|
||
} else {
|
||
const errorText = await response.text();
|
||
console.error('Failed to upload activity:', errorText);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error uploading activity:', error);
|
||
} finally {
|
||
uploadingActivity[visitId] = false;
|
||
uploadingActivity = { ...uploadingActivity };
|
||
}
|
||
}
|
||
|
||
async function deleteActivity(visitId: string, activityId: string) {
|
||
if (!confirm($t('adventures.confirm_delete_activity'))) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/activities/${activityId}/`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (response.ok) {
|
||
// Refetch the location data to get the updated visits with correct IDs
|
||
|
||
const locationResponse = await fetch(`/api/locations/${objectId}/`);
|
||
if (locationResponse.ok) {
|
||
const updatedLocation = await locationResponse.json();
|
||
visits = updatedLocation.visits;
|
||
} else {
|
||
console.error('Failed to refetch location data:', await locationResponse.text());
|
||
}
|
||
} else {
|
||
console.error('Failed to delete activity:', await response.text());
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting activity:', error);
|
||
}
|
||
}
|
||
|
||
async function handleStravaActivityImport(event: CustomEvent<StravaActivity>, visitId: string) {
|
||
const stravaActivity = event.detail;
|
||
|
||
try {
|
||
// Store the pending import and show upload form
|
||
pendingStravaImport[visitId] = stravaActivity;
|
||
pendingStravaImport = { ...pendingStravaImport };
|
||
|
||
// Pre-fill the activity form with Strava data
|
||
activityForm = {
|
||
name: stravaActivity.name,
|
||
sport_type: stravaActivity.sport_type || stravaActivity.type,
|
||
distance: stravaActivity.distance || null, // Keep in meters
|
||
moving_time: stravaActivity.moving_time ? formatDuration(stravaActivity.moving_time) : '',
|
||
elapsed_time: stravaActivity.elapsed_time
|
||
? formatDuration(stravaActivity.elapsed_time)
|
||
: '',
|
||
elevation_gain: stravaActivity.total_elevation_gain || null,
|
||
elevation_loss: stravaActivity.estimated_elevation_loss || null,
|
||
start_date: stravaActivity.start_date ? stravaActivity.start_date.substring(0, 16) : '',
|
||
calories: stravaActivity.calories || null,
|
||
gpx_file: null,
|
||
trail: null,
|
||
elev_high: stravaActivity.elev_high || null,
|
||
elev_low: stravaActivity.elev_low || null,
|
||
rest_time: stravaActivity.rest_time || null,
|
||
average_speed: stravaActivity.average_speed || null,
|
||
max_speed: stravaActivity.max_speed || null,
|
||
average_cadence: stravaActivity.average_cadence || null,
|
||
start_lat: stravaActivity.start_latlng ? stravaActivity.start_latlng[0] : null,
|
||
start_lng: stravaActivity.start_latlng ? stravaActivity.start_latlng[1] : null,
|
||
end_lat: stravaActivity.end_latlng ? stravaActivity.end_latlng[0] : null,
|
||
end_lng: stravaActivity.end_latlng ? stravaActivity.end_latlng[1] : null,
|
||
timezone: stravaActivity.timezone || undefined
|
||
};
|
||
|
||
// Show the upload form
|
||
showActivityUpload[visitId] = true;
|
||
showActivityUpload = { ...showActivityUpload };
|
||
} catch (error) {
|
||
console.error('Error initiating Strava import:', error);
|
||
}
|
||
}
|
||
|
||
function editVisit(visit: Visit) {
|
||
isEditing = true;
|
||
visitIdEditing = visit.id;
|
||
const isAllDayEvent = isAllDay(visit.start_date);
|
||
allDay = isAllDayEvent;
|
||
|
||
if ('start_timezone' in visit && typeof visit.start_timezone === 'string') {
|
||
selectedStartTimezone = visit.start_timezone;
|
||
if ('end_timezone' in visit && typeof visit.end_timezone === 'string') {
|
||
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 && typeof visit.end_timezone === 'string'
|
||
? visit.end_timezone
|
||
: selectedStartTimezone
|
||
}).localDate;
|
||
}
|
||
|
||
// Remove the visit from the array temporarily for editing
|
||
if (visits) {
|
||
visits = visits.filter((v) => v.id !== visit.id);
|
||
}
|
||
|
||
// Clean up activities for this visit
|
||
delete visitActivities[visit.id];
|
||
delete expandedVisits[visit.id];
|
||
delete loadingActivities[visit.id];
|
||
delete showActivityUpload[visit.id];
|
||
|
||
note = visit.notes;
|
||
constrainDates = true;
|
||
utcStartDate = visit.start_date;
|
||
utcEndDate = visit.end_date;
|
||
|
||
setTimeout(() => {
|
||
isEditing = false;
|
||
}, 0);
|
||
}
|
||
|
||
function removeVisit(visitId: string) {
|
||
if (visits) {
|
||
visits = visits.filter((v) => v.id !== visitId);
|
||
}
|
||
|
||
// Clean up activities for this visit
|
||
delete visitActivities[visitId];
|
||
delete expandedVisits[visitId];
|
||
delete loadingActivities[visitId];
|
||
delete showActivityUpload[visitId];
|
||
|
||
// make the DELETE request
|
||
fetch(`/api/visits/${visitId}/`, {
|
||
method: 'DELETE'
|
||
}).then((response) => {
|
||
if (!response.ok) {
|
||
console.error('Error deleting visit:', response.statusText);
|
||
} else {
|
||
// remove the visit from the local state
|
||
visits = visits?.filter((v) => v.id !== visitId) ?? null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleBack() {
|
||
dispatch('back');
|
||
}
|
||
|
||
function handleClose() {
|
||
dispatch('close');
|
||
}
|
||
|
||
// Lifecycle
|
||
onMount(async () => {
|
||
localStartDate = updateLocalDate({
|
||
utcDate: utcStartDate,
|
||
timezone: selectedStartTimezone
|
||
}).localDate;
|
||
|
||
localEndDate = updateLocalDate({
|
||
utcDate: utcEndDate,
|
||
timezone: selectedEndTimezone
|
||
}).localDate;
|
||
|
||
if (!selectedStartTimezone) {
|
||
selectedStartTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
if (!selectedEndTimezone) {
|
||
selectedEndTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
|
||
// Check if Strava is enabled by making a simple API call
|
||
try {
|
||
const response = await fetch('/api/integrations/strava/activities', {
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
stravaEnabled = response.ok;
|
||
} catch {
|
||
stravaEnabled = false;
|
||
}
|
||
});
|
||
|
||
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
|
||
$: typeConfig = getTypeConfig();
|
||
</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">
|
||
<div class="card bg-base-100 border border-base-300 shadow-lg">
|
||
<div class="card-body p-6">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div class="flex items-center gap-3">
|
||
<div class="p-2 bg-{typeConfig.color}/10 rounded-lg">
|
||
<svelte:component this={typeConfig.icon} class="w-5 h-5 text-{typeConfig.color}" />
|
||
</div>
|
||
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Section -->
|
||
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<SettingsIcon class="w-4 h-4 text-base-content/70" />
|
||
<h3 class="font-medium text-base-content/80">{$t('navbar.settings')}</h3>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<!-- Timezone Selection -->
|
||
|
||
<div>
|
||
<label class="label-text text-sm font-medium" for="timezone-selector"
|
||
>{$t('adventures.timezone')}</label
|
||
>
|
||
<div class="mt-1">
|
||
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toggles -->
|
||
<div class="flex flex-wrap gap-6">
|
||
<div class="flex items-center gap-3">
|
||
<ClockIcon class="w-4 h-4 text-base-content/70" />
|
||
<label class="label-text text-sm font-medium" for="all-day-toggle"
|
||
>{$t('adventures.all_day')}</label
|
||
>
|
||
<input
|
||
id="all-day-toggle"
|
||
type="checkbox"
|
||
class="toggle toggle-{typeConfig.color} toggle-sm"
|
||
bind:checked={allDay}
|
||
on:change={handleAllDayToggle}
|
||
/>
|
||
</div>
|
||
|
||
{#if collection?.start_date && collection?.end_date}
|
||
<div class="flex items-center gap-3">
|
||
<CalendarIcon class="w-4 h-4 text-base-content/70" />
|
||
<label class="label-text text-sm font-medium" for="constrain-dates"
|
||
>{$t('adventures.date_constrain')}</label
|
||
>
|
||
<input
|
||
id="constrain-dates"
|
||
type="checkbox"
|
||
class="toggle toggle-{typeConfig.color} toggle-sm"
|
||
bind:checked={constrainDates}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Date Selection Section -->
|
||
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
|
||
<h3 class="font-medium text-base-content/80 mb-4">{$t('adventures.date_selection')}</h3>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<!-- Start Date -->
|
||
<div>
|
||
<label class="label-text text-sm font-medium" for="start-date-input">
|
||
{typeConfig.startLabel}
|
||
</label>
|
||
{#if allDay}
|
||
<input
|
||
id="start-date-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
|
||
id="start-date-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>
|
||
|
||
<!-- End Date -->
|
||
{#if localStartDate}
|
||
<div>
|
||
<label class="label-text text-sm font-medium" for="end-date-input">
|
||
{typeConfig.endLabel}
|
||
</label>
|
||
{#if allDay}
|
||
<input
|
||
id="end-date-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
|
||
id="end-date-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>
|
||
|
||
<!-- Notes (Location only) -->
|
||
|
||
<div class="mt-4">
|
||
<label class="label-text text-sm font-medium" for="visit-notes"
|
||
>{$t('adventures.notes')}</label
|
||
>
|
||
<textarea
|
||
id="visit-notes"
|
||
class="textarea textarea-bordered w-full mt-1"
|
||
rows="3"
|
||
placeholder={$t('adventures.notes_placeholder') + '...'}
|
||
bind:value={note}
|
||
></textarea>
|
||
</div>
|
||
|
||
<!-- Add Visit Button -->
|
||
<div class="flex justify-end mt-4">
|
||
<button
|
||
class="btn btn-{typeConfig.color} btn-sm gap-2"
|
||
type="button"
|
||
disabled={!localStartDate || !isDateValid}
|
||
on:click={addVisit}
|
||
>
|
||
<PlusIcon class="w-4 h-4" />
|
||
{visitIdEditing ? $t('adventures.update_visit') : $t('adventures.add_visit')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Validation Error -->
|
||
{#if !isDateValid}
|
||
<div class="alert alert-error mb-6">
|
||
<AlertIcon class="w-5 h-5" />
|
||
<span class="text-sm">{$t('adventures.invalid_date_range')}</span>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Visits List (Location only) -->
|
||
|
||
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
|
||
<h3 class="font-medium text-base-content/80 mb-4">
|
||
{$t('adventures.visits')} ({visits?.length || 0})
|
||
</h3>
|
||
|
||
{#if !visits || visits.length === 0}
|
||
<div class="text-center py-8 text-base-content/60">
|
||
<CalendarIcon class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||
<p class="text-sm">{$t('adventures.no_visits')}</p>
|
||
<p class="text-xs text-base-content/40 mt-1">
|
||
{$t('adventures.no_visits_description')}
|
||
</p>
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-3">
|
||
{#each visits as visit (visit.id)}
|
||
<div
|
||
class="bg-base-100 p-4 rounded-lg border border-base-300 hover:border-base-400 transition-colors"
|
||
>
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
{#if isAllDay(visit.start_date)}
|
||
<span class="badge badge-outline badge-sm"
|
||
>{$t('adventures.all_day')}</span
|
||
>
|
||
{:else}
|
||
<ClockIcon class="w-3 h-3 text-base-content/50" />
|
||
{/if}
|
||
<div class="text-sm font-medium truncate">
|
||
{#if isAllDay(visit.start_date)}
|
||
{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 && visit.timezone}
|
||
{formatDateInTimezone(visit.start_date, visit.timezone)}
|
||
– {formatDateInTimezone(visit.end_date, visit.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>
|
||
</div>
|
||
|
||
{#if visit.notes}
|
||
<p class="text-xs text-base-content/70 bg-base-200/50 p-2 rounded">
|
||
"{visit.notes}"
|
||
</p>
|
||
{/if}
|
||
|
||
{#if visit.activities && visit.activities.length > 0}
|
||
<div class="flex items-center gap-2 mt-2">
|
||
<RunFastIcon class="w-3 h-3 text-success" />
|
||
<span class="text-xs text-success font-medium">
|
||
{visit.activities.length}
|
||
{$t('adventures.saved_activities')}
|
||
</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Visit Actions -->
|
||
<div class="flex gap-1 ml-4">
|
||
<!-- Activities Button (only show if Strava is enabled) -->
|
||
{#if stravaEnabled}
|
||
<button
|
||
class="btn btn-info btn-xs tooltip tooltip-top gap-1"
|
||
data-tip={$t('adventures.view_strava_activities')}
|
||
on:click={() => toggleVisitActivities(visit)}
|
||
>
|
||
<RunFastIcon class="w-3 h-3" />
|
||
{#if visitActivities[visit.id]}
|
||
({visitActivities[visit.id].length})
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
|
||
<!-- Upload Activity Button -->
|
||
<button
|
||
class="btn btn-success btn-xs tooltip tooltip-top gap-1"
|
||
data-tip={$t('adventures.add_activity')}
|
||
on:click={() => showActivityUploadForm(visit.id)}
|
||
>
|
||
<UploadIcon class="w-3 h-3" />
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-warning btn-xs tooltip tooltip-top"
|
||
data-tip={$t('adventures.edit_visit')}
|
||
on:click={() => editVisit(visit)}
|
||
>
|
||
<EditIcon class="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
class="btn btn-error btn-xs tooltip tooltip-top"
|
||
data-tip={$t('adventures.remove_visit')}
|
||
on:click={() => removeVisit(visit.id)}
|
||
>
|
||
<TrashIcon class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Activity Upload Form -->
|
||
{#if showActivityUpload[visit.id]}
|
||
<div class="mt-4 pt-4 border-t border-base-300">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="flex items-center gap-2">
|
||
<UploadIcon class="w-4 h-4 text-success" />
|
||
<h4 class="font-medium text-sm">
|
||
{#if pendingStravaImport[visit.id]}
|
||
{$t('adventures.complete_strava_import')}
|
||
{:else}
|
||
{$t('adventures.add_new_activity')}
|
||
{/if}
|
||
</h4>
|
||
</div>
|
||
<button
|
||
class="btn btn-ghost btn-xs"
|
||
on:click={() => hideActivityUploadForm(visit.id)}
|
||
>
|
||
<CloseIcon class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
|
||
{#if pendingStravaImport[visit.id]}
|
||
<div class="alert alert-info mb-4">
|
||
<div class="flex items-center gap-2">
|
||
<RunFastIcon class="w-4 h-4" />
|
||
<div class="text-sm">
|
||
<div class="font-medium">
|
||
{$t('adventures.strava_activity_ready')}
|
||
</div>
|
||
<div class="text-xs opacity-75">
|
||
{$t('adventures.gpx_file_downloaded')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="bg-base-200/50 p-4 rounded-lg">
|
||
{#if pendingStravaImport[visit.id]}
|
||
<!-- Highlight GPX upload for Strava imports -->
|
||
<div class="mb-6 p-4 bg-warning/10 border-2 border-warning/30 rounded-lg">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<FileIcon class="w-4 h-4 text-warning" />
|
||
<label
|
||
class="label-text font-medium text-warning"
|
||
for="gpx-file-{visit.id}"
|
||
>{$t('adventures.gpx_file_required')} *</label
|
||
>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<input
|
||
id="gpx-file-{visit.id}"
|
||
type="file"
|
||
accept=".gpx"
|
||
class="file-input file-input-bordered file-input-warning flex-1"
|
||
on:change={handleGpxFileChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="btn btn-warning btn-sm gap-1"
|
||
on:click={() => {
|
||
const stravaActivity = pendingStravaImport[visit.id];
|
||
if (stravaActivity && stravaActivity.export_gpx) {
|
||
window.open(stravaActivity.export_gpx, '_blank');
|
||
}
|
||
}}
|
||
>
|
||
<UploadIcon class="w-3 h-3" />
|
||
{$t('adventures.download_gpx')}
|
||
</button>
|
||
</div>
|
||
<div class="text-xs text-warning/80 mt-1">
|
||
{$t('adventures.upload_gpx_file')}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<!-- Activity Name -->
|
||
<div class="md:col-span-2">
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="activity-name-{visit.id}"
|
||
>{$t('adventures.activity_name')} *</label
|
||
>
|
||
<input
|
||
id="activity-name-{visit.id}"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder={$t('adventures.activity_name_placeholder')}
|
||
bind:value={activityForm.name}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Sport Type -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="sport-type-{visit.id}">{$t('adventures.sport_type')}</label
|
||
>
|
||
<select
|
||
id="sport-type-{visit.id}"
|
||
class="select select-bordered select-sm w-full mt-1"
|
||
bind:value={activityForm.sport_type}
|
||
disabled={!!pendingStravaImport[visit.id]}
|
||
>
|
||
{#each SPORT_TYPE_CHOICES as sportType}
|
||
<option value={sportType.key}
|
||
>{sportType.icon} {sportType.label}</option
|
||
>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Distance -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="distance-{visit.id}"
|
||
>{$t('adventures.distance')} (km)</label
|
||
>
|
||
<input
|
||
id="distance-{visit.id}"
|
||
type="number"
|
||
step="0.01"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="5.2"
|
||
bind:value={activityForm.distance}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Moving Time -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="moving-time-{visit.id}"
|
||
>{$t('adventures.moving_time')} (HH:MM:SS)</label
|
||
>
|
||
<input
|
||
id="moving-time-{visit.id}"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="0:25:30"
|
||
bind:value={activityForm.moving_time}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Elapsed Time -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="elapsed-time-{visit.id}"
|
||
>{$t('adventures.elapsed_time')} (HH:MM:SS)</label
|
||
>
|
||
<input
|
||
id="elapsed-time-{visit.id}"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="0:30:00"
|
||
bind:value={activityForm.elapsed_time}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Start Date -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="start-date-{visit.id}">{$t('adventures.start_date')}</label
|
||
>
|
||
<input
|
||
id="start-date-{visit.id}"
|
||
type="datetime-local"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
bind:value={activityForm.start_date}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Elevation Gain -->
|
||
{#if !activityForm.gpx_file}
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="elevation-gain-{visit.id}"
|
||
>{$t('adventures.elevation_gain')} (m)</label
|
||
>
|
||
<input
|
||
id="elevation-gain-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="150"
|
||
bind:value={activityForm.elevation_gain}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Elevation Loss -->
|
||
{#if !activityForm.gpx_file}
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="elevation-loss-{visit.id}"
|
||
>{$t('adventures.elevation_loss')} (m)</label
|
||
>
|
||
<input
|
||
id="elevation-loss-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="150"
|
||
bind:value={activityForm.elevation_loss}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Calories -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="calories-{visit.id}"
|
||
>{$t('adventures.calories')}</label
|
||
>
|
||
<input
|
||
id="calories-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="300"
|
||
bind:value={activityForm.calories}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Elevation High -->
|
||
{#if !activityForm.gpx_file}
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="elevation-high-{visit.id}"
|
||
>{$t('adventures.elevation_high')} (m)</label
|
||
>
|
||
<input
|
||
id="elevation-high-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="2000"
|
||
bind:value={activityForm.elev_high}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Elevation Low -->
|
||
{#if !activityForm.gpx_file}
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="elevation-low-{visit.id}"
|
||
>{$t('adventures.elevation_low')} (m)</label
|
||
>
|
||
<input
|
||
id="elevation-low-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="1000"
|
||
bind:value={activityForm.elev_low}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Rest Time -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="rest-time-{visit.id}"
|
||
>{$t('adventures.rest_time')} (s)</label
|
||
>
|
||
<input
|
||
id="rest-time-{visit.id}"
|
||
type="number"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="60"
|
||
bind:value={activityForm.rest_time}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Start Latitude -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="start-lat-{visit.id}"
|
||
>{$t('adventures.start_lat')} (°)</label
|
||
>
|
||
<input
|
||
id="start-lat-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="37.7749"
|
||
bind:value={activityForm.start_lat}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Start Longitude -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="start-lng-{visit.id}"
|
||
>{$t('adventures.start_lng')} (°)</label
|
||
>
|
||
<input
|
||
id="start-lng-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="-122.4194"
|
||
bind:value={activityForm.start_lng}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- End Latitude -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="end-lat-{visit.id}"
|
||
>{$t('adventures.end_lat')} (°)</label
|
||
>
|
||
<input
|
||
id="end-lat-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="37.7749"
|
||
bind:value={activityForm.end_lat}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- End Longitude -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="end-lng-{visit.id}"
|
||
>{$t('adventures.end_lng')} (°)</label
|
||
>
|
||
<input
|
||
id="end-lng-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="-122.4194"
|
||
bind:value={activityForm.end_lng}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Timezone -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="timezone-{visit.id}"
|
||
>{$t('adventures.timezone')}</label
|
||
>
|
||
<TimezoneSelector bind:selectedTimezone={activityForm.timezone} />
|
||
</div>
|
||
|
||
<!-- Average Speed -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="average-speed-{visit.id}"
|
||
>{$t('adventures.average_speed')} (m/s)</label
|
||
>
|
||
<input
|
||
id="average-speed-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="3.5"
|
||
bind:value={activityForm.average_speed}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Max Speed -->
|
||
<div>
|
||
<label class="label-text text-xs font-medium" for="max-speed-{visit.id}"
|
||
>{$t('adventures.max_speed')} (m/s)</label
|
||
>
|
||
<input
|
||
id="max-speed-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="5.0"
|
||
bind:value={activityForm.max_speed}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Average Cadence -->
|
||
<div>
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="average-cadence-{visit.id}"
|
||
>{$t('adventures.average_cadence')} (rpm)</label
|
||
>
|
||
<input
|
||
id="average-cadence-{visit.id}"
|
||
type="number"
|
||
step="any"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="80"
|
||
bind:value={activityForm.average_cadence}
|
||
readonly={!!pendingStravaImport[visit.id]}
|
||
/>
|
||
</div>
|
||
|
||
<!-- Trail Selection -->
|
||
{#if trails && trails.length > 0}
|
||
<div class="md:col-span-2">
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="trail-select-{visit.id}">{$t('adventures.trail')}</label
|
||
>
|
||
<select
|
||
id="trail-select-{visit.id}"
|
||
class="select select-bordered select-sm w-full mt-1"
|
||
bind:value={activityForm.trail}
|
||
>
|
||
<option value="">Select a trail</option>
|
||
{#each trails as trail (trail.id)}
|
||
<option value={trail.id}>{trail.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- GPX File (for manual uploads) -->
|
||
{#if !pendingStravaImport[visit.id]}
|
||
<div class="md:col-span-2">
|
||
<label
|
||
class="label-text text-xs font-medium"
|
||
for="gpx-file-manual-{visit.id}">{$t('adventures.gpx_file')}</label
|
||
>
|
||
<input
|
||
id="gpx-file-manual-{visit.id}"
|
||
type="file"
|
||
accept=".gpx"
|
||
class="file-input file-input-bordered file-input-sm w-full mt-1"
|
||
on:change={handleGpxFileChange}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-2 mt-4">
|
||
<button
|
||
class="btn btn-ghost btn-sm"
|
||
on:click={() => hideActivityUploadForm(visit.id)}
|
||
disabled={uploadingActivity[visit.id]}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
class="btn btn-success btn-sm gap-2"
|
||
on:click={() => uploadActivity(visit.id)}
|
||
disabled={uploadingActivity[visit.id] ||
|
||
!activityForm.name.trim() ||
|
||
(pendingStravaImport[visit.id] && !activityForm.gpx_file)}
|
||
>
|
||
{#if uploadingActivity[visit.id]}
|
||
<LoadingIcon class="w-3 h-3 animate-spin" />
|
||
{#if pendingStravaImport[visit.id]}
|
||
{$t('adventures.importing')}...
|
||
{:else}
|
||
{$t('adventures.uploading')}...
|
||
{/if}
|
||
{:else if pendingStravaImport[visit.id]}
|
||
<UploadIcon class="w-3 h-3" />
|
||
{$t('adventures.complete_import')}
|
||
{:else}
|
||
<UploadIcon class="w-3 h-3" />
|
||
{$t('adventures.upload_activity')}
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Saved Activities Section -->
|
||
{#if visit.activities && visit.activities.length > 0}
|
||
<div class="mt-4 pt-4 border-t border-base-300">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<RunFastIcon class="w-4 h-4 text-success" />
|
||
<h4 class="font-medium text-sm">
|
||
{$t('adventures.saved_activities')} ({visit.activities.length})
|
||
</h4>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
{#each visit.activities as activity (activity.id)}
|
||
<ActivityCard
|
||
{activity}
|
||
{trails}
|
||
{visit}
|
||
{measurementSystem}
|
||
on:delete={(event) =>
|
||
deleteActivity(event.detail.visitId, event.detail.activityId)}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Strava Activities Section -->
|
||
{#if stravaEnabled && expandedVisits[visit.id]}
|
||
<div class="mt-4 pt-4 border-t border-base-300">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<RunFastIcon class="w-4 h-4 text-info" />
|
||
<h4 class="font-medium text-sm">
|
||
{$t('adventures.strava_activities_during_visit')}
|
||
</h4>
|
||
{#if loadingActivities[visit.id]}
|
||
<LoadingIcon class="w-4 h-4 animate-spin text-info" />
|
||
{/if}
|
||
</div>
|
||
|
||
{#if loadingActivities[visit.id]}
|
||
<div class="text-center py-4">
|
||
<div class="loading loading-spinner loading-sm"></div>
|
||
<p class="text-xs text-base-content/60 mt-2">
|
||
{$t('adventures.loading_activities')}...
|
||
</p>
|
||
</div>
|
||
{:else if visitActivities[visit.id] && visitActivities[visit.id].length > 0}
|
||
<div class="space-y-2">
|
||
{#each visitActivities[visit.id] as activity (activity.id)}
|
||
<div class="pl-4">
|
||
<StravaActivityCard
|
||
{activity}
|
||
on:import={(event) => handleStravaActivityImport(event, visit.id)}
|
||
{measurementSystem}
|
||
/>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<div class="text-center py-4 text-base-content/60">
|
||
<div class="text-2xl mb-2">🏃♂️</div>
|
||
<p class="text-xs">{$t('adventures.no_strava_activities')}</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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" on:click={handleClose}>
|
||
<CheckIcon class="w-5 h-5" />
|
||
{$t('adventures.done')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|