From 72b94597cab38bcd2ee2f3a9bc36012d3d0ff165 Mon Sep 17 00:00:00 2001 From: Steven Polley Date: Tue, 13 Jan 2026 13:10:55 -0700 Subject: [PATCH] user profiles --- frontend/app.js | 325 ++++++++++++++++++++++++++++++++++++++------ frontend/index.html | 25 ++++ frontend/style.css | 74 ++++++++++ server/kml.go | 79 ++++++++++- server/main.go | 5 +- server/user.go | 15 ++ 6 files changed, 478 insertions(+), 45 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index b9424b3..9ab857b 100644 --- a/frontend/app.js +++ b/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) {
${escapeHtml(file.filename)}
📏 ${file.distance.toFixed(2)} km - ${!isOwnFiles ? `👤 ${escapeHtml(file.display_name)}` : ''} + ${!isOwnFiles ? `👤 ${escapeHtml(file.display_name)}` : ''} ${file.is_public ? '🌍 Public' : '🔒 Private'}
@@ -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 = '

Loading...

'; + + 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 = '

Failed to load user profile.

'; } } -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 = '

No public walks shared yet.

'; + } + + // 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 = '

Error loading walks.

'; } - 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); } diff --git a/frontend/index.html b/frontend/index.html index 8d41195..ec9834b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -252,6 +252,31 @@ + +
+ +
diff --git a/frontend/style.css b/frontend/style.css index 9ee867d..9f3d99d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; + } } \ No newline at end of file diff --git a/server/kml.go b/server/kml.go index 22e4da1..1934e74 100644 --- a/server/kml.go +++ b/server/kml.go @@ -287,16 +287,41 @@ func HandleKMLList(w http.ResponseWriter, r *http.Request) { if publicSortBy == "" { publicSortBy = "votes" } + targetUserID := r.URL.Query().Get("target_user_id") - // 1. Get My Files - myFiles, myTotal, err := fetchKMLList(userID, true, myPage, myLimit, mySortBy) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + // 1. Get My Files (if no specific target user is requested, or if target is me) + // logic: If targetUserID is present and != userID, we don't show "My Files" (which implies private/editable). + // But the UI might expect the "My Files" block to just be empty. + // Actually, the prompt implies "My Files" is the logged-in user's files. + // 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 - 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 { http.Error(w, err.Error(), http.StatusInternalServerError) 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) { + // 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 var whereClause string var args []interface{} + if mineOnly { whereClause = "WHERE m.user_id = ?" args = append(args, userID) } else { whereClause = "WHERE m.is_public = 1" + if targetUserID != "" { + whereClause += " AND m.user_id = ?" + args = append(args, targetUserID) + } } // 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.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) +} diff --git a/server/main.go b/server/main.go index 4e85db9..e19af70 100644 --- a/server/main.go +++ b/server/main.go @@ -137,7 +137,10 @@ func main() { 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" fmt.Printf("Server starting on http://%s\n", binding) log.Fatal(http.ListenAndServe(binding, RecoveryMiddleware(http.DefaultServeMux))) diff --git a/server/user.go b/server/user.go index 17222c3..a79e680 100644 --- a/server/user.go +++ b/server/user.go @@ -30,6 +30,21 @@ func GetUser(fitbitUserID string) (*User, error) { 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 func CreateOrUpdateUser(fitbitUserID, displayName, avatarURL string) (*User, error) { _, err := db.Exec(`