const Browser = (() => {
let selectedDirId = null; // used as upload context
let expandedDirs = new Set();
let dirContents = {}; // 'root' | dirId -> { dirs, tracks }
let dirMeta = {}; // dirId -> { id, name, parentId }
let dragTrackId = null;
let dragTrackName = null;
let allDirs = []; // flat list for move dialog
async function init() {
document.getElementById('new-dir-btn').addEventListener('click', createDirPrompt);
document.getElementById('upload-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', handleFileUpload);
setupDropZone();
await reload();
}
async function reload() {
const prevExpanded = new Set(expandedDirs);
dirContents = {};
dirMeta = {};
await loadRootContents();
// Re-fetch previously expanded dirs to restore tree state
for (const dirId of prevExpanded) {
try {
const data = await API.getDir(dirId);
dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
(data.children || []).forEach(d => {
dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
});
} catch (e) {
expandedDirs.delete(dirId);
}
}
renderBreadcrumb();
renderTree();
}
async function loadRootContents() {
const [dirs, tracks] = await Promise.all([
API.getDirs(),
API.getTracks('')
]);
dirContents['root'] = { dirs, tracks };
dirs.forEach(d => { dirMeta[d.id] = { id: d.id, name: d.name, parentId: null }; });
}
// ===== Breadcrumb =====
function renderBreadcrumb() {
const bc = document.getElementById('breadcrumb');
const path = buildPath(selectedDirId);
let html = 'Root';
for (const item of path) {
html += ' βΊ ';
html += `${escHtml(item.name)}`;
}
bc.innerHTML = html;
bc.querySelectorAll('.bc-item').forEach(el => {
el.addEventListener('click', () => {
selectedDirId = el.dataset.id === 'null' ? null : parseInt(el.dataset.id);
renderBreadcrumb();
renderTree();
});
});
}
function buildPath(dirId) {
if (dirId === null) return [];
const result = [];
let current = dirId;
const seen = new Set();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const meta = dirMeta[current];
if (!meta) break;
result.unshift({ id: meta.id, name: meta.name });
current = meta.parentId;
}
return result;
}
// ===== Tree Rendering =====
function renderTree() {
const list = document.getElementById('browser-list');
const root = dirContents['root'];
if (!root) {
list.innerHTML = '
Loading...
';
return;
}
let html = buildTreeHtml(root.dirs, root.tracks, 0);
if (!html.trim()) {
html = 'No files here. Upload a GPX or create a folder.
';
}
list.innerHTML = html;
bindEvents(list);
}
function buildTreeHtml(dirs, tracks, depth) {
let html = '';
const baseIndent = depth * 16;
for (const dir of dirs) {
const isExpanded = expandedDirs.has(dir.id);
const isSelected = selectedDirId === dir.id;
html += `
π
${escHtml(dir.name)}
${formatDate(dir.updatedAt)}
`;
if (isExpanded) {
const cached = dirContents[dir.id];
if (cached) {
html += buildTreeHtml(cached.dirs, cached.tracks, depth + 1);
} else {
html += `Loading...
`;
}
}
}
for (const track of tracks) {
const dist = track.totalDistance ? formatDistance(track.totalDistance) : '';
html += `
πΊοΈ
${escHtml(track.name)}
${dist}
${formatDate(track.trackDate || track.uploadDate)}
`;
}
return html;
}
// ===== Event Binding =====
function bindEvents(container) {
// Expand/collapse triangle
container.querySelectorAll('.expand-btn[data-expand]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await toggleExpand(parseInt(btn.dataset.expand));
});
});
// Dir click β select + zoom map to folder tracks
container.querySelectorAll('.dir-item').forEach(el => {
el.addEventListener('click', async (e) => {
if (e.target.closest('.item-actions') || e.target.classList.contains('expand-btn')) return;
await selectDir(parseInt(el.dataset.id));
});
});
container.querySelectorAll('.rename-dir-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
renameDir(parseInt(btn.dataset.id), btn.dataset.name);
});
});
container.querySelectorAll('.delete-dir-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
confirmDelete('directory', btn.dataset.name, () => deleteDir(parseInt(btn.dataset.id)));
});
});
// Track click β open on map; hover β highlight on map
container.querySelectorAll('.track-item').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.closest('.item-actions')) return;
openTrack(parseInt(el.dataset.id));
});
el.addEventListener('mouseenter', () => MapView.highlightTrack(parseInt(el.dataset.id)));
el.addEventListener('mouseleave', () => MapView.unhighlightTrack());
});
container.querySelectorAll('.view-track-btn').forEach(btn => {
btn.addEventListener('click', (e) => { e.stopPropagation(); openTrack(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.share-track-btn').forEach(btn => {
btn.addEventListener('click', (e) => { e.stopPropagation(); shareTrack(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.move-track-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
showMoveDialog(parseInt(btn.dataset.id), btn.dataset.name);
});
});
container.querySelectorAll('.delete-track-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
confirmDelete('track', btn.dataset.name, () => deleteTrack(parseInt(btn.dataset.id)));
});
});
// Drag tracks
container.querySelectorAll('.track-item[draggable="true"]').forEach(el => {
el.addEventListener('dragstart', (e) => {
dragTrackId = parseInt(el.dataset.id);
dragTrackName = el.dataset.name;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(dragTrackId));
el.classList.add('dragging');
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
container.querySelectorAll('.drag-target').forEach(d => d.classList.remove('drag-target'));
dragTrackId = null;
dragTrackName = null;
});
});
// Drop targets (folders)
container.querySelectorAll('.dir-item[data-drop-target]').forEach(el => {
el._dragCount = 0;
el.addEventListener('dragenter', (e) => {
if (dragTrackId === null) return;
e.preventDefault();
el._dragCount = (el._dragCount || 0) + 1;
el.classList.add('drag-target');
});
el.addEventListener('dragleave', () => {
if (dragTrackId === null) return;
el._dragCount = (el._dragCount || 1) - 1;
if (el._dragCount <= 0) {
el._dragCount = 0;
el.classList.remove('drag-target');
}
});
el.addEventListener('dragover', (e) => {
if (dragTrackId === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
el.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
el._dragCount = 0;
el.classList.remove('drag-target');
if (dragTrackId === null) return;
const targetDirId = parseInt(el.dataset.id);
const targetDirName = el.dataset.name;
const trackId = dragTrackId;
const trackName = dragTrackName;
dragTrackId = null;
dragTrackName = null;
confirmMoveTrack(trackId, trackName, targetDirId, targetDirName);
});
});
}
// ===== Expand / Select =====
async function toggleExpand(dirId) {
if (expandedDirs.has(dirId)) {
expandedDirs.delete(dirId);
renderTree();
} else {
expandedDirs.add(dirId);
if (!dirContents[dirId]) {
renderTree(); // show loading placeholder
try {
const data = await API.getDir(dirId);
dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
(data.children || []).forEach(d => {
dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
});
} catch (e) {
showToast('Error: ' + e.message, 'error');
expandedDirs.delete(dirId);
}
}
renderTree();
}
}
async function selectDir(dirId) {
selectedDirId = dirId;
renderBreadcrumb();
renderTree();
// Ensure contents loaded
if (!dirContents[dirId]) {
try {
const data = await API.getDir(dirId);
dirMeta[data.id] = { id: data.id, name: data.name, parentId: data.parentId };
dirContents[dirId] = { dirs: data.children || [], tracks: data.tracks || [] };
(data.children || []).forEach(d => {
dirMeta[d.id] = { id: d.id, name: d.name, parentId: dirId };
});
} catch (e) {
showToast('Error loading folder: ' + e.message, 'error');
return;
}
}
// Load all tracks in this folder onto map and zoom
const tracks = dirContents[dirId].tracks;
if (tracks.length === 0) return;
await Promise.all(tracks.map(async track => {
if (!MapView.hasTrack(track.id)) {
try {
const data = await API.getTrackPoints(track.id);
MapView.addTrack(data, track.id);
} catch (e) { /* ignore individual errors */ }
}
}));
MapView.fitAll();
}
// ===== Track Actions =====
async function openTrack(trackId) {
try {
if (MapView.hasTrack(trackId)) {
MapView.removeTrack(trackId);
MapView.setCurrentTrack(null);
document.getElementById('track-info-panel').style.display = 'none';
if (typeof Elevation !== 'undefined') Elevation.clear();
return;
}
const data = await API.getTrackPoints(trackId);
MapView.addTrack(data, trackId);
MapView.fitTrack(trackId);
MapView.setCurrentTrack(trackId);
showTrackInfo(data.meta);
if (typeof Elevation !== 'undefined') {
const pts = MapView.getTrackPoints(trackId);
if (pts) Elevation.setTrack(pts);
}
} catch (e) {
showToast('Error loading track: ' + e.message, 'error');
}
}
function showTrackInfo(meta) {
if (!meta) return;
const panel = document.getElementById('track-info-panel');
document.getElementById('track-info-content').innerHTML = `
${escHtml(meta.name || 'Track')}
${formatDistance(meta.totalDistance)}
${meta.pointCount || 0}
${meta.trackDate ? `${formatDate(meta.trackDate)}
` : ''}
`;
panel.style.display = 'block';
document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
}
async function deleteTrack(id) {
try {
MapView.removeTrack(id);
await API.deleteTrack(id);
await reload();
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
// ===== Dir Actions =====
async function createDirPrompt() {
const name = prompt('Folder name:');
if (!name || !name.trim()) return;
try {
await API.createDir(name.trim(), selectedDirId);
await reload();
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
async function renameDir(id, currentName) {
const name = prompt('New name:', currentName);
if (!name || !name.trim() || name.trim() === currentName) return;
try {
await API.renameDir(id, name.trim());
await reload();
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
async function deleteDir(id) {
try {
await API.deleteDir(id);
if (selectedDirId === id) selectedDirId = null;
expandedDirs.delete(id);
await reload();
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
// ===== Upload =====
async function handleFileUpload(e) {
const files = Array.from(e.target.files);
if (files.length === 0) return;
e.target.value = '';
for (const file of files) {
showUploadToast(`Uploading ${file.name}...`);
try {
await API.uploadTrack(file, selectedDirId, null);
showToast(`Uploaded: ${file.name}`, 'success');
} catch (err) {
showToast(`Error uploading ${file.name}: ${err.message}`, 'error');
}
}
hideUploadToast();
await reload();
}
function setupDropZone() {
const mapContainer = document.getElementById('map-container');
mapContainer.addEventListener('dragover', (e) => {
// Only handle file drops (not track drags)
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
mapContainer.classList.add('drag-over');
}
});
mapContainer.addEventListener('dragleave', (e) => {
if (!mapContainer.contains(e.relatedTarget)) {
mapContainer.classList.remove('drag-over');
}
});
mapContainer.addEventListener('drop', async (e) => {
e.preventDefault();
mapContainer.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files).filter(f => f.name.toLowerCase().endsWith('.gpx'));
if (files.length === 0) return;
for (const file of files) {
showUploadToast(`Uploading ${file.name}...`);
try {
await API.uploadTrack(file, selectedDirId, null);
showToast(`Uploaded: ${file.name}`, 'success');
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
hideUploadToast();
await reload();
});
}
// ===== Move Track =====
function confirmMoveTrack(trackId, trackName, targetDirId, targetDirName) {
document.getElementById('confirm-title').textContent = 'Move Track';
document.getElementById('confirm-message').textContent =
`Move "${trackName}" to folder "${targetDirName}"?`;
const modal = document.getElementById('confirm-modal');
modal.style.display = 'flex';
document.getElementById('confirm-ok-btn').onclick = async () => {
modal.style.display = 'none';
try {
await API.updateTrack(trackId, { directoryId: targetDirId });
await reload();
showToast('Track moved', 'success');
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
};
}
async function showMoveDialog(trackId, trackName) {
try {
allDirs = await loadAllDirs();
const list = document.getElementById('move-dir-list');
let html = 'π (Root)
';
for (const dir of allDirs) {
html += `π ${escHtml(dir.path)}
`;
}
list.innerHTML = html;
let selectedMoveId = null;
list.querySelectorAll('.move-dir-item').forEach(el => {
el.addEventListener('click', () => {
list.querySelectorAll('.move-dir-item').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
selectedMoveId = el.dataset.id || null;
});
});
document.getElementById('move-confirm-btn').onclick = async () => {
try {
await API.updateTrack(trackId, { directoryId: selectedMoveId || null });
document.getElementById('move-modal').style.display = 'none';
await reload();
showToast('Track moved', 'success');
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
};
document.getElementById('move-modal').style.display = 'flex';
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
async function loadAllDirs(parentId, prefix) {
const result = [];
let dirs;
try {
if (parentId !== undefined && parentId !== null) {
const data = await API.getDir(parentId);
dirs = data.children || [];
} else {
dirs = await API.getDirs();
}
} catch (e) {
return result;
}
for (const d of dirs) {
const path = (prefix ? prefix + ' / ' : '') + d.name;
result.push({ id: d.id, name: d.name, path });
const sub = await loadAllDirs(d.id, path);
result.push(...sub);
}
return result;
}
// ===== Share =====
async function shareTrack(trackId) {
try {
const track = await API.getTrack(trackId);
const modal = document.getElementById('share-modal');
const content = document.getElementById('share-content');
const existingCode = track.ShareLink?.code || track.shareCode;
if (existingCode) {
const shareUrl = buildShareUrl(existingCode);
content.innerHTML = `
Share link:
`;
content.querySelector('#copy-share-btn').addEventListener('click', () => {
navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
document.getElementById('share-url-input').select();
document.execCommand('copy');
showToast('Copied!', 'success');
});
});
content.querySelector('#share-revoke-btn').addEventListener('click', async () => {
try {
await API.deleteShare(trackId);
modal.style.display = 'none';
showToast('Share link revoked', 'success');
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
});
} else {
content.innerHTML = `
No share link yet.
`;
content.querySelector('#share-create-btn').addEventListener('click', async () => {
try {
const res = await API.createShare(trackId);
const shareUrl = buildShareUrl(res.code);
content.innerHTML = `
Share link created:
`;
content.querySelector('#copy-share-new-btn').addEventListener('click', () => {
navigator.clipboard.writeText(shareUrl).then(() => showToast('Copied!', 'success')).catch(() => {
document.getElementById('share-url-input-new').select();
document.execCommand('copy');
showToast('Copied!', 'success');
});
});
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
});
}
modal.style.display = 'flex';
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
function buildShareUrl(code) {
const base = window.location.origin + window.location.pathname;
const appRoot = base.replace(/\/[^/]*$/, '/');
return appRoot + 'share/' + code;
}
// ===== Confirm Delete =====
function confirmDelete(type, name, onConfirm) {
document.getElementById('confirm-title').textContent = `Delete ${type}`;
document.getElementById('confirm-message').textContent = `Are you sure you want to delete "${name}"? This cannot be undone.`;
const modal = document.getElementById('confirm-modal');
modal.style.display = 'flex';
document.getElementById('confirm-ok-btn').onclick = () => {
modal.style.display = 'none';
onConfirm();
};
}
function getCurrentDirId() { return selectedDirId; }
function refresh() { return reload(); }
return { init, getCurrentDirId, refresh };
})();