Compare commits

..
10 Commits
3 changed files with 80 additions and 37 deletions

No files matched your search

+1
View File
@@ -11,5 +11,6 @@ window.STEPMATES_CONFIG = {
adminNotifyUrl: "https://adminnotify-mgc3i5pbxq-uc.a.run.app", adminNotifyUrl: "https://adminnotify-mgc3i5pbxq-uc.a.run.app",
sendMagicLinkUrl: "https://sendmagiclink-mgc3i5pbxq-uc.a.run.app", sendMagicLinkUrl: "https://sendmagiclink-mgc3i5pbxq-uc.a.run.app",
friendRequestUrl: "https://friendrequest-mgc3i5pbxq-uc.a.run.app", friendRequestUrl: "https://friendrequest-mgc3i5pbxq-uc.a.run.app",
acceptFriendUrl: "https://acceptfriendrequest-mgc3i5pbxq-uc.a.run.app",
appUrl: "https://justinoros.github.io/step-tracker" appUrl: "https://justinoros.github.io/step-tracker"
}; };
+76 -37
View File
@@ -529,10 +529,14 @@
<span style="color:var(--muted);font-size:14px">Display Name</span> <span style="color:var(--muted);font-size:14px">Display Name</span>
<span id="fp-name-text" onclick="openNicknameModal()" style="font-size:14px;font-weight:600;cursor:pointer;color:var(--teal);text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted">—</span> <span id="fp-name-text" onclick="openNicknameModal()" style="font-size:14px;font-weight:600;cursor:pointer;color:var(--teal);text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted">—</span>
</div> </div>
<div style="display:flex;justify-content:space-between;padding:14px 18px"> <div style="display:flex;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="color:var(--muted);font-size:14px">Date Joined</span> <span style="color:var(--muted);font-size:14px">Date Joined</span>
<span style="font-size:14px;font-weight:500" id="fp-joined">—</span> <span style="font-size:14px;font-weight:500" id="fp-joined">—</span>
</div> </div>
<div style="display:flex;justify-content:space-between;padding:14px 18px">
<span style="color:var(--muted);font-size:14px">Last Synced</span>
<span style="font-size:14px;font-weight:500" id="fp-last-synced">—</span>
</div>
</div> </div>
<div style="display:flex;gap:12px"> <div style="display:flex;gap:12px">
@@ -590,8 +594,12 @@
</div> </div>
<label for="profile-name-input">Display name</label> <label for="profile-name-input">Display name</label>
<input type="text" id="profile-name-input" placeholder="e.g. Alex or RunnerAlex" maxlength="30"> <input type="text" id="profile-name-input" placeholder="e.g. Alex or RunnerAlex" maxlength="30">
<label for="profile-avatar-input">Avatar image URL <span style="font-weight:400;text-transform:none;font-size:11px;color:var(--muted)">(optional)</span></label> <label>Avatar Emoji</label>
<input type="text" id="profile-avatar-input" placeholder="https://example.com/photo.jpg"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:4px">
<div id="emoji-preview" style="width:48px;height:48px;border-radius:50%;background:var(--surface-2);border:2px solid var(--teal);display:flex;align-items:center;justify-content:center;font-size:26px;flex-shrink:0">🏃</div>
<input type="text" id="profile-avatar-input" placeholder="Click here, then press the emoji key (⊞+. on Windows, Ctrl+Cmd+Space on Mac, or emoji keyboard on mobile)" style="flex:1;font-size:24px;text-align:center" oninput="updateEmojiPreview(this.value)">
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Mac: <strong>Ctrl+Cmd+Space</strong> · Windows: <strong>Win+.</strong> · Mobile: switch to emoji keyboard</div>
<div class="error-msg" id="profile-error" style="display:none"></div> <div class="error-msg" id="profile-error" style="display:none"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-ghost" onclick="closeProfileModal()">Cancel</button> <button class="btn btn-ghost" onclick="closeProfileModal()">Cancel</button>
@@ -716,12 +724,19 @@ function todayKey() {
function esc(str) { return (str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(str) { return (str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function isEmoji(str) {
if (!str) return false;
const cp = str.codePointAt(0);
return (cp >= 0x1F300 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0xFE00 && cp <= 0xFE0F);
}
function avatarHtml(profile, size=40, nickname='') { function avatarHtml(profile, size=40, nickname='') {
const displayName = profile?.displayName || 'No Display Name'; const displayName = profile?.displayName || 'No Display Name';
const initial = (nickname || displayName)[0].toUpperCase(); const initial = (nickname || displayName)[0].toUpperCase();
const url = profile?.avatarUrl || ''; const url = profile?.avatarUrl || '';
if (url) return `<div class="lb-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'"></div>`; if (url && isEmoji(url)) return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">${url}</div>`;
return `<div class="lb-avatar" style="width:${size}px;height:${size}px">${initial}</div>`; if (url && url.startsWith('http')) return `<div class="lb-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'"></div>`;
return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">🏃</div>`;
} }
function getFriendDisplayName(uid) { function getFriendDisplayName(uid) {
@@ -788,27 +803,23 @@ async function initUser(user) {
await setDoc(userRef, { await setDoc(userRef, {
email: user.email, email: user.email,
webhookToken, webhookToken,
approved: false, approved: true,
dailyGoal: 5000, dailyGoal: 5000,
createdAt: serverTimestamp() createdAt: serverTimestamp()
}); });
await setDoc(doc(db, 'profiles', user.uid), { displayName: '', avatarUrl: '' }); await setDoc(doc(db, 'profiles', user.uid), { displayName: user.displayName || '', avatarUrl: '🏃' });
await setDoc(doc(db, 'steps', user.uid), { stepsByDate: {} }); await setDoc(doc(db, 'steps', user.uid), { stepsByDate: {} });
currentUserData = { email: user.email, webhookToken, approved: false, dailyGoal: 5000 }; currentUserData = { email: user.email, webhookToken, approved: true, dailyGoal: 5000 };
await notifyAdminOfNewUser(user.email, user.uid);
showScreen('pending');
} else {
currentUserData = snap.data();
if (currentUserData.approved === false) {
showScreen('pending');
} else if (!currentUserData.setupComplete) {
const profileSnap = await getDoc(doc(db, 'profiles', user.uid)); const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {}; currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid)); currentStepsData = {};
currentStepsData = stepsSnap.exists() ? stepsSnap.data().stepsByDate || {} : {}; loadDashboard();
showScreen('onboarding');
document.getElementById('ob-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
} else { } else {
currentUserData = snap.data();
if (!currentUserData.setupComplete && currentUserData.approved === false) {
await updateDoc(userRef, { approved: true });
currentUserData.approved = true;
}
const profileSnap = await getDoc(doc(db, 'profiles', user.uid)); const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {}; currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid)); const stepsSnap = await getDoc(doc(db, 'steps', user.uid));
@@ -818,12 +829,9 @@ async function initUser(user) {
if (pendingCount > 0) switchPanel('friends'); if (pendingCount > 0) switchPanel('friends');
} }
} }
}
async function notifyAdminOfNewUser(email, uid) { async function notifyAdminOfNewUser(email, uid) {
try { // Admin approval removed - auto-approval enabled
await fetch(`${window.STEPMATES_CONFIG.adminNotifyUrl}?uid=${uid}&email=${encodeURIComponent(email)}`);
} catch(e) { console.warn('Admin notify failed', e); }
} }
// ONBOARDING // ONBOARDING
@@ -881,8 +889,9 @@ function refreshProfileUI() {
} }
function setAvatarEl(wrapEl, initialId, initial, url, size) { function setAvatarEl(wrapEl, initialId, initial, url, size) {
if (url) wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`; if (url && isEmoji(url)) wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">${url}</span>`;
else wrapEl.innerHTML = `<span id="${initialId}">${initial}</span>`; else if (url && url.startsWith('http')) wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'">`;
else wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">🏃</span>`;
} }
function checkSetupReminder() { function checkSetupReminder() {
@@ -940,10 +949,10 @@ function loadFriendsRealtime() {
friendSteps = {}; friendSteps = {};
snap.forEach(d => { friendsData[d.id] = d.data(); }); snap.forEach(d => { friendsData[d.id] = d.data(); });
const approvedUids = Object.entries(friendsData) const approvedUids = Object.entries(friendsData)
.filter(([uid, data]) => data.approved === true && data.enabled === true) .filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid); .map(([uid]) => uid);
const pendingUids = Object.entries(friendsData) const pendingUids = Object.entries(friendsData)
.filter(([uid, data]) => data.approved === false && data.enabled !== false) .filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid); .map(([uid]) => uid);
await loadFriendProfilesAndSteps(approvedUids); await loadFriendProfilesAndSteps(approvedUids);
for (const uid of pendingUids) { for (const uid of pendingUids) {
@@ -974,13 +983,13 @@ function loadFriendsRealtime() {
function getMutualFriendUids() { function getMutualFriendUids() {
return Object.entries(friendsData) return Object.entries(friendsData)
.filter(([uid, data]) => data.approved === true && data.enabled === true) .filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid); .map(([uid]) => uid);
} }
function getPendingFriendUids() { function getPendingFriendUids() {
return Object.entries(friendsData) return Object.entries(friendsData)
.filter(([uid, data]) => data.approved === false && data.enabled !== false) .filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid); .map(([uid]) => uid);
} }
@@ -1160,7 +1169,7 @@ window.bulkRemoveFriends = async function() {
async function doRemoveFriend(friendUid) { async function doRemoveFriend(friendUid) {
const idToken = await currentUser.getIdToken(); const idToken = await currentUser.getIdToken();
await fetch(`${window.STEPMATES_CONFIG.friendRequestUrl}?action=remove&fromUid=${currentUser.uid}&toUid=${friendUid}&idToken=${idToken}`); await fetch(`${window.STEPMATES_CONFIG.friendRequestUrl}?action=remove&fromUid=${currentUser.uid}&toUid=${friendUid}&idToken=${idToken}`);
if (friendsData[friendUid]) friendsData[friendUid].enabled = false; if (friendsData[friendUid]) friendsData[friendUid].status = 'removed';
delete friendProfiles[friendUid]; delete friendProfiles[friendUid];
delete friendSteps[friendUid]; delete friendSteps[friendUid];
renderLeaderboard(); renderLeaderboard();
@@ -1168,13 +1177,19 @@ async function doRemoveFriend(friendUid) {
} }
window.approveFriend = async function(uid) { window.approveFriend = async function(uid) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { approved: true }); try {
const idToken = await currentUser.getIdToken();
await fetch(`${STEPMATES_CONFIG.acceptFriendUrl}?fromUid=${currentUser.uid}&requesterUid=${uid}&idToken=${encodeURIComponent(idToken)}`);
await loadFriendProfilesAndSteps([uid]); await loadFriendProfilesAndSteps([uid]);
showToast('Friend added! 🎉', 'success'); showToast('Friend added! 🎉', 'success');
} catch(e) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'active' });
showToast('Friend added! 🎉', 'success');
}
} }
window.denyFriend = async function(uid) { window.denyFriend = async function(uid) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { enabled: false }); await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'removed' });
showToast('Request declined', ''); showToast('Request declined', '');
} }
@@ -1187,14 +1202,29 @@ window.openFriendProfile = async function(uid) {
const displayName = profile.displayName || 'No Display Name'; const displayName = profile.displayName || 'No Display Name';
const avatarEl = document.getElementById('fp-avatar'); const avatarEl = document.getElementById('fp-avatar');
if (profile.avatarUrl) avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`; if (profile.avatarUrl && isEmoji(profile.avatarUrl)) {
else avatarEl.textContent = (nick || displayName)[0].toUpperCase(); avatarEl.innerHTML = `<span style="font-size:32px">${profile.avatarUrl}</span>`;
} else if (profile.avatarUrl) {
avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
avatarEl.textContent = (nick || displayName)[0].toUpperCase();
}
document.getElementById('fp-name-text').textContent = nick || displayName; document.getElementById('fp-name-text').textContent = nick || displayName;
const addedAt = friendsData[uid]?.addedAt?.toDate?.(); const addedAt = friendsData[uid]?.addedAt?.toDate?.();
document.getElementById('fp-joined').textContent = addedAt ? addedAt.toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}) : '—'; document.getElementById('fp-joined').textContent = addedAt ? addedAt.toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}) : '—';
try {
const stepsDoc = await getDoc(doc(db, 'steps', uid));
const syncTs = stepsDoc.data()?.lastSynced?.toDate?.();
document.getElementById('fp-last-synced').textContent = syncTs
? syncTs.toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'})
: 'Never';
} catch(e) {
document.getElementById('fp-last-synced').textContent = 'Never';
}
showScreen('friend-profile'); showScreen('friend-profile');
} }
@@ -1333,7 +1363,8 @@ window.showAuthForm = function() {
// PROFILE MODAL // PROFILE MODAL
window.openProfileModal = function() { window.openProfileModal = function() {
document.getElementById('profile-name-input').value = currentProfileData?.displayName || ''; document.getElementById('profile-name-input').value = currentProfileData?.displayName || '';
document.getElementById('profile-avatar-input').value = currentProfileData?.avatarUrl || ''; const avatarUrl = currentProfileData?.avatarUrl || '';
document.getElementById('profile-avatar-input').value = isEmoji(avatarUrl) ? avatarUrl : '';
document.getElementById('profile-error').style.display = 'none'; document.getElementById('profile-error').style.display = 'none';
updateProfilePreview(); updateProfilePreview();
document.getElementById('profile-modal').classList.add('open'); document.getElementById('profile-modal').classList.add('open');
@@ -1341,14 +1372,22 @@ window.openProfileModal = function() {
} }
window.closeProfileModal = function() { document.getElementById('profile-modal').classList.remove('open'); } window.closeProfileModal = function() { document.getElementById('profile-modal').classList.remove('open'); }
window.closeProfileModalOutside = function(e) { if (e.target===document.getElementById('profile-modal')) closeProfileModal(); } window.closeProfileModalOutside = function(e) { if (e.target===document.getElementById('profile-modal')) closeProfileModal(); }
function updateEmojiPreview(val) {
const preview = document.getElementById('emoji-preview');
if (!preview) return;
const emoji = val.trim();
preview.textContent = emoji || '🏃';
}
function updateProfilePreview() { function updateProfilePreview() {
const name = document.getElementById('profile-name-input').value.trim() || 'Your Name'; const name = document.getElementById('profile-name-input').value.trim() || 'Your Name';
const url = document.getElementById('profile-avatar-input').value.trim(); const url = document.getElementById('profile-avatar-input').value.trim();
const initial = name[0].toUpperCase(); const initial = name[0].toUpperCase();
document.getElementById('profile-modal-name-preview').textContent = name; document.getElementById('profile-modal-name-preview').textContent = name;
const avatarEl = document.getElementById('profile-modal-avatar'); const avatarEl = document.getElementById('profile-modal-avatar');
if (url) avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`; if (url && isEmoji(url)) avatarEl.innerHTML = `<span style="font-size:28px">${url}</span>`;
else if (url) avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
else avatarEl.innerHTML = `<span id="profile-modal-initial">${initial}</span>`; else avatarEl.innerHTML = `<span id="profile-modal-initial">${initial}</span>`;
updateEmojiPreview(url);
} }
window.saveProfile = async function() { window.saveProfile = async function() {
const name = document.getElementById('profile-name-input').value.trim(); const name = document.getElementById('profile-name-input').value.trim();
@@ -1356,7 +1395,7 @@ window.saveProfile = async function() {
const errEl = document.getElementById('profile-error'); const errEl = document.getElementById('profile-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
if (!name || name.length < 2) { errEl.textContent = 'Display name must be at least 2 characters.'; errEl.style.display = 'block'; return; } if (!name || name.length < 2) { errEl.textContent = 'Display name must be at least 2 characters.'; errEl.style.display = 'block'; return; }
if (avatarUrl && !avatarUrl.startsWith('http')) { errEl.textContent = 'Avatar URL must start with http:// or https://'; errEl.style.display = 'block'; return; } if (avatarUrl && !avatarUrl.startsWith('http') && !isEmoji(avatarUrl)) { errEl.textContent = 'Please enter a valid emoji'; errEl.style.display = 'block'; return; }
const btn = document.querySelector('#profile-modal .btn-primary'); const btn = document.querySelector('#profile-modal .btn-primary');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>'; btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try { try {
+3
View File
@@ -42,6 +42,9 @@
<h2>Account Access</h2> <h2>Account Access</h2>
<p>StepMates is a private app — new accounts require admin approval. Access can be revoked at any time.</p> <p>StepMates is a private app — new accounts require admin approval. Access can be revoked at any time.</p>
<h2>Data Deletion</h2>
<p>You may request complete deletion of your account and all associated data (including step history, profile, and friend connections) by emailing <a href="mailto:justin@slothware.net">justin@slothware.net</a> with the subject line "Data Deletion Request". We will process your request within 30 days.</p>
<h2>Contact</h2> <h2>Contact</h2>
<p>For privacy questions or data deletion requests, contact us at <a href="mailto:justin@slothware.net">justin@slothware.net</a>.</p> <p>For privacy questions or data deletion requests, contact us at <a href="mailto:justin@slothware.net">justin@slothware.net</a>.</p>