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

View File

@@ -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>

View File

@@ -977,3 +977,77 @@ body {
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;
}
}

View File

@@ -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).
// 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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)
}

View File

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

View File

@@ -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(`