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

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

This commit is contained in:
Sean Morley 2025-08-03 15:16:15 -04:00
parent 5046bd49f7
commit 418edb4245
3 changed files with 152 additions and 95 deletions

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import type { Location, Attachment, Category, Collection, User } from '$lib/types'; import type { Location, User } from '$lib/types';
import { addToast } from '$lib/toasts'; import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import LocationQuickStart from './locations/LocationQuickStart.svelte'; import LocationQuickStart from './locations/LocationQuickStart.svelte';
@ -260,6 +260,7 @@
location.location = e.detail.location; location.location = e.detail.location;
location.tags = e.detail.tags; location.tags = e.detail.tags;
location.user = e.detail.user; location.user = e.detail.user;
location.id = e.detail.id;
steps[1].selected = false; steps[1].selected = false;
steps[2].selected = true; steps[2].selected = true;

View file

@ -6,6 +6,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isAllDay } from '$lib'; import { isAllDay } from '$lib';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { deserialize } from '$app/forms';
// Icons // Icons
import CalendarIcon from '~icons/mdi/calendar'; import CalendarIcon from '~icons/mdi/calendar';
@ -75,6 +76,7 @@
let expandedVisits: { [visitId: string]: boolean } = {}; let expandedVisits: { [visitId: string]: boolean } = {};
let uploadingActivity: { [visitId: string]: boolean } = {}; let uploadingActivity: { [visitId: string]: boolean } = {};
let showActivityUpload: { [visitId: string]: boolean } = {}; let showActivityUpload: { [visitId: string]: boolean } = {};
let pendingStravaImport: { [visitId: string]: StravaActivity | null } = {};
// Activity form state // Activity form state
let activityForm = { let activityForm = {
@ -293,9 +295,12 @@
const newVisit = createVisitObject(); const newVisit = createVisitObject();
// Patch updated visits array to location and get the response with actual IDs // Patch updated visits array to location and get the response with actual IDs
console.log('Adding new visit:', newVisit);
console.log(objectId);
if (type === 'location' && objectId) { if (type === 'location' && objectId) {
try { try {
const updatedVisits = visits ? [...visits, newVisit] : [newVisit]; const updatedVisits = visits ? [...visits, newVisit] : [newVisit];
console.log('Patching visits:', updatedVisits);
const response = await fetch(`/api/locations/${objectId}/`, { const response = await fetch(`/api/locations/${objectId}/`, {
method: 'PATCH', method: 'PATCH',
@ -425,6 +430,10 @@
function hideActivityUploadForm(visitId: string) { function hideActivityUploadForm(visitId: string) {
showActivityUpload[visitId] = false; showActivityUpload[visitId] = false;
showActivityUpload = { ...showActivityUpload }; showActivityUpload = { ...showActivityUpload };
// Clear pending import
delete pendingStravaImport[visitId];
pendingStravaImport = { ...pendingStravaImport };
} }
function handleGpxFileChange(event: Event) { function handleGpxFileChange(event: Event) {
@ -440,6 +449,12 @@
return; return;
} }
// If this is a Strava import, require GPX file
if (pendingStravaImport[visitId] && !activityForm.gpx_file) {
alert('Please upload the GPX file to complete the Strava import');
return;
}
uploadingActivity[visitId] = true; uploadingActivity[visitId] = true;
uploadingActivity = { ...uploadingActivity }; uploadingActivity = { ...uploadingActivity };
@ -473,13 +488,20 @@
formData.append('gpx_file', activityForm.gpx_file); formData.append('gpx_file', activityForm.gpx_file);
} }
const response = await fetch('/api/activities/', { // 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', method: 'POST',
body: formData body: formData
}); });
if (response.ok) { if (response.ok) {
const newActivity = await response.json(); const newActivityResponse = deserialize(await response.text()) as { data: Activity };
const newActivity = newActivityResponse.data as Activity;
console.log('Activity uploaded successfully:', newActivity);
// Update the visit's activities array // Update the visit's activities array
if (visits) { if (visits) {
@ -497,16 +519,10 @@
// Hide the upload form // Hide the upload form
hideActivityUploadForm(visitId); hideActivityUploadForm(visitId);
// Update the location with new visits data const importMessage = pendingStravaImport[visitId]
if (type === 'location' && objectId) { ? `Strava activity "${activityForm.name}" imported successfully!`
await fetch(`/api/locations/${objectId}/`, { : 'Activity uploaded successfully!';
method: 'PATCH', alert(importMessage);
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ visits })
});
}
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
console.error('Failed to upload activity:', errorText); console.error('Failed to upload activity:', errorText);
@ -567,79 +583,37 @@
const stravaActivity = event.detail; const stravaActivity = event.detail;
try { try {
// Convert Strava activity to our activity format // Open GPX export in new tab
const formData = new FormData(); window.open(stravaActivity.export_gpx, '_blank');
formData.append('visit', visitId);
formData.append('name', stravaActivity.name);
formData.append('type', stravaActivity.type);
formData.append('sport_type', stravaActivity.sport_type || stravaActivity.type);
// Convert distance from meters to kilometers // Store the pending import and show upload form
if (stravaActivity.distance) { pendingStravaImport[visitId] = stravaActivity;
formData.append('distance', (stravaActivity.distance / 1000).toString()); pendingStravaImport = { ...pendingStravaImport };
}
// Convert time to ISO duration format // Pre-fill the activity form with Strava data
if (stravaActivity.moving_time) { activityForm = {
formData.append('moving_time', `PT${stravaActivity.moving_time}S`); name: stravaActivity.name,
} type: stravaActivity.type,
sport_type: stravaActivity.sport_type || stravaActivity.type,
distance: stravaActivity.distance ? stravaActivity.distance / 1000 : null, // Convert to km
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,
notes: '',
gpx_file: null
};
if (stravaActivity.elapsed_time) { // Show the upload form
formData.append('elapsed_time', `PT${stravaActivity.elapsed_time}S`); showActivityUpload[visitId] = true;
} showActivityUpload = { ...showActivityUpload };
// Add elevation data
if (stravaActivity.total_elevation_gain) {
formData.append('elevation_gain', stravaActivity.total_elevation_gain.toString());
}
if (stravaActivity.estimated_elevation_loss) {
formData.append('elevation_loss', stravaActivity.estimated_elevation_loss.toString());
}
// Add start date
if (stravaActivity.start_date) {
formData.append('start_date', stravaActivity.start_date);
}
// Add calories if available
if (stravaActivity.calories) {
formData.append('calories', stravaActivity.calories.toString());
}
// Add external service ID to track the Strava origin
formData.append('external_service_id', stravaActivity.id.toString());
const response = await fetch('/api/activities/', {
method: 'POST',
body: formData
});
if (response.ok) {
const newActivity = await response.json();
// Update the visit's activities array
if (visits) {
visits = visits.map((visit) => {
if (visit.id === visitId) {
return {
...visit,
activities: [...(visit.activities || []), newActivity]
};
}
return visit;
});
}
alert(`Activity "${stravaActivity.name}" imported successfully`);
} else {
const errorText = await response.text();
console.error('Failed to import Strava activity:', errorText);
alert('Failed to import activity. Please try again.');
}
} catch (error) { } catch (error) {
console.error('Error importing Strava activity:', error); console.error('Error initiating Strava import:', error);
alert('Error importing activity. Please try again.'); alert('Error downloading GPX file. Please try again.');
} }
} }
@ -1058,7 +1032,13 @@
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UploadIcon class="w-4 h-4 text-success" /> <UploadIcon class="w-4 h-4 text-success" />
<h4 class="font-medium text-sm">Add New Activity</h4> <h4 class="font-medium text-sm">
{#if pendingStravaImport[visit.id]}
Complete Strava Import
{:else}
Add New Activity
{/if}
</h4>
</div> </div>
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@ -1068,7 +1048,46 @@
</button> </button>
</div> </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">Strava Activity Ready</div>
<div class="text-xs opacity-75">
GPX file downloaded. Please upload it below to complete the
import.
</div>
</div>
</div>
</div>
{/if}
<div class="bg-base-200/50 p-4 rounded-lg"> <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"
>GPX File Required *</label
>
</div>
<input
type="file"
accept=".gpx"
class="file-input file-input-bordered file-input-warning w-full"
on:change={handleGpxFileChange}
/>
<div class="text-xs text-warning/80 mt-1">
Upload the GPX file that was just downloaded to complete the Strava
import
</div>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Activity Name --> <!-- Activity Name -->
<div class="md:col-span-2"> <div class="md:col-span-2">
@ -1078,6 +1097,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Morning Run" placeholder="Morning Run"
bind:value={activityForm.name} bind:value={activityForm.name}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1087,6 +1107,7 @@
<select <select
class="select select-bordered select-sm w-full mt-1" class="select select-bordered select-sm w-full mt-1"
bind:value={activityForm.type} bind:value={activityForm.type}
disabled={!!pendingStravaImport[visit.id]}
> >
{#each activityTypes as activityType} {#each activityTypes as activityType}
<option value={activityType}>{activityType}</option> <option value={activityType}>{activityType}</option>
@ -1102,6 +1123,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="Trail Running" placeholder="Trail Running"
bind:value={activityForm.sport_type} bind:value={activityForm.sport_type}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1114,6 +1136,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="5.2" placeholder="5.2"
bind:value={activityForm.distance} bind:value={activityForm.distance}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1127,6 +1150,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="0:25:30" placeholder="0:25:30"
bind:value={activityForm.moving_time} bind:value={activityForm.moving_time}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1140,6 +1164,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="0:30:00" placeholder="0:30:00"
bind:value={activityForm.elapsed_time} bind:value={activityForm.elapsed_time}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1150,6 +1175,7 @@
type="datetime-local" type="datetime-local"
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
bind:value={activityForm.start_date} bind:value={activityForm.start_date}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1163,6 +1189,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="150" placeholder="150"
bind:value={activityForm.elevation_gain} bind:value={activityForm.elevation_gain}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1176,6 +1203,7 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="150" placeholder="150"
bind:value={activityForm.elevation_loss} bind:value={activityForm.elevation_loss}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
@ -1187,19 +1215,22 @@
class="input input-bordered input-sm w-full mt-1" class="input input-bordered input-sm w-full mt-1"
placeholder="300" placeholder="300"
bind:value={activityForm.calories} bind:value={activityForm.calories}
readonly={!!pendingStravaImport[visit.id]}
/> />
</div> </div>
<!-- GPX File --> <!-- GPX File (for manual uploads) -->
<div class="md:col-span-2"> {#if !pendingStravaImport[visit.id]}
<label class="label-text text-xs font-medium">GPX File</label> <div class="md:col-span-2">
<input <label class="label-text text-xs font-medium">GPX File</label>
type="file" <input
accept=".gpx" type="file"
class="file-input file-input-bordered file-input-sm w-full mt-1" accept=".gpx"
on:change={handleGpxFileChange} class="file-input file-input-bordered file-input-sm w-full mt-1"
/> on:change={handleGpxFileChange}
</div> />
</div>
{/if}
</div> </div>
<div class="flex justify-end gap-2 mt-4"> <div class="flex justify-end gap-2 mt-4">
@ -1213,11 +1244,20 @@
<button <button
class="btn btn-success btn-sm gap-2" class="btn btn-success btn-sm gap-2"
on:click={() => uploadActivity(visit.id)} on:click={() => uploadActivity(visit.id)}
disabled={uploadingActivity[visit.id] || !activityForm.name.trim()} disabled={uploadingActivity[visit.id] ||
!activityForm.name.trim() ||
(pendingStravaImport[visit.id] && !activityForm.gpx_file)}
> >
{#if uploadingActivity[visit.id]} {#if uploadingActivity[visit.id]}
<LoadingIcon class="w-3 h-3 animate-spin" /> <LoadingIcon class="w-3 h-3 animate-spin" />
Uploading... {#if pendingStravaImport[visit.id]}
Importing...
{:else}
Uploading...
{/if}
{:else if pendingStravaImport[visit.id]}
<UploadIcon class="w-3 h-3" />
Complete Import
{:else} {:else}
<UploadIcon class="w-3 h-3" /> <UploadIcon class="w-3 h-3" />
Upload Activity Upload Activity

View file

@ -77,6 +77,22 @@ export const actions: Actions = {
let data = await res.json(); let data = await res.json();
return data; return data;
}, },
activity: async (event) => {
let formData = await event.request.formData();
let csrfToken = await fetchCSRFToken();
let sessionId = event.cookies.get('sessionid');
let res = await fetch(`${serverEndpoint}/api/activities/`, {
method: 'POST',
headers: {
Cookie: `csrftoken=${csrfToken}; sessionid=${sessionId}`,
'X-CSRFToken': csrfToken,
Referer: event.url.origin // Include Referer header
},
body: formData
});
let data = await res.json();
return data;
},
attachment: async (event) => { attachment: async (event) => {
let formData = await event.request.formData(); let formData = await event.request.formData();
let csrfToken = await fetchCSRFToken(); let csrfToken = await fetchCSRFToken();