This commit is contained in:
325
frontend/app.js
325
frontend/app.js
@@ -370,6 +370,17 @@ function renderKMLFiles(listId, files, isOwnFiles) {
|
||||
const uniqueId = `${listId}-${index}`;
|
||||
|
||||
try {
|
||||
// User Profile Link (new)
|
||||
if (!isOwnFiles) {
|
||||
const userBtn = document.getElementById(`user-${uniqueId}`);
|
||||
if (userBtn) {
|
||||
userBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openUserProfile(file.user_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Open Details button
|
||||
const openBtn = document.getElementById(`open-${uniqueId}`);
|
||||
if (openBtn) {
|
||||
@@ -426,7 +437,7 @@ function createKMLFileHTML(file, isOwnFiles, listId, index) {
|
||||
<div class="kml-file-name">${escapeHtml(file.filename)}</div>
|
||||
<div class="kml-file-meta">
|
||||
<span>📏 ${file.distance.toFixed(2)} km</span>
|
||||
${!isOwnFiles ? `<span>👤 ${escapeHtml(file.display_name)}</span>` : ''}
|
||||
${!isOwnFiles ? `<span id="user-${uniqueId}" class="clickable-author" title="View Profile" style="cursor: pointer; text-decoration: underline;">👤 ${escapeHtml(file.display_name)}</span>` : ''}
|
||||
<span>${file.is_public ? '🌍 Public' : '🔒 Private'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,12 +589,22 @@ function setupKMLDetailsListeners() {
|
||||
|
||||
function closeKMLDetails() {
|
||||
document.getElementById('kml-details-overlay').classList.remove('active');
|
||||
|
||||
// Restore logic: if Profile is open, go back to profile URL, else Home
|
||||
if (document.getElementById('user-profile-overlay').classList.contains('active') && currentProfileUserId) {
|
||||
updateURLState('profile', { userId: currentProfileUserId });
|
||||
} else {
|
||||
updateURLState('home', null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openKMLDetails(file) {
|
||||
currentKmlFile = file;
|
||||
const overlay = document.getElementById('kml-details-overlay');
|
||||
|
||||
// Update URL
|
||||
updateURLState('kml', { ownerId: file.user_id, filename: file.filename });
|
||||
|
||||
// Reset UI
|
||||
document.getElementById('kmlDetailsTitle').textContent = file.filename;
|
||||
document.getElementById('kmlDetailsDistance').textContent = `📏 ${file.distance.toFixed(2)} km`;
|
||||
@@ -1125,6 +1146,13 @@ function startFromLocation(locationLatLng, address) {
|
||||
// Start camera animation loop
|
||||
startCameraAnimation();
|
||||
|
||||
// Initialize User Profile Listeners
|
||||
setupUserProfileListeners();
|
||||
setupKMLDetailsListeners();
|
||||
|
||||
// Check for Deep Links
|
||||
restoreStateFromURL();
|
||||
|
||||
console.log('Street View initialized at:', address);
|
||||
}
|
||||
|
||||
@@ -1454,30 +1482,222 @@ function startCameraAnimation() {
|
||||
idleAnimationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function stopAnimation() {
|
||||
if (idleAnimationId) {
|
||||
cancelAnimationFrame(idleAnimationId);
|
||||
idleAnimationId = null;
|
||||
}
|
||||
currentPathIndex = 0;
|
||||
cameraTime = 0; // Reset camera animation
|
||||
lastFrameTime = null; // Reset frame time tracking
|
||||
const animProgress = document.getElementById('animProgress');
|
||||
if (animProgress) {
|
||||
animProgress.textContent = '0%';
|
||||
// ========================================
|
||||
// User Profile Logic
|
||||
// ========================================
|
||||
|
||||
async function openUserProfile(userId) {
|
||||
if (!userId) return;
|
||||
|
||||
const overlay = document.getElementById('user-profile-overlay');
|
||||
|
||||
// Reset UI
|
||||
document.getElementById('profileDisplayName').textContent = 'Loading...';
|
||||
document.getElementById('profileFitbitID').textContent = '@...';
|
||||
document.getElementById('profileAvatar').src = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png';
|
||||
document.getElementById('profilePublicTripsList').innerHTML = '<p class="empty-message">Loading...</p>';
|
||||
|
||||
overlay.classList.add('active');
|
||||
|
||||
// Update URL
|
||||
updateURLState('profile', { userId: userId });
|
||||
|
||||
try {
|
||||
// 1. Fetch User Info
|
||||
const userResp = await fetch(`/api/user/profile?user_id=${userId}`);
|
||||
if (!userResp.ok) throw new Error('User not found');
|
||||
const user = await userResp.json();
|
||||
|
||||
document.getElementById('profileDisplayName').textContent = user.display_name || 'Unknown User';
|
||||
document.getElementById('profileFitbitID').textContent = `@${user.fitbit_user_id}`;
|
||||
document.getElementById('profileAvatar').src = user.avatar_url || 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png';
|
||||
|
||||
// 2. Fetch Public KMLs
|
||||
currentProfileUserId = userId;
|
||||
currentProfilePage = 1;
|
||||
loadProfilePublicFiles();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading profile:", err);
|
||||
document.getElementById('profileDisplayName').textContent = 'Error';
|
||||
document.getElementById('profilePublicTripsList').innerHTML = '<p class="empty-message">Failed to load user profile.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function cycleViewMode() {
|
||||
// Don't cycle if no route is set yet
|
||||
if (!panorama || !masterPath || masterPath.length === 0) {
|
||||
console.log('Cannot cycle view mode: no route set');
|
||||
return;
|
||||
let currentProfileUserId = null;
|
||||
let currentProfilePage = 1;
|
||||
|
||||
async function loadProfilePublicFiles() {
|
||||
if (!currentProfileUserId) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
public_page: currentProfilePage,
|
||||
public_limit: 10,
|
||||
public_sort_by: 'date',
|
||||
target_user_id: currentProfileUserId
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/kml/list?${params.toString()}`);
|
||||
if (!response.ok) throw new Error('Failed to load walks');
|
||||
const data = await response.json();
|
||||
|
||||
// Render Public Files
|
||||
const listEl = document.getElementById('profilePublicTripsList');
|
||||
if (data.public_files && data.public_files.length > 0) {
|
||||
// We reuse renderKMLFiles logic but targeting a specific list
|
||||
// Note: renderKMLFiles attaches listeners based on listId pattern
|
||||
// kml-file-item needs to be generated.
|
||||
// Let's manually reuse the generator string for simplicity or refactor?
|
||||
// createKMLFileHTML is available.
|
||||
|
||||
listEl.innerHTML = data.public_files.map((file, index) =>
|
||||
createKMLFileHTML(file, false, 'profilePublicTripsList', index)
|
||||
).join('');
|
||||
|
||||
// Attach listeners manually effectively
|
||||
data.public_files.forEach((file, index) => {
|
||||
const uniqueId = `profilePublicTripsList-${index}`;
|
||||
|
||||
// Open Details
|
||||
const openBtn = document.getElementById(`open-${uniqueId}`);
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
openKMLDetails(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Vote buttons
|
||||
// Note: Voting on other people's files is allowed
|
||||
const upvoteBtn = document.getElementById(`upvote-${uniqueId}`);
|
||||
const downvoteBtn = document.getElementById(`downvote-${uniqueId}`);
|
||||
if (upvoteBtn) upvoteBtn.addEventListener('click', () => handleVote(file, 1));
|
||||
if (downvoteBtn) downvoteBtn.addEventListener('click', () => handleVote(file, -1));
|
||||
});
|
||||
|
||||
} else {
|
||||
listEl.innerHTML = '<p class="empty-message">No public walks shared yet.</p>';
|
||||
}
|
||||
|
||||
// Update Pagination
|
||||
document.getElementById('profilePageNum').textContent = data.public_page;
|
||||
document.getElementById('prevProfilePage').disabled = data.public_page <= 1;
|
||||
document.getElementById('nextProfilePage').disabled = (data.public_page * 10) >= data.public_total;
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading profile walks:", err);
|
||||
document.getElementById('profilePublicTripsList').innerHTML = '<p class="empty-message">Error loading walks.</p>';
|
||||
}
|
||||
viewMode = (viewMode + 1) % 3;
|
||||
updateViewMode();
|
||||
}
|
||||
|
||||
function setupUserProfileListeners() {
|
||||
document.getElementById('closeUserProfile').addEventListener('click', () => {
|
||||
document.getElementById('user-profile-overlay').classList.remove('active');
|
||||
updateURLState('home', null);
|
||||
});
|
||||
|
||||
document.getElementById('prevProfilePage').addEventListener('click', () => {
|
||||
if (currentProfilePage > 1) {
|
||||
currentProfilePage--;
|
||||
loadProfilePublicFiles();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('nextProfilePage').addEventListener('click', () => {
|
||||
currentProfilePage++;
|
||||
loadProfilePublicFiles();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// Deep Linking & URL State
|
||||
// ========================================
|
||||
|
||||
function updateURLState(type, data) {
|
||||
const url = new URL(window.location);
|
||||
|
||||
// Clear existing params to keep URL clean, or merge?
|
||||
// Let's clear relevant ones.
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('kml_owner');
|
||||
url.searchParams.delete('kml_file');
|
||||
|
||||
if (type === 'profile') {
|
||||
url.searchParams.set('profile', data.userId);
|
||||
} else if (type === 'kml') {
|
||||
url.searchParams.set('kml_owner', data.ownerId);
|
||||
url.searchParams.set('kml_file', data.filename);
|
||||
}
|
||||
|
||||
// Push state
|
||||
window.history.pushState({ type, data }, '', url);
|
||||
}
|
||||
|
||||
async function restoreStateFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileId = params.get('profile');
|
||||
const kmlOwner = params.get('kml_owner');
|
||||
const kmlFile = params.get('kml_file');
|
||||
|
||||
if (profileId) {
|
||||
openUserProfile(profileId);
|
||||
} else if (kmlOwner && kmlFile) {
|
||||
// We need to fetch the file metadata to open details
|
||||
// Or we can fetch download URL directly? openKMLDetails expects a file object.
|
||||
// Let's construct a minimal file object and try to open details
|
||||
// Note: details overlay might need more info (votes etc), so maybe we should fetch metadata?
|
||||
// We don't have a direct metadata endpoint by filename + owner.
|
||||
// We can search the list? Or add an endpoint.
|
||||
// For now, let's try to find it in the public/my list logic? No that's inefficient.
|
||||
// Let's just create a dummy object with enough info to fetch the KML content
|
||||
// Details overlay needs: filename, user_id, display_name, distance, votes, description.
|
||||
// We can fetch details via download? No, download is just the KML XML.
|
||||
|
||||
// Strategy: Open details with skeletons, then (optional) fetch fresh metadata if we had an endpoint.
|
||||
// Since we don't have a single-KML-metadata endpoint, we might miss some stats like votes count.
|
||||
// But we can enable the functionality to view the Route.
|
||||
|
||||
// Better: Just fetch the KML download, parse it, and show it.
|
||||
// We might show "Loading stats..." for metadata we don't have.
|
||||
|
||||
// Actually, let's simulate a file object with what we have.
|
||||
const dummyFile = {
|
||||
filename: kmlFile,
|
||||
user_id: kmlOwner,
|
||||
display_name: 'Unknown (Deep Link)', // We don't know the name yet
|
||||
distance: 0, // Will be calculated from KML
|
||||
votes: '?',
|
||||
description: 'Loaded via link.',
|
||||
is_public: true // Assumed
|
||||
};
|
||||
|
||||
openKMLDetails(dummyFile);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', (event) => {
|
||||
// Handle back button
|
||||
if (event.state) {
|
||||
const { type, data } = event.state;
|
||||
if (type === 'profile') {
|
||||
openUserProfile(data.userId);
|
||||
} else if (type === 'kml') {
|
||||
// For KML deep links via back button
|
||||
// logic similar to restore
|
||||
restoreStateFromURL();
|
||||
} else {
|
||||
// Home/Default
|
||||
document.querySelectorAll('.overlay').forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
} else {
|
||||
// Initial state or cleared state
|
||||
// Re-check URL?
|
||||
restoreStateFromURL();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function updateViewMode() {
|
||||
const panoDiv = document.getElementById('panorama');
|
||||
const mainMapDiv = document.getElementById('main-map');
|
||||
@@ -1488,17 +1708,20 @@ function updateViewMode() {
|
||||
// Switching TO Street View (from Satellite/Map)
|
||||
// Transition: Animate Main Map shrinking into Minimap
|
||||
|
||||
// Show minimap immediately so we have a target
|
||||
minimapContainer.classList.remove('hidden-mode');
|
||||
|
||||
animateMapToMinimap(() => {
|
||||
// Show SV and Minimap, Hide Main Map
|
||||
panoDiv.classList.remove('hidden-mode');
|
||||
minimapContainer.classList.remove('hidden-mode');
|
||||
|
||||
// Fix Black Screen: Trigger resize when pano becomes visible
|
||||
google.maps.event.trigger(panorama, 'resize');
|
||||
|
||||
mainMapDiv.classList.add('hidden-mode');
|
||||
// Ensure styles are cleaned up
|
||||
mainMapDiv.style.top = '';
|
||||
mainMapDiv.style.left = '';
|
||||
mainMapDiv.style.width = '';
|
||||
mainMapDiv.style.height = '';
|
||||
mainMapDiv.style.borderRadius = '';
|
||||
|
||||
// Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean
|
||||
@@ -1519,24 +1742,23 @@ function updateViewMode() {
|
||||
|
||||
// Set Map Type
|
||||
const targetType = (viewMode === 1) ? google.maps.MapTypeId.ROADMAP : google.maps.MapTypeId.HYBRID;
|
||||
// Optimization: Only set map type if visible or strictly needed to avoid pre-load flash?
|
||||
// If we adjust the order, we can set type AFTER making it visible? No, that causes visual switch.
|
||||
// We set it here. The key is resetting it when LEAVING mode 2 (above).
|
||||
mainMap.setMapTypeId(targetType);
|
||||
|
||||
if (mainMapDiv.classList.contains('hidden-mode')) {
|
||||
// Switching FROM Street View TO Map
|
||||
// Transition: Animate Main Map expanding from Minimap
|
||||
|
||||
// Ensure Street View is hidden AFTER animation or immediately?
|
||||
// Better to hide SV immediately so we see map expanding over black/bg?
|
||||
// Or keep SV visible behind?
|
||||
// Let's keep SV visible for a moment? No, it might look glitchy.
|
||||
// Let's hide SV immediately to indicate mode switch, then expand map.
|
||||
// Hide SV immediately for clean transition? Or keep behind?
|
||||
panoDiv.classList.add('hidden-mode');
|
||||
|
||||
animateMapToFullscreen(() => {
|
||||
minimapContainer.classList.add('hidden-mode');
|
||||
// Ensure map is interactive
|
||||
google.maps.event.trigger(mainMap, "resize");
|
||||
});
|
||||
} else {
|
||||
// Just switching Map <-> Satellite (No animation needed, handled by map type change)
|
||||
// Just switching Map <-> Satellite (No animation needed)
|
||||
panoDiv.classList.add('hidden-mode');
|
||||
mainMapDiv.classList.remove('hidden-mode');
|
||||
minimapContainer.classList.add('hidden-mode');
|
||||
@@ -1558,24 +1780,28 @@ function animateMapToFullscreen(onComplete) {
|
||||
const minimapContainer = document.getElementById('minimap-container');
|
||||
|
||||
// 1. Measure Start State (Minimap)
|
||||
// Minimap might be hidden, so we need to measure where it WOULD be or force it visible for a split second?
|
||||
// It should be visible in Street View mode.
|
||||
const rect = minimapContainer.getBoundingClientRect();
|
||||
|
||||
// 2. Set Initial State on Main Map (Match Minimap)
|
||||
mainMapDiv.style.transition = 'none'; // Disable transition for setup
|
||||
mainMapDiv.style.transition = 'none';
|
||||
mainMapDiv.style.top = rect.top + 'px';
|
||||
mainMapDiv.style.left = rect.left + 'px';
|
||||
mainMapDiv.style.width = rect.width + 'px';
|
||||
mainMapDiv.style.height = rect.height + 'px';
|
||||
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
|
||||
|
||||
// Ensure it's above everything
|
||||
mainMapDiv.style.zIndex = '9999';
|
||||
|
||||
mainMapDiv.classList.remove('hidden-mode');
|
||||
|
||||
// 3. Force Reflow
|
||||
void mainMapDiv.offsetHeight;
|
||||
|
||||
// 4. Set Target State (Fullscreen)
|
||||
// Re-enable transition defined in CSS
|
||||
mainMapDiv.style.transition = '';
|
||||
mainMapDiv.style.transition = ''; // Restore CSS transition
|
||||
|
||||
mainMapDiv.style.top = '0';
|
||||
mainMapDiv.style.left = '0';
|
||||
@@ -1583,26 +1809,49 @@ function animateMapToFullscreen(onComplete) {
|
||||
mainMapDiv.style.height = '100%';
|
||||
mainMapDiv.style.borderRadius = '0';
|
||||
|
||||
// 5. Cleanup on End
|
||||
// 5. Cleanup
|
||||
const handleTransitionEnd = () => {
|
||||
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
|
||||
// Clear manual styles so CSS classes take over
|
||||
mainMapDiv.style.top = '';
|
||||
mainMapDiv.style.left = '';
|
||||
mainMapDiv.style.width = '';
|
||||
mainMapDiv.style.height = '';
|
||||
mainMapDiv.style.borderRadius = '';
|
||||
mainMapDiv.style.zIndex = '';
|
||||
google.maps.event.trigger(mainMap, "resize");
|
||||
if (onComplete) onComplete();
|
||||
};
|
||||
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
|
||||
}
|
||||
|
||||
function animateMapToMinimap(onComplete) {
|
||||
const mainMapDiv = document.getElementById('main-map');
|
||||
const minimapContainer = document.getElementById('minimap-container');
|
||||
|
||||
// Ensure Minimap is logically visible so we can measure it (though visually we might hide it under map)
|
||||
minimapContainer.classList.remove('hidden-mode');
|
||||
const rect = minimapContainer.getBoundingClientRect();
|
||||
|
||||
function animateMapToMinimap(callback) {
|
||||
// No complex animation for now, just direct switch
|
||||
callback();
|
||||
// 1. Start State: Fullscreen (already is)
|
||||
mainMapDiv.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
mainMapDiv.style.zIndex = '9999'; // Stay on top
|
||||
|
||||
// 2. Target State: Match Minimap position
|
||||
// We need to set these explicitly to trigger transition
|
||||
mainMapDiv.style.top = rect.top + 'px';
|
||||
mainMapDiv.style.left = rect.left + 'px';
|
||||
mainMapDiv.style.width = rect.width + 'px';
|
||||
mainMapDiv.style.height = rect.height + 'px';
|
||||
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
|
||||
|
||||
// 3. Cleanup after animation
|
||||
const handleTransitionEnd = () => {
|
||||
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
|
||||
mainMapDiv.style.transition = '';
|
||||
mainMapDiv.style.zIndex = '';
|
||||
if (onComplete) onComplete();
|
||||
};
|
||||
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user