user profiles
All checks were successful
pedestrian-simulator / build (push) Successful in 1m17s

This commit is contained in:
2026-01-13 13:10:55 -07:00
parent f985d3433d
commit 72b94597ca
6 changed files with 478 additions and 45 deletions

View File

@@ -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);
}

View File

@@ -252,6 +252,31 @@
</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>
<script src="app.js"></script>

View File

@@ -976,4 +976,78 @@ body {
border-right: none;
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;
}
}