Skip to content

Commit b9141d8

Browse files
authored
Merge pull request #4034 from bobrippling/feat/recorder-multiselect
recorder: add multi-select to interface.html
2 parents 3fdd644 + 87590ca commit b9141d8

File tree

1 file changed

+156
-71
lines changed

1 file changed

+156
-71
lines changed

apps/recorder/interface.html

Lines changed: 156 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@
4343
#track-selector { margin-bottom: 20px; }
4444
.text-center { text-align: center; }
4545
.accordion-header i { transition: transform 0.2s; }
46-
input:checked ~ .accordion-header i { transition: transform 0.2s; }
46+
input[name=accordion-tracks]:checked ~ .accordion-header i { transition: transform 0.2s; }
4747
.accordion { margin-bottom: 20px; }
4848
.accordion-item { position: relative; }
49-
.accordion-header { cursor: pointer; padding: 10px; background: #f8f9fa; border-radius: 4px; margin-bottom: 5px; }
49+
.accordion-header { cursor: pointer; padding: 10px; background: #f8f9fa; border-radius: 4px; }
50+
/* only show margin on expanded entries */
51+
input[name=accordion-tracks]:checked ~ .accordion-header { margin-bottom: 5px; }
52+
input.select-checkbox:checked ~ div > .accordion-body { background: #f0f8ff; }
5053
.accordion-header:hover { background: #e9ecef; }
5154
.accordion-body { overflow: visible; }
5255
.track-content-wrapper {
@@ -58,6 +61,17 @@
5861
.track-loading { text-align: center; padding: 20px; color: #666; }
5962
html, body { height: auto; min-height: 100%; }
6063
body { margin: 0; padding-bottom: 50px; }
64+
.vertical {
65+
display: flex;
66+
flex-direction: column;
67+
}
68+
.horizontal {
69+
display: flex;
70+
flex-direction: row;
71+
}
72+
.hidden {
73+
display: none;
74+
}
6175
</style>
6276
</head>
6377
<body>
@@ -393,8 +407,9 @@
393407
}
394408

395409
function saveGPX(track, title) {
396-
if (!track?.[0]?.Time) return showToast("Error in trackfile.", "error");
397410
track = filterGPSCoordinates(track);
411+
// check track validity after filtering
412+
if (!track?.[0]?.Time) return showToast("Error in trackfile (no time or empty).", "error");
398413
let lastTime = 0;
399414
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
400415
<gpx creator="Bangle.js" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3">
@@ -440,34 +455,50 @@
440455
return o;
441456
}
442457

443-
function downloadTrack(filename, callback) {
444-
function onData(data) {
458+
async function downloadTrack(filename) {
459+
function parse(data) {
445460
const lines = data.trim().split("\n"), headers = lines.shift().split(",");
446-
callback(lines.map(l=>trackLineToObject(headers, l)).filter(t => t.Time));
461+
return lines.map(l=>trackLineToObject(headers, l)).filter(t => t.Time);
447462
}
448463
const data = fileCache.get(filename);
449-
if (data) onData(data);
450-
else {
451-
Util.showModal(`Downloading ${filename}...`);
452-
Util.readStorageFile(filename, data => {
453-
fileCache.set(filename, data);
454-
onData(data);
455-
Util.hideModal();
456-
});
464+
if (data) return parse(data);
465+
466+
Util.showModal(`Downloading ${filename}...`);
467+
try {
468+
const data = await new Promise(resolve => Util.readStorageFile(filename, resolve));
469+
fileCache.set(filename, data);
470+
return parse(data);
471+
} finally {
472+
Util.hideModal();
457473
}
458474
}
459475

460-
function downloadAll(trackList, cb) {
461-
const tracks = [...trackList];
462-
const downloadOne = () => {
463-
const track = tracks.pop();
464-
if(!track) return showToast("Finished downloading all.", "success");
465-
downloadTrack(track.filename, lines => {
466-
cb(lines, `Bangle.js Track ${track.number}`);
467-
downloadOne();
476+
function isSelected(track) {
477+
const trackNumber = track.number;
478+
return document.getElementById(`track-download-${trackNumber}`).checked;
479+
}
480+
481+
async function downloadTracks(tracks, saveCb) {
482+
for(const track of tracks){
483+
const lines = await downloadTrack(track.filename);
484+
const title = `Bangle.js Track ${track.number}`;
485+
486+
saveCb(lines, title);
487+
}
488+
489+
showToast("Finished downloading.", "success");
490+
}
491+
492+
async function deleteTrack(filename) {
493+
Util.showModal(`Deleting ${filename}...`);
494+
495+
try {
496+
await new Promise(resolve => {
497+
Util.eraseStorageFile(filename, () => resolve());
468498
});
469-
};
470-
downloadOne();
499+
} finally {
500+
Util.hideModal();
501+
}
471502
}
472503

473504
// ========================================
@@ -641,6 +672,16 @@
641672
}
642673
}
643674

675+
function htmlCheckbox(id, text = "", extra = "", type = "checkbox") {
676+
return `
677+
<label class="form-${type}">
678+
<input type="checkbox" id="${id}" ${extra}>
679+
<i class="form-icon"></i>
680+
${text}
681+
</label>
682+
`;
683+
}
684+
644685
function getTrackList() {
645686
Util.showModal("Loading Track List...");
646687
domTracks.innerHTML = "";
@@ -698,6 +739,7 @@
698739
<h2>GPS Tracks</h2>`;
699740

700741
if (trackList.length > 0) {
742+
html += htmlCheckbox("select-all", "Select all");
701743
html += `<div class="accordion">`;
702744
trackList.forEach((track, index) => {
703745
const trackData = trackLineToObject(track.info.headers, track.info.l);
@@ -708,20 +750,24 @@ <h2>GPS Tracks</h2>`;
708750

709751
html += `
710752
<div class="accordion-item">
711-
<input type="checkbox" id="accordion-track-${track.number}" name="accordion-tracks" hidden>
712-
<label class="accordion-header" for="accordion-track-${track.number}" data-track-index="${index}">
713-
<i class="icon icon-arrow-right mr-1"></i>
714-
<strong>Track ${track.number}</strong> - ${dateStr}
715-
</label>
716-
<div class="accordion-body" id="track-content-${track.number}">
717-
<div class="track-loading">Click to load track data...</div>
753+
<div class="horizontal">
754+
${htmlCheckbox("track-download-" + track.number, undefined, "class='select-checkbox'")}
755+
<div class="vertical">
756+
<input type="checkbox" id="accordion-track-${track.number}" name="accordion-tracks" hidden>
757+
<label class="accordion-header" for="accordion-track-${track.number}" data-track-index="${index}">
758+
<i class="icon icon-arrow-right mr-1"></i>
759+
<strong>Track ${track.number}</strong> - ${dateStr}
760+
</label>
761+
<div class="accordion-body" id="track-content-${track.number}">
762+
<div class="track-loading">Click to load track data...</div>
763+
</div>
764+
</div>
718765
</div>
719766
</div>`;
720767
});
721768

722769
html += `</div>`;
723-
}
724-
if (trackList.length==0) {
770+
} else {
725771
html += `
726772
<div class="column col-12">
727773
<div class="card">
@@ -734,16 +780,21 @@ <h2>GPS Tracks</h2>`;
734780
}
735781

736782
html += `
737-
<h2>Batch Operations</h2>
738-
<div class="form-group">
739-
${['KML', 'GPX', 'CSV'].map(fmt => `<button class="btn btn-primary" task="download${fmt.toLowerCase()}_all">Download all ${fmt}</button>`).join(' ')}
783+
<div id="batch" class="hidden">
784+
<h2>Batch Operations</h2>
785+
<div class="form-group">
786+
${['KML', 'GPX', 'CSV'].map(fmt => `<button class="btn btn-primary" task="download${fmt.toLowerCase()}_selected">Download selected ${fmt}</button>`).join(' ')}
787+
<button class="btn btn-primary" id="delete-selected">Delete selected</button>
788+
</div>
740789
</div>
741790
<h2>Settings</h2>
742791
<div class="form-group">
743-
<label class="form-switch">
744-
<input type="checkbox" id="settings-allow-no-gps" ${localStorage.getItem("recorder-allow-no-gps")=="true" ? "checked" : ""}>
745-
<i class="form-icon"></i> Include GPX/KML entries even when there's no GPS info
746-
</label>
792+
${htmlCheckbox(
793+
"settings-allow-no-gps",
794+
"Include GPX/KML entries even when there's no GPS info",
795+
localStorage.getItem("recorder-allow-no-gps")=="true" ? "checked" : "",
796+
"switch",
797+
)}
747798
</div>
748799
<div class="form-group">
749800
<label class="form-label">Units</label>
@@ -790,7 +841,7 @@ <h2>Settings</h2>
790841
trackContainer.dataset.loaded = 'true';
791842
attachTrackButtonListeners(trackContainer);
792843

793-
downloadTrack(track.filename, fullTrack => {
844+
downloadTrack(track.filename).then(fullTrack => {
794845
if (trackData.Latitude) {
795846
const coordinates = fullTrack
796847
.filter(hasValidGPS)
@@ -815,11 +866,35 @@ <h2>Settings</h2>
815866
});
816867
}
817868

869+
async function confirmDelete(button, filenames) {
870+
if (button.dataset.confirmDelete === "true") {
871+
// Second click - proceed with deletion
872+
for(const filename of filenames)
873+
await deleteTrack(filename);
874+
getTrackList();
875+
} else {
876+
// First click - change to confirm state
877+
const originalText = button.textContent;
878+
button.textContent = "Confirm Delete";
879+
button.classList.add("btn-error");
880+
button.dataset.confirmDelete = "true";
881+
882+
// Reset after 3 seconds
883+
setTimeout(() => {
884+
if (button.dataset.confirmDelete === "true") {
885+
button.textContent = originalText;
886+
button.classList.remove("btn-error");
887+
delete button.dataset.confirmDelete;
888+
}
889+
}, 3000);
890+
}
891+
}
892+
818893
function attachTrackButtonListeners(container) {
819894
const buttons = container.querySelectorAll("button[task]");
820895

821896
buttons.forEach(button => {
822-
button.addEventListener("click", event => {
897+
button.addEventListener("click", async event => {
823898
const button = event.currentTarget;
824899
const filename = button.getAttribute("filename");
825900
const trackid = button.getAttribute("trackid");
@@ -829,45 +904,35 @@ <h2>Settings</h2>
829904

830905
switch(task) {
831906
case "delete":
832-
if (button.dataset.confirmDelete === "true") {
833-
// Second click - proceed with deletion
834-
Util.showModal(`Deleting ${filename}...`);
835-
Util.eraseStorageFile(filename, () => {
836-
Util.hideModal();
837-
getTrackList();
838-
});
839-
} else {
840-
// First click - change to confirm state
841-
const originalText = button.textContent;
842-
button.textContent = "Confirm Delete";
843-
button.classList.add("btn-error");
844-
button.dataset.confirmDelete = "true";
845-
846-
// Reset after 3 seconds
847-
setTimeout(() => {
848-
if (button.dataset.confirmDelete === "true") {
849-
button.textContent = originalText;
850-
button.classList.remove("btn-error");
851-
delete button.dataset.confirmDelete;
852-
}
853-
}, 3000);
854-
}
907+
await confirmDelete(button, [filename]);
855908
break;
856909
case "downloadkml":
857-
downloadTrack(filename, track => saveKML(track, `Bangle.js Track ${trackid}`));
910+
await downloadTracks([filename], track => saveKML(track, `Bangle.js Track ${trackid}`));
858911
break;
859912
case "downloadgpx":
860-
downloadTrack(filename, track => saveGPX(track, `Bangle.js Track ${trackid}`));
913+
await downloadTracks([filename], track => saveGPX(track, `Bangle.js Track ${trackid}`));
861914
break;
862915
case "downloadcsv":
863-
downloadTrack(filename, track => saveCSV(track, `Bangle.js Track ${trackid}`));
916+
await downloadTracks([filename], track => saveCSV(track, `Bangle.js Track ${trackid}`));
864917
break;
865918
}
866919
});
867920
});
868921
}
869922

870923
if (trackList.length > 0) {
924+
const selectAll = document.querySelector("#select-all");
925+
const showOrHideBatch = checkboxes => {
926+
const batch = document.querySelector("#batch");
927+
batch.classList.toggle("hidden", !checkboxes.some(b => b.checked));
928+
};
929+
const handleCheckboxCheck = () => {
930+
const checkboxes = [...document.querySelectorAll(".select-checkbox")];
931+
932+
selectAll.checked = checkboxes.every(b => b.checked);
933+
showOrHideBatch(checkboxes);
934+
};
935+
871936
document.querySelectorAll('.accordion-header').forEach(header => {
872937
header.addEventListener('click', e => {
873938
const trackIndex = parseInt(header.dataset.trackIndex);
@@ -876,6 +941,19 @@ <h2>Settings</h2>
876941
setTimeout(() => displayTrack(trackIndex, trackNumber), 10);
877942
}
878943
});
944+
945+
header.closest(".horizontal")
946+
.querySelector(".select-checkbox")
947+
.addEventListener('click', handleCheckboxCheck);
948+
});
949+
950+
selectAll.addEventListener("click", e => {
951+
const checkboxes = [...document.querySelectorAll(".select-checkbox")];
952+
const { checked } = e.target;
953+
954+
for(const b of checkboxes)
955+
b.checked = checked;
956+
showOrHideBatch(checkboxes);
879957
});
880958
}
881959

@@ -898,12 +976,19 @@ <h2>Settings</h2>
898976
getTrackList();
899977
});
900978
Util.hideModal();
901-
domTracks.querySelectorAll("button[task$='_all']").forEach(button => {
902-
button.addEventListener("click", e => {
979+
domTracks.querySelectorAll("button[task$='_selected']").forEach(button => {
980+
button.addEventListener("click", async e => {
903981
const task = e.currentTarget.getAttribute("task");
904-
downloadAll(trackList, task.includes('kml') ? saveKML : task.includes('gpx') ? saveGPX : saveCSV);
982+
await downloadTracks(
983+
trackList.filter(isSelected),
984+
task.includes('kml') ? saveKML : task.includes('gpx') ? saveGPX : saveCSV
985+
);
905986
});
906987
});
988+
domTracks.querySelector("button#delete-selected").addEventListener("click", e => {
989+
const filenames = trackList.filter(isSelected).map(track => track.filename);
990+
confirmDelete(e.target, filenames);
991+
});
907992
});
908993
});
909994
}

0 commit comments

Comments
 (0)