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}`;
|
const uniqueId = `${listId}-${index}`;
|
||||||
|
|
||||||
try {
|
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
|
// Open Details button
|
||||||
const openBtn = document.getElementById(`open-${uniqueId}`);
|
const openBtn = document.getElementById(`open-${uniqueId}`);
|
||||||
if (openBtn) {
|
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-name">${escapeHtml(file.filename)}</div>
|
||||||
<div class="kml-file-meta">
|
<div class="kml-file-meta">
|
||||||
<span>📏 ${file.distance.toFixed(2)} km</span>
|
<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>
|
<span>${file.is_public ? '🌍 Public' : '🔒 Private'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -578,12 +589,22 @@ function setupKMLDetailsListeners() {
|
|||||||
|
|
||||||
function closeKMLDetails() {
|
function closeKMLDetails() {
|
||||||
document.getElementById('kml-details-overlay').classList.remove('active');
|
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) {
|
async function openKMLDetails(file) {
|
||||||
currentKmlFile = file;
|
currentKmlFile = file;
|
||||||
const overlay = document.getElementById('kml-details-overlay');
|
const overlay = document.getElementById('kml-details-overlay');
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
updateURLState('kml', { ownerId: file.user_id, filename: file.filename });
|
||||||
|
|
||||||
// Reset UI
|
// Reset UI
|
||||||
document.getElementById('kmlDetailsTitle').textContent = file.filename;
|
document.getElementById('kmlDetailsTitle').textContent = file.filename;
|
||||||
document.getElementById('kmlDetailsDistance').textContent = `📏 ${file.distance.toFixed(2)} km`;
|
document.getElementById('kmlDetailsDistance').textContent = `📏 ${file.distance.toFixed(2)} km`;
|
||||||
@@ -1125,6 +1146,13 @@ function startFromLocation(locationLatLng, address) {
|
|||||||
// Start camera animation loop
|
// Start camera animation loop
|
||||||
startCameraAnimation();
|
startCameraAnimation();
|
||||||
|
|
||||||
|
// Initialize User Profile Listeners
|
||||||
|
setupUserProfileListeners();
|
||||||
|
setupKMLDetailsListeners();
|
||||||
|
|
||||||
|
// Check for Deep Links
|
||||||
|
restoreStateFromURL();
|
||||||
|
|
||||||
console.log('Street View initialized at:', address);
|
console.log('Street View initialized at:', address);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,30 +1482,222 @@ function startCameraAnimation() {
|
|||||||
idleAnimationId = requestAnimationFrame(animate);
|
idleAnimationId = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAnimation() {
|
// ========================================
|
||||||
if (idleAnimationId) {
|
// User Profile Logic
|
||||||
cancelAnimationFrame(idleAnimationId);
|
// ========================================
|
||||||
idleAnimationId = null;
|
|
||||||
}
|
async function openUserProfile(userId) {
|
||||||
currentPathIndex = 0;
|
if (!userId) return;
|
||||||
cameraTime = 0; // Reset camera animation
|
|
||||||
lastFrameTime = null; // Reset frame time tracking
|
const overlay = document.getElementById('user-profile-overlay');
|
||||||
const animProgress = document.getElementById('animProgress');
|
|
||||||
if (animProgress) {
|
// Reset UI
|
||||||
animProgress.textContent = '0%';
|
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() {
|
let currentProfileUserId = null;
|
||||||
// Don't cycle if no route is set yet
|
let currentProfilePage = 1;
|
||||||
if (!panorama || !masterPath || masterPath.length === 0) {
|
|
||||||
console.log('Cannot cycle view mode: no route set');
|
async function loadProfilePublicFiles() {
|
||||||
return;
|
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() {
|
function updateViewMode() {
|
||||||
const panoDiv = document.getElementById('panorama');
|
const panoDiv = document.getElementById('panorama');
|
||||||
const mainMapDiv = document.getElementById('main-map');
|
const mainMapDiv = document.getElementById('main-map');
|
||||||
@@ -1488,17 +1708,20 @@ function updateViewMode() {
|
|||||||
// Switching TO Street View (from Satellite/Map)
|
// Switching TO Street View (from Satellite/Map)
|
||||||
// Transition: Animate Main Map shrinking into Minimap
|
// Transition: Animate Main Map shrinking into Minimap
|
||||||
|
|
||||||
// Show minimap immediately so we have a target
|
|
||||||
minimapContainer.classList.remove('hidden-mode');
|
|
||||||
|
|
||||||
animateMapToMinimap(() => {
|
animateMapToMinimap(() => {
|
||||||
|
// Show SV and Minimap, Hide Main Map
|
||||||
panoDiv.classList.remove('hidden-mode');
|
panoDiv.classList.remove('hidden-mode');
|
||||||
|
minimapContainer.classList.remove('hidden-mode');
|
||||||
|
|
||||||
// Fix Black Screen: Trigger resize when pano becomes visible
|
// Fix Black Screen: Trigger resize when pano becomes visible
|
||||||
google.maps.event.trigger(panorama, 'resize');
|
google.maps.event.trigger(panorama, 'resize');
|
||||||
|
|
||||||
mainMapDiv.classList.add('hidden-mode');
|
mainMapDiv.classList.add('hidden-mode');
|
||||||
// Ensure styles are cleaned up
|
// Ensure styles are cleaned up
|
||||||
|
mainMapDiv.style.top = '';
|
||||||
|
mainMapDiv.style.left = '';
|
||||||
|
mainMapDiv.style.width = '';
|
||||||
|
mainMapDiv.style.height = '';
|
||||||
mainMapDiv.style.borderRadius = '';
|
mainMapDiv.style.borderRadius = '';
|
||||||
|
|
||||||
// Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean
|
// Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean
|
||||||
@@ -1519,24 +1742,23 @@ function updateViewMode() {
|
|||||||
|
|
||||||
// Set Map Type
|
// Set Map Type
|
||||||
const targetType = (viewMode === 1) ? google.maps.MapTypeId.ROADMAP : google.maps.MapTypeId.HYBRID;
|
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);
|
mainMap.setMapTypeId(targetType);
|
||||||
|
|
||||||
if (mainMapDiv.classList.contains('hidden-mode')) {
|
if (mainMapDiv.classList.contains('hidden-mode')) {
|
||||||
// Switching FROM Street View TO Map
|
// Switching FROM Street View TO Map
|
||||||
// Transition: Animate Main Map expanding from Minimap
|
// Hide SV immediately for clean transition? Or keep behind?
|
||||||
|
|
||||||
// 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.
|
|
||||||
panoDiv.classList.add('hidden-mode');
|
panoDiv.classList.add('hidden-mode');
|
||||||
|
|
||||||
animateMapToFullscreen(() => {
|
animateMapToFullscreen(() => {
|
||||||
minimapContainer.classList.add('hidden-mode');
|
minimapContainer.classList.add('hidden-mode');
|
||||||
|
// Ensure map is interactive
|
||||||
|
google.maps.event.trigger(mainMap, "resize");
|
||||||
});
|
});
|
||||||
} else {
|
} 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');
|
panoDiv.classList.add('hidden-mode');
|
||||||
mainMapDiv.classList.remove('hidden-mode');
|
mainMapDiv.classList.remove('hidden-mode');
|
||||||
minimapContainer.classList.add('hidden-mode');
|
minimapContainer.classList.add('hidden-mode');
|
||||||
@@ -1558,24 +1780,28 @@ function animateMapToFullscreen(onComplete) {
|
|||||||
const minimapContainer = document.getElementById('minimap-container');
|
const minimapContainer = document.getElementById('minimap-container');
|
||||||
|
|
||||||
// 1. Measure Start State (Minimap)
|
// 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();
|
const rect = minimapContainer.getBoundingClientRect();
|
||||||
|
|
||||||
// 2. Set Initial State on Main Map (Match Minimap)
|
// 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.top = rect.top + 'px';
|
||||||
mainMapDiv.style.left = rect.left + 'px';
|
mainMapDiv.style.left = rect.left + 'px';
|
||||||
mainMapDiv.style.width = rect.width + 'px';
|
mainMapDiv.style.width = rect.width + 'px';
|
||||||
mainMapDiv.style.height = rect.height + 'px';
|
mainMapDiv.style.height = rect.height + 'px';
|
||||||
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
|
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
|
||||||
|
|
||||||
|
// Ensure it's above everything
|
||||||
|
mainMapDiv.style.zIndex = '9999';
|
||||||
|
|
||||||
mainMapDiv.classList.remove('hidden-mode');
|
mainMapDiv.classList.remove('hidden-mode');
|
||||||
|
|
||||||
// 3. Force Reflow
|
// 3. Force Reflow
|
||||||
void mainMapDiv.offsetHeight;
|
void mainMapDiv.offsetHeight;
|
||||||
|
|
||||||
// 4. Set Target State (Fullscreen)
|
// 4. Set Target State (Fullscreen)
|
||||||
// Re-enable transition defined in CSS
|
mainMapDiv.style.transition = ''; // Restore CSS transition
|
||||||
mainMapDiv.style.transition = '';
|
|
||||||
|
|
||||||
mainMapDiv.style.top = '0';
|
mainMapDiv.style.top = '0';
|
||||||
mainMapDiv.style.left = '0';
|
mainMapDiv.style.left = '0';
|
||||||
@@ -1583,26 +1809,49 @@ function animateMapToFullscreen(onComplete) {
|
|||||||
mainMapDiv.style.height = '100%';
|
mainMapDiv.style.height = '100%';
|
||||||
mainMapDiv.style.borderRadius = '0';
|
mainMapDiv.style.borderRadius = '0';
|
||||||
|
|
||||||
// 5. Cleanup on End
|
// 5. Cleanup
|
||||||
const handleTransitionEnd = () => {
|
const handleTransitionEnd = () => {
|
||||||
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
|
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
|
||||||
// Clear manual styles so CSS classes take over
|
|
||||||
mainMapDiv.style.top = '';
|
mainMapDiv.style.top = '';
|
||||||
mainMapDiv.style.left = '';
|
mainMapDiv.style.left = '';
|
||||||
mainMapDiv.style.width = '';
|
mainMapDiv.style.width = '';
|
||||||
mainMapDiv.style.height = '';
|
mainMapDiv.style.height = '';
|
||||||
mainMapDiv.style.borderRadius = '';
|
mainMapDiv.style.borderRadius = '';
|
||||||
|
mainMapDiv.style.zIndex = '';
|
||||||
google.maps.event.trigger(mainMap, "resize");
|
google.maps.event.trigger(mainMap, "resize");
|
||||||
if (onComplete) onComplete();
|
if (onComplete) onComplete();
|
||||||
};
|
};
|
||||||
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
|
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) {
|
// 1. Start State: Fullscreen (already is)
|
||||||
// No complex animation for now, just direct switch
|
mainMapDiv.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||||
callback();
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- User Profile Overlay -->
|
||||||
|
<div id="user-profile-overlay" class="overlay">
|
||||||
|
<div class="user-profile-card">
|
||||||
|
<div class="user-profile-header">
|
||||||
|
<button id="closeUserProfile" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="user-profile-info">
|
||||||
|
<img id="profileAvatar" class="profile-avatar-large" src="" alt="User" />
|
||||||
|
<h2 id="profileDisplayName">User Name</h2>
|
||||||
|
<p id="profileFitbitID" class="profile-fitbit-id">@username</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-profile-content">
|
||||||
|
<h3>Completed Walks (Public)</h3>
|
||||||
|
<div id="profilePublicTripsList" class="kml-file-list">
|
||||||
|
<p class="empty-message">No public walks shared yet.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls small">
|
||||||
|
<button id="prevProfilePage" class="action-btn" disabled>←</button>
|
||||||
|
<span id="profilePageNum">1</span>
|
||||||
|
<button id="nextProfilePage" class="action-btn">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@@ -976,4 +976,78 @@ body {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 2px solid var(--border);
|
border-bottom: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Profile Overlay */
|
||||||
|
.user-profile-card {
|
||||||
|
background: rgba(30, 41, 59, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-large {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #6366f1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-fitbit-id {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-content h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animating classes for Map Toggle */
|
||||||
|
.minimap-transitioning {
|
||||||
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
.kml-browser-card,
|
||||||
|
.kml-details-card,
|
||||||
|
.user-profile-card {
|
||||||
|
width: 95%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -287,16 +287,41 @@ func HandleKMLList(w http.ResponseWriter, r *http.Request) {
|
|||||||
if publicSortBy == "" {
|
if publicSortBy == "" {
|
||||||
publicSortBy = "votes"
|
publicSortBy = "votes"
|
||||||
}
|
}
|
||||||
|
targetUserID := r.URL.Query().Get("target_user_id")
|
||||||
|
|
||||||
// 1. Get My Files
|
// 1. Get My Files (if no specific target user is requested, or if target is me)
|
||||||
myFiles, myTotal, err := fetchKMLList(userID, true, myPage, myLimit, mySortBy)
|
// logic: If targetUserID is present and != userID, we don't show "My Files" (which implies private/editable).
|
||||||
if err != nil {
|
// But the UI might expect the "My Files" block to just be empty.
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
// Actually, the prompt implies "My Files" is the logged-in user's files.
|
||||||
return
|
// If we are viewing a profile, we just want that user's PUBLIC files.
|
||||||
|
// The current frontend calls this endpoint to populate the standard browser "My Files" and "Public Files" tabs.
|
||||||
|
// We should probably keep this behavior for the standard view.
|
||||||
|
|
||||||
|
// If it's a profile request, we might just be using the public list with a filter.
|
||||||
|
// Let's rely on the client to ask for what it wants.
|
||||||
|
// Existing client: Calls /list without target_user_id. Expects: My Files (all mine), Public Files (all public).
|
||||||
|
|
||||||
|
// New Client (Profile): Needs distinct endpoint or param?
|
||||||
|
// If we add `target_user_id` to the public files query, we can reuse this.
|
||||||
|
|
||||||
|
// 1. Get My Files (Logged in user's files)
|
||||||
|
var myFiles []KMLMetadata
|
||||||
|
var myTotal int
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if targetUserID == "" {
|
||||||
|
myFiles, myTotal, err = fetchKMLList(userID, true, myPage, myLimit, mySortBy)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get Public Files
|
// 2. Get Public Files
|
||||||
publicFiles, publicTotal, err := fetchKMLList(userID, false, publicPage, publicLimit, publicSortBy)
|
var publicFiles []KMLMetadata
|
||||||
|
var publicTotal int
|
||||||
|
|
||||||
|
publicFiles, publicTotal, err = fetchKMLListPublic(userID, targetUserID, publicPage, publicLimit, publicSortBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -315,15 +340,29 @@ func HandleKMLList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchKMLList(userID string, mineOnly bool, page, limit int, sortBy string) ([]KMLMetadata, int, error) {
|
func fetchKMLList(userID string, mineOnly bool, page, limit int, sortBy string) ([]KMLMetadata, int, error) {
|
||||||
|
// Standard fetch for current user or general public list (wrapper)
|
||||||
|
return fetchKMLQuery(userID, "", mineOnly, page, limit, sortBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKMLListPublic(requestingUserID, targetUserID string, page, limit int, sortBy string) ([]KMLMetadata, int, error) {
|
||||||
|
return fetchKMLQuery(requestingUserID, targetUserID, false, page, limit, sortBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKMLQuery(userID, targetUserID string, mineOnly bool, page, limit int, sortBy string) ([]KMLMetadata, int, error) {
|
||||||
offset := (page - 1) * limit
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
var whereClause string
|
var whereClause string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
if mineOnly {
|
if mineOnly {
|
||||||
whereClause = "WHERE m.user_id = ?"
|
whereClause = "WHERE m.user_id = ?"
|
||||||
args = append(args, userID)
|
args = append(args, userID)
|
||||||
} else {
|
} else {
|
||||||
whereClause = "WHERE m.is_public = 1"
|
whereClause = "WHERE m.is_public = 1"
|
||||||
|
if targetUserID != "" {
|
||||||
|
whereClause += " AND m.user_id = ?"
|
||||||
|
args = append(args, targetUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count first
|
// Get total count first
|
||||||
@@ -609,3 +648,31 @@ func HandleKMLDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleUserProfile serves public user profile data
|
||||||
|
func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Authentication optional? Yes, profiles should be public.
|
||||||
|
// But let's check auth just to be safe if we want to restrict it to logged-in users later.
|
||||||
|
// Current requirement: "implement profile pages... open the KML details overlay... so that you can do it too".
|
||||||
|
// Implies logged in users mostly, but let's allow it generally if the files are public.
|
||||||
|
|
||||||
|
// Get target user ID from URL
|
||||||
|
targetID := r.URL.Query().Get("user_id")
|
||||||
|
if targetID == "" {
|
||||||
|
http.Error(w, "Missing user_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := GetPublicUser(targetID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,10 @@ func main() {
|
|||||||
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. Start Server
|
// 7. User Profile Endpoint
|
||||||
|
http.HandleFunc("/api/user/profile", RequireAuth(HandleUserProfile))
|
||||||
|
|
||||||
|
// 8. Start Server
|
||||||
binding := "0.0.0.0:8080"
|
binding := "0.0.0.0:8080"
|
||||||
fmt.Printf("Server starting on http://%s\n", binding)
|
fmt.Printf("Server starting on http://%s\n", binding)
|
||||||
log.Fatal(http.ListenAndServe(binding, RecoveryMiddleware(http.DefaultServeMux)))
|
log.Fatal(http.ListenAndServe(binding, RecoveryMiddleware(http.DefaultServeMux)))
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ func GetUser(fitbitUserID string) (*User, error) {
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPublicUser retrieves public user info by Fitbit user ID
|
||||||
|
func GetPublicUser(fitbitUserID string) (*User, error) {
|
||||||
|
var user User
|
||||||
|
err := db.QueryRow("SELECT fitbit_user_id, display_name, avatar_url, created_at FROM users WHERE fitbit_user_id = ?", fitbitUserID).
|
||||||
|
Scan(&user.FitbitUserID, &user.DisplayName, &user.AvatarURL, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Ensure we don't return sensitive info if struct expands later, though current struct is safe for public
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateOrUpdateUser adds or updates a user in the database
|
// CreateOrUpdateUser adds or updates a user in the database
|
||||||
func CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) {
|
func CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) {
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
|
|||||||
Reference in New Issue
Block a user