Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-25 18:04:32 -07:00
commit c5e6dc4aed
6 changed files with 639 additions and 217 deletions

View File

@ -287,9 +287,8 @@ class FolkMapViewer extends HTMLElement {
if (this.userName) return true;
// Use EncryptID username if authenticated
const identityName = getUsername();
const name = identityName || prompt("Your display name for this room:");
if (!name?.trim()) return false;
this.userName = name.trim();
if (identityName) {
this.userName = identityName;
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId,
name: this.userName,
@ -298,6 +297,99 @@ class FolkMapViewer extends HTMLElement {
}));
return true;
}
return false;
}
private pendingRoomSlug = "";
private showJoinForm(slug: string) {
this.pendingRoomSlug = slug;
const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null");
const savedName = saved?.name || "";
const savedEmoji = saved?.emoji || this.userEmoji;
const overlay = document.createElement("div");
overlay.id = "join-form-overlay";
overlay.style.cssText = `
position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.6);
display:flex;align-items:center;justify-content:center;
backdrop-filter:blur(4px);
`;
const card = document.createElement("div");
card.style.cssText = `
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:16px;padding:28px;width:340px;max-width:90vw;
box-shadow:0 16px 48px rgba(0,0,0,0.4);text-align:center;
font-family:system-ui,-apple-system,sans-serif;
`;
card.innerHTML = `
<div style="font-size:48px;margin-bottom:4px;" id="join-avatar-preview">${savedEmoji}</div>
<div style="font-size:18px;font-weight:700;color:var(--rs-text-primary);margin-bottom:4px;">Join ${this.esc(slug)}</div>
<div style="font-size:13px;color:var(--rs-text-muted);margin-bottom:16px;">Choose your avatar and display name</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-bottom:16px;">
${EMOJIS.map(e => `<button class="join-emoji-opt" data-e="${e}" style="width:36px;height:36px;border-radius:8px;border:2px solid ${e === savedEmoji ? "#4f46e5" : "transparent"};background:${e === savedEmoji ? "#4f46e520" : "var(--rs-bg-surface-sunken)"};font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:border-color 0.15s;">${e}</button>`).join("")}
</div>
<input id="join-name-input" type="text" placeholder="Your display name" value="${this.esc(savedName)}" style="
width:100%;padding:10px 14px;border-radius:8px;border:1px solid var(--rs-border);
background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:15px;
outline:none;margin-bottom:12px;box-sizing:border-box;
">
${savedName ? `<button id="join-quick-btn" style="
width:100%;padding:10px;border-radius:8px;border:none;
background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:14px;
margin-bottom:8px;
">Join as ${this.esc(savedEmoji)} ${this.esc(savedName)}</button>` : ""}
<button id="join-submit-btn" style="
width:100%;padding:10px;border-radius:8px;border:1px solid var(--rs-border);
background:var(--rs-bg-surface);color:var(--rs-text-primary);font-weight:600;cursor:pointer;font-size:14px;
">${savedName ? "Join with new name" : "Join Room"}</button>
<button id="join-cancel-btn" style="
margin-top:8px;background:none;border:none;color:var(--rs-text-muted);
cursor:pointer;font-size:12px;padding:4px;
">Cancel</button>
`;
overlay.appendChild(card);
this.shadow.appendChild(overlay);
let selectedEmoji = savedEmoji;
const preview = card.querySelector("#join-avatar-preview") as HTMLElement;
const nameInput = card.querySelector("#join-name-input") as HTMLInputElement;
card.querySelectorAll(".join-emoji-opt").forEach(btn => {
btn.addEventListener("click", () => {
selectedEmoji = (btn as HTMLElement).dataset.e!;
preview.textContent = selectedEmoji;
card.querySelectorAll(".join-emoji-opt").forEach(b => {
const isSelected = (b as HTMLElement).dataset.e === selectedEmoji;
(b as HTMLElement).style.borderColor = isSelected ? "#4f46e5" : "transparent";
(b as HTMLElement).style.background = isSelected ? "#4f46e520" : "var(--rs-bg-surface-sunken)";
});
});
});
const doJoin = (name: string, emoji: string) => {
if (!name.trim()) { nameInput.style.borderColor = "#ef4444"; return; }
this.userName = name.trim();
this.userEmoji = emoji;
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId, name: this.userName,
emoji: this.userEmoji, color: this.userColor,
}));
overlay.remove();
this.joinRoom(this.pendingRoomSlug);
};
card.querySelector("#join-quick-btn")?.addEventListener("click", () => doJoin(savedName, savedEmoji));
card.querySelector("#join-submit-btn")?.addEventListener("click", () => doJoin(nameInput.value, selectedEmoji));
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") doJoin(nameInput.value, selectedEmoji); });
card.querySelector("#join-cancel-btn")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
nameInput.focus();
}
// ─── Demo mode ───────────────────────────────────────────────
@ -1071,7 +1163,10 @@ class FolkMapViewer extends HTMLElement {
// ─── Room mode: join / leave / create ────────────────────────
private joinRoom(slug: string) {
if (!this.ensureUserProfile()) return;
if (!this.ensureUserProfile()) {
this.showJoinForm(slug);
return;
}
this.room = slug;
this.view = "map";
this.render();
@ -1110,10 +1205,57 @@ class FolkMapViewer extends HTMLElement {
private createRoom() {
if (!requireAuth("create map room")) return;
const name = prompt("Room name (slug):");
if (!name?.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-");
this.showCreateRoomForm();
}
private showCreateRoomForm() {
const overlay = document.createElement("div");
overlay.id = "create-room-overlay";
overlay.style.cssText = `
position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.6);
display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px);
`;
const card = document.createElement("div");
card.style.cssText = `
background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);
border-radius:16px;padding:28px;width:340px;max-width:90vw;
box-shadow:0 16px 48px rgba(0,0,0,0.4);text-align:center;
font-family:system-ui,-apple-system,sans-serif;
`;
card.innerHTML = `
<div style="font-size:32px;margin-bottom:8px;">&#127760;</div>
<div style="font-size:18px;font-weight:700;color:var(--rs-text-primary);margin-bottom:4px;">Create Room</div>
<div style="font-size:13px;color:var(--rs-text-muted);margin-bottom:16px;">Enter a name for your new map room</div>
<input id="room-name-input" type="text" placeholder="Room name (e.g. berlin-meetup)" style="
width:100%;padding:10px 14px;border-radius:8px;border:1px solid var(--rs-border);
background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:15px;
outline:none;margin-bottom:12px;box-sizing:border-box;
">
<button id="room-create-btn" style="
width:100%;padding:10px;border-radius:8px;border:none;
background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:14px;
">Create & Join</button>
<button id="room-cancel-btn" style="
margin-top:8px;background:none;border:none;color:var(--rs-text-muted);
cursor:pointer;font-size:12px;padding:4px;
">Cancel</button>
`;
overlay.appendChild(card);
this.shadow.appendChild(overlay);
const input = card.querySelector("#room-name-input") as HTMLInputElement;
const doCreate = () => {
const name = input.value.trim();
if (!name) { input.style.borderColor = "#ef4444"; return; }
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
overlay.remove();
this.joinRoom(slug);
};
card.querySelector("#room-create-btn")?.addEventListener("click", doCreate);
input.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); });
card.querySelector("#room-cancel-btn")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
input.focus();
}
private leaveRoom() {
@ -1258,6 +1400,7 @@ class FolkMapViewer extends HTMLElement {
});
saveRoomVisit(this.room, this.room);
try { localStorage.setItem("rmaps_last_room", this.room); } catch {}
// Listen for SW-forwarded location request pushes
if ("serviceWorker" in navigator) {
@ -1379,6 +1522,21 @@ class FolkMapViewer extends HTMLElement {
emojiSpan.className = "marker-emoji";
emojiSpan.textContent = p.emoji;
el.appendChild(emojiSpan);
// Status dot overlay (bottom-right corner)
const statusDot = document.createElement("div");
statusDot.className = "marker-status-dot";
const dotColor = isStale ? "#6b7280" : (
p.status === "online" ? "#22c55e" : p.status === "away" ? "#f59e0b" :
p.status === "ghost" ? "#64748b" : "#ef4444"
);
statusDot.style.cssText = `
position:absolute;bottom:-1px;right:-1px;
width:10px;height:10px;border-radius:50%;
background:${dotColor};border:2px solid ${markerBg};
box-shadow:0 0 6px ${dotColor};
`;
el.appendChild(statusDot);
}
// Staleness tooltip
@ -1477,6 +1635,13 @@ class FolkMapViewer extends HTMLElement {
this.indoorView.updateParticipants(state.participants);
}
// Update participant counts (header + mobile)
const pCount = String(Object.keys(state.participants).length);
const headerCount = this.shadow.getElementById("header-participant-count");
if (headerCount) headerCount.textContent = pCount;
const mobileCount = this.shadow.getElementById("mobile-friends-count");
if (mobileCount) mobileCount.textContent = pCount;
// Update participant list sidebar
this.updateParticipantList(state);
}
@ -1508,11 +1673,12 @@ class FolkMapViewer extends HTMLElement {
const ageSec = Math.floor(ageMs / 1000);
ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
}
const glowColor = isStale ? "#6b7280" : statusColor;
return `
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);${isStale ? "opacity:0.6;" : ""}">
<div style="position:relative;">
<span style="font-size:18px">${this.esc(p.emoji)}</span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${isStale ? "#6b7280" : statusColor};border:2px solid var(--rs-bg-surface);"></span>
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:8px 4px;border-bottom:1px solid var(--rs-border);${isStale ? "opacity:0.6;" : ""}">
<div style="position:relative;flex-shrink:0;">
<span style="font-size:20px;display:block;width:28px;text-align:center;">${this.esc(p.emoji)}</span>
<span style="position:absolute;bottom:-1px;right:-1px;width:10px;height:10px;border-radius:50%;background:${glowColor};border:2px solid var(--rs-bg-surface);box-shadow:0 0 6px ${glowColor};"></span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:${isStale ? "#6b7280" : p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
@ -1521,8 +1687,8 @@ class FolkMapViewer extends HTMLElement {
${distLabel ? ` \u2022 ${distLabel}` : ""}
</div>
</div>
${p.id !== this.participantId && p.location ? `<button class="nav-to-btn" data-nav-participant="${p.id}" title="Navigate" style="background:none;border:1px solid var(--rs-border);border-radius:4px;color:var(--rs-text-muted);cursor:pointer;padding:2px 6px;font-size:11px;">\u{1F9ED}</button>` : ""}
${p.id !== this.participantId ? `<button class="ping-btn-inline" data-ping="${p.id}" title="Ping" style="background:none;border:1px solid var(--rs-border);border-radius:4px;color:var(--rs-text-muted);cursor:pointer;padding:2px 6px;font-size:11px;">\u{1F514}</button>` : ""}
${p.id !== this.participantId && p.location ? `<button class="nav-to-btn" data-nav-participant="${p.id}" title="Navigate" style="background:none;border:1px solid var(--rs-border);border-radius:6px;color:var(--rs-text-muted);cursor:pointer;padding:3px 8px;font-size:12px;">&#129517;</button>` : ""}
${p.id !== this.participantId ? `<button class="ping-btn-inline" data-ping="${p.id}" title="Ping" style="background:none;border:1px solid var(--rs-border);border-radius:6px;color:var(--rs-text-muted);cursor:pointer;padding:3px 8px;font-size:12px;">&#128276;</button>` : ""}
</div>`;
}).join("");
}
@ -1562,29 +1728,33 @@ class FolkMapViewer extends HTMLElement {
const list = this.shadow.getElementById("participant-list");
const mobileList = this.shadow.getElementById("participant-list-mobile");
const count = Object.keys(state.participants).length;
const html = this.buildParticipantHTML(state);
const headerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<span style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.06em;">Friends (${count})</span>
<button id="sidebar-ping-all-btn" style="padding:3px 8px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:10px;white-space:nowrap;">&#128226; Ping All</button>
</div>
`;
const footerHTML = `
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
<button id="sidebar-ping-all-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E2} Ping All</button>
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">&#128205; Meeting Point</button>
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">&#128229; Import Places</button>
</div>
`;
// Desktop sidebar
if (list) {
list.innerHTML = html + footerHTML;
list.innerHTML = headerHTML + html + footerHTML;
this.attachParticipantListeners(list);
}
// Mobile bottom sheet
if (mobileList) {
mobileList.innerHTML = html;
mobileList.innerHTML = html + footerHTML;
this.attachParticipantListeners(mobileList);
// Update sheet header count
const header = this.shadow.querySelector(".sheet-header span:first-child");
const count = Object.keys(state.participants).length;
if (header) header.textContent = `Participants (${count})`;
const sheetCount = this.shadow.getElementById("sheet-participant-count");
if (sheetCount) sheetCount.textContent = String(count);
}
}
@ -1622,6 +1792,16 @@ class FolkMapViewer extends HTMLElement {
el.title = `${p.name} - ${p.status} (${ageLabel})`;
const arrow = el.querySelector(".heading-arrow") as HTMLElement | null;
if (arrow) arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color;
// Update status dot
const statusDot = el.querySelector(".marker-status-dot") as HTMLElement | null;
if (statusDot) {
const dotColor = isStale ? "#6b7280" : (
p.status === "online" ? "#22c55e" : p.status === "away" ? "#f59e0b" :
p.status === "ghost" ? "#64748b" : "#ef4444"
);
statusDot.style.background = dotColor;
statusDot.style.boxShadow = `0 0 6px ${dotColor}`;
}
}
}
@ -1786,7 +1966,7 @@ class FolkMapViewer extends HTMLElement {
private updateShareButton() {
const btn = this.shadow.getElementById("share-location");
if (!btn) return;
if (btn) {
if (this.privacySettings.precision === "hidden") {
btn.textContent = "\u{1F47B} Hidden";
btn.classList.remove("sharing");
@ -1800,6 +1980,22 @@ class FolkMapViewer extends HTMLElement {
btn.classList.remove("sharing");
btn.classList.remove("ghost");
}
}
// Floating share button
const floatBtn = this.shadow.getElementById("map-share-float");
if (floatBtn) {
if (this.privacySettings.precision === "hidden") {
floatBtn.innerHTML = "&#128123; Hidden";
floatBtn.className = "map-share-float ghost";
} else if (this.sharingLocation) {
floatBtn.innerHTML = "&#128205; Sharing";
floatBtn.className = "map-share-float active";
} else {
floatBtn.innerHTML = "&#128205; Share Location";
floatBtn.className = "map-share-float";
}
}
// Update permission indicator
const permIndicator = this.shadow.getElementById("geo-perm-indicator");
if (permIndicator) {
@ -1807,6 +2003,11 @@ class FolkMapViewer extends HTMLElement {
permIndicator.style.background = colors[this.geoPermissionState] || "#64748b";
permIndicator.title = `Geolocation: ${this.geoPermissionState || "unknown"}`;
}
// Update header share toggle
const headerToggle = this.shadow.getElementById("header-share-toggle");
if (headerToggle) {
headerToggle.className = `map-header__share-toggle ${this.sharingLocation ? "active" : ""}`;
}
// Also update mobile FAB
this.updateMobileFab();
}
@ -2369,11 +2570,49 @@ class FolkMapViewer extends HTMLElement {
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
/* Lobby nav */
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Map room header — dark bar */
.map-header {
display:flex;align-items:center;gap:8px;padding:8px 14px;
background:rgba(15,23,42,0.95);backdrop-filter:blur(8px);
border-radius:10px;margin-bottom:12px;min-height:44px;
border:1px solid rgba(255,255,255,0.08);
}
.map-header__left { display:flex;align-items:center;gap:6px;flex:1;min-width:0; }
.map-header__back {
background:none;border:none;color:#94a3b8;cursor:pointer;
font-size:16px;padding:4px 6px;border-radius:4px;
}
.map-header__back:hover { color:#e2e8f0; }
.map-header__logo { font-size:13px;font-weight:700;color:#e2e8f0;white-space:nowrap; }
.map-header__slug { font-size:12px;color:#64748b;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.map-header__participants {
display:flex;align-items:center;gap:4px;
padding:4px 12px;border-radius:20px;
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);
color:#e2e8f0;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;
}
.map-header__participants:hover { background:rgba(255,255,255,0.14); }
.map-header__right { display:flex;align-items:center;gap:4px; }
.map-header__icon-btn {
background:none;border:none;color:#94a3b8;cursor:pointer;
font-size:15px;padding:4px 6px;border-radius:4px;
}
.map-header__icon-btn:hover { color:#e2e8f0; }
.map-header__share-toggle {
width:32px;height:32px;border-radius:50%;
background:transparent;border:1px solid rgba(255,255,255,0.15);
color:#94a3b8;cursor:pointer;font-size:14px;
display:flex;align-items:center;justify-content:center;transition:all 0.2s;
}
.map-header__share-toggle.active { background:#10b981;border-color:#10b981;color:#fff; }
.map-header__share-toggle:hover { border-color:#10b981;color:#10b981; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
@ -2448,8 +2687,21 @@ class FolkMapViewer extends HTMLElement {
}
.map-locate-fab:hover { border-color: #4285f4; color: #4285f4; }
/* Mobile FAB menu — hidden on desktop */
.mobile-fab-container { display: none; }
/* Floating share-location button on map */
.map-share-float {
position:absolute;bottom:20px;right:16px;z-index:6;
padding:10px 18px;border-radius:24px;
background:#fff;border:1px solid #e2e8f0;
color:#1e293b;font-weight:600;font-size:13px;cursor:pointer;
box-shadow:0 2px 10px rgba(0,0,0,0.15);transition:all 0.2s;
font-family:system-ui,-apple-system,sans-serif;
}
.map-share-float.active { background:#10b981;border-color:#10b981;color:#fff; }
.map-share-float.ghost { background:#8b5cf6;border-color:#8b5cf6;color:#fff; }
.map-share-float:hover { box-shadow:0 4px 16px rgba(0,0,0,0.2); }
/* Mobile floating buttons — hidden on desktop */
.mobile-float-btns { display: none; }
.mobile-bottom-sheet { display: none; }
@media (max-width: 768px) {
@ -2458,52 +2710,20 @@ class FolkMapViewer extends HTMLElement {
.map-sidebar { display: none; }
.controls { display: none; }
#privacy-panel { display: none !important; }
.rapp-nav { position: absolute; top: 0; left: 0; right: 0; z-index: 7; background: var(--rs-bg-surface); border-bottom: 1px solid var(--rs-border); margin: 0; padding: 6px 12px; min-height: 48px; }
.map-header { position:absolute;top:0;left:0;right:0;z-index:7;margin:0;border-radius:0;border-bottom:1px solid rgba(255,255,255,0.08); }
.map-locate-fab { bottom: 100px; }
/* Mobile FAB menu */
.mobile-fab-container {
display: block; position: fixed; bottom: 24px; right: 16px; z-index: 8;
/* Mobile floating pill buttons */
.mobile-float-btns { display:flex;position:fixed;bottom:16px;left:0;right:0;z-index:8;padding:0 16px;justify-content:space-between;pointer-events:none; }
.mobile-pill-btn {
padding:10px 16px;border-radius:24px;border:none;
background:rgba(15,23,42,0.92);color:#e2e8f0;font-weight:600;
font-size:13px;cursor:pointer;pointer-events:auto;
box-shadow:0 4px 16px rgba(0,0,0,0.3);backdrop-filter:blur(4px);
font-family:system-ui,-apple-system,sans-serif;
}
.fab-main {
width: 52px; height: 52px; border-radius: 50%;
background: #4f46e5; border: none; color: #fff; cursor: pointer;
font-size: 22px; display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(79,70,229,0.5); transition: transform 0.2s;
}
.fab-main.open { transform: rotate(45deg); }
.fab-mini-list {
position: absolute; bottom: 62px; right: 2px;
display: flex; flex-direction: column-reverse; gap: 10px;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
.fab-mini-list.open { opacity: 1; pointer-events: auto; }
.fab-mini {
display: flex; align-items: center; gap: 8px; flex-direction: row-reverse;
}
.fab-mini-btn {
width: 40px; height: 40px; border-radius: 50%;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
color: var(--rs-text-primary); cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transform: scale(0); transition: transform 0.15s;
}
.fab-mini-list.open .fab-mini-btn { transform: scale(1); }
.fab-mini-list.open .fab-mini:nth-child(1) .fab-mini-btn { transition-delay: 0s; }
.fab-mini-list.open .fab-mini:nth-child(2) .fab-mini-btn { transition-delay: 0.04s; }
.fab-mini-list.open .fab-mini:nth-child(3) .fab-mini-btn { transition-delay: 0.08s; }
.fab-mini-list.open .fab-mini:nth-child(4) .fab-mini-btn { transition-delay: 0.12s; }
.fab-mini-list.open .fab-mini:nth-child(5) .fab-mini-btn { transition-delay: 0.16s; }
.fab-mini-list.open .fab-mini:nth-child(6) .fab-mini-btn { transition-delay: 0.20s; }
.fab-mini-list.open .fab-mini:nth-child(7) .fab-mini-btn { transition-delay: 0.24s; }
.fab-mini-label {
font-size: 11px; background: var(--rs-bg-surface); color: var(--rs-text-secondary);
padding: 4px 8px; border-radius: 6px; white-space: nowrap;
box-shadow: 0 1px 4px rgba(0,0,0,0.15); border: 1px solid var(--rs-border);
}
.fab-mini-btn.sharing { border-color: #22c55e; color: #22c55e; }
.fab-mini-btn.ghost { border-color: #8b5cf6; color: #8b5cf6; }
.mobile-pill-btn:active { opacity:0.8; }
.map-share-float { bottom:70px; }
/* Mobile bottom sheet */
.mobile-bottom-sheet {
@ -2611,6 +2831,37 @@ class FolkMapViewer extends HTMLElement {
startTour() { this._tour.start(); }
private renderLobby(): string {
const saved = JSON.parse(localStorage.getItem("rmaps_user") || "null");
const lastRoom = localStorage.getItem("rmaps_last_room") || "";
const profileSection = `
<div style="background:var(--rs-glass-bg);border:1px solid var(--rs-border);border-radius:12px;padding:16px;margin-bottom:16px;">
<div style="font-size:12px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:10px;">Get Started</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="position:relative;">
<span id="lobby-avatar-preview" style="font-size:32px;display:block;cursor:pointer;" title="Click to change emoji">${this.userEmoji}</span>
</div>
<div style="flex:1;">
<input id="lobby-name-input" type="text" placeholder="Your display name" value="${this.esc(saved?.name || this.userName || "")}" style="
width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--rs-border);
background:var(--rs-input-bg);color:var(--rs-text-primary);font-size:14px;outline:none;box-sizing:border-box;
">
</div>
</div>
<div id="lobby-emoji-grid" style="display:none;flex-wrap:wrap;gap:4px;margin-bottom:10px;justify-content:center;">
${EMOJIS.map(e => `<button class="lobby-emoji-pick" data-lobby-emoji="${e}" style="width:34px;height:34px;border-radius:6px;border:2px solid ${e === this.userEmoji ? "#4f46e5" : "transparent"};background:${e === this.userEmoji ? "#4f46e520" : "var(--rs-bg-surface-sunken)"};font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
${lastRoom ? `
<button id="lobby-rejoin-btn" style="
width:100%;padding:10px;border-radius:8px;border:none;
background:#4f46e5;color:#fff;font-weight:600;cursor:pointer;font-size:13px;
margin-bottom:6px;
">Rejoin &#127760; ${this.esc(lastRoom)}</button>
` : ""}
<div style="font-size:11px;color:var(--rs-text-muted);text-align:center;">Profile is saved locally for quick rejoins</div>
</div>
`;
const history = loadRoomHistory();
const historyCards = history.length > 0 ? `
<div class="section-label">Recent Rooms</div>
@ -2640,6 +2891,8 @@ class FolkMapViewer extends HTMLElement {
<button class="rapp-nav__btn" id="btn-tour" style="background:transparent;border:1px solid var(--rs-border,#334155);color:var(--rs-text-secondary,#94a3b8);font-weight:500">Tour</button>
</div>
${profileSection}
${this.rooms.length > 0 ? `
<div class="section-label">Active Rooms</div>
${this.rooms.map((r) => `
@ -2661,12 +2914,24 @@ class FolkMapViewer extends HTMLElement {
private renderMap(): string {
return `
<div class="rapp-nav">
${this._history.canGoBack ? '<button class="rapp-nav__back" data-back="lobby">&#8592; Rooms</button>' : ''}
<span class="rapp-nav__title">\u{1F5FA} ${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}"></span>
<div class="map-header">
<div class="map-header__left">
${this._history.canGoBack ? '<button class="map-header__back" data-back="lobby">&#8592;</button>' : ''}
<span class="map-header__logo">&#127760; rMaps</span>
<span class="map-header__slug">/${this.esc(this.room)}</span>
<span class="status-dot ${this.syncStatus === "connected" ? "status-connected" : "status-disconnected"}" style="margin-left:6px;"></span>
</div>
<button class="map-header__participants" id="header-participants-toggle" title="Show participants">
&#128101; <span id="header-participant-count">0</span>
</button>
<div class="map-header__right">
<span id="geo-perm-indicator" style="width:6px;height:6px;border-radius:50%;background:#64748b;" title="Geolocation: unknown"></span>
<button id="bell-toggle" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;padding:4px;" title="Notifications">\u{1F514}</button>
<button class="map-header__icon-btn" id="bell-toggle" title="Notifications">&#128276;</button>
<button class="map-header__icon-btn" id="share-room-btn" title="Share rMap">&#128228;</button>
<button class="map-header__share-toggle ${this.sharingLocation ? "active" : ""}" id="header-share-toggle" title="${this.sharingLocation ? "Sharing location" : "Share location"}">
${this.sharingLocation ? "&#128205;" : "&#128205;"}
</button>
</div>
</div>
<div class="map-layout">
@ -2705,54 +2970,30 @@ class FolkMapViewer extends HTMLElement {
<button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button>
<button class="map-locate-fab" id="fit-all-fab" title="Show all participants" style="bottom:80px;">\u{1F465}</button>
<!-- Mobile FAB menu -->
<div class="mobile-fab-container" id="mobile-fab-container">
<div class="fab-mini-list" id="fab-mini-list">
<div class="fab-mini">
<button class="fab-mini-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="fab-share-loc" title="Share Location">\u{1F4CD}</button>
<span class="fab-mini-label">${this.privacySettings.ghostMode ? "Ghost" : this.sharingLocation ? "Stop" : "Share"}</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-privacy" title="Privacy">\u{1F6E1}</button>
<span class="fab-mini-label">Privacy</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-drop-pin" title="Drop Pin">\u{1F4CC}</button>
<span class="fab-mini-label">Drop Pin</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-share-map" title="Share rMap">\u{1F4E4}</button>
<span class="fab-mini-label">Share rMap</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-emoji" title="Emoji">${this.userEmoji}</button>
<span class="fab-mini-label">Emoji</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-chat" title="Chat" style="position:relative;">\u{1F4AC}<span id="fab-chat-badge" style="display:${this.unreadCount > 0 ? "flex" : "none"};position:absolute;top:-4px;right:-4px;min-width:14px;height:14px;border-radius:7px;background:#ef4444;color:#fff;font-size:8px;align-items:center;justify-content:center;padding:0 3px;">${this.unreadCount || ""}</span></button>
<span class="fab-mini-label">Chat</span>
</div>
<div class="fab-mini">
<button class="fab-mini-btn" id="fab-indoor" title="Indoor">${this.mapMode === "indoor" ? "\u{1F30D}" : "\u{1F3E2}"}</button>
<span class="fab-mini-label">${this.mapMode === "indoor" ? "Outdoor" : "Indoor"}</span>
</div>
</div>
<button class="fab-main" id="fab-main" title="Menu">\u2699</button>
<!-- Mobile floating buttons (rmaps-online style) -->
<div class="mobile-float-btns">
<button class="mobile-pill-btn mobile-pill-left" id="mobile-friends-btn">&#128101; See Friends (<span id="mobile-friends-count">0</span>)</button>
<button class="mobile-pill-btn mobile-pill-right" id="mobile-qr-btn">&#9641; QR</button>
</div>
<!-- Floating share-location button on map -->
<button class="map-share-float ${this.sharingLocation ? "active" : ""} ${this.privacySettings.precision === "hidden" ? "ghost" : ""}" id="map-share-float">
${this.privacySettings.precision === "hidden" ? "&#128123; Hidden" : this.sharingLocation ? "&#128205; Sharing" : "&#128205; Share Location"}
</button>
<!-- Mobile bottom sheet (participants) -->
<div class="mobile-bottom-sheet" id="mobile-bottom-sheet">
<div class="sheet-handle" id="sheet-handle">
<div class="sheet-handle-bar"></div>
</div>
<div class="sheet-header">
<span>Participants</span>
<span style="font-size:10px;font-weight:400;color:var(--rs-text-muted);text-transform:none;letter-spacing:0;">tap to expand</span>
<span>Friends (<span id="sheet-participant-count">0</span>)</span>
<button id="sheet-close-btn" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:14px;padding:2px 6px;">&#10005;</button>
</div>
<div class="sheet-content" id="participant-list-mobile"></div>
</div>
<!-- Mobile privacy popup (hidden by default) -->
<!-- Mobile privacy popup (hidden by default, triggered from bottom sheet) -->
<div class="mobile-privacy-popup" id="mobile-privacy-popup" style="display:none;"></div>
`;
}
@ -2761,6 +3002,60 @@ class FolkMapViewer extends HTMLElement {
this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.getElementById("create-room")?.addEventListener("click", () => this.createRoom());
// Lobby profile section
const lobbyNameInput = this.shadow.getElementById("lobby-name-input") as HTMLInputElement;
if (lobbyNameInput) {
lobbyNameInput.addEventListener("change", () => {
const name = lobbyNameInput.value.trim();
if (name) {
this.userName = name;
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId, name: this.userName,
emoji: this.userEmoji, color: this.userColor,
}));
}
});
}
const lobbyAvatar = this.shadow.getElementById("lobby-avatar-preview");
const lobbyEmojiGrid = this.shadow.getElementById("lobby-emoji-grid");
if (lobbyAvatar && lobbyEmojiGrid) {
lobbyAvatar.addEventListener("click", () => {
lobbyEmojiGrid.style.display = lobbyEmojiGrid.style.display === "none" ? "flex" : "none";
});
}
this.shadow.querySelectorAll("[data-lobby-emoji]").forEach(btn => {
btn.addEventListener("click", () => {
const emoji = (btn as HTMLElement).dataset.lobbyEmoji!;
this.userEmoji = emoji;
if (lobbyAvatar) lobbyAvatar.textContent = emoji;
this.shadow.querySelectorAll("[data-lobby-emoji]").forEach(b => {
const sel = (b as HTMLElement).dataset.lobbyEmoji === emoji;
(b as HTMLElement).style.borderColor = sel ? "#4f46e5" : "transparent";
(b as HTMLElement).style.background = sel ? "#4f46e520" : "var(--rs-bg-surface-sunken)";
});
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId, name: this.userName,
emoji: this.userEmoji, color: this.userColor,
}));
});
});
this.shadow.getElementById("lobby-rejoin-btn")?.addEventListener("click", () => {
const lastRoom = localStorage.getItem("rmaps_last_room");
if (lastRoom) {
// Save name from input if provided
if (lobbyNameInput?.value.trim()) {
this.userName = lobbyNameInput.value.trim();
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId, name: this.userName,
emoji: this.userEmoji, color: this.userColor,
}));
}
this._history.push("lobby");
this._history.push("map", { room: lastRoom });
this.joinRoom(lastRoom);
}
});
this.shadow.querySelectorAll("[data-room]").forEach((el) => {
el.addEventListener("click", () => {
const room = (el as HTMLElement).dataset.room!;
@ -2780,6 +3075,22 @@ class FolkMapViewer extends HTMLElement {
this.toggleLocationSharing();
});
// Header share toggle
this.shadow.getElementById("header-share-toggle")?.addEventListener("click", () => {
this.toggleLocationSharing();
});
// Header participants toggle
this.shadow.getElementById("header-participants-toggle")?.addEventListener("click", () => {
// Desktop: toggle sidebar visibility, Mobile: toggle bottom sheet
const sidebar = this.shadow.querySelector(".map-sidebar") as HTMLElement;
if (sidebar && window.innerWidth > 768) {
sidebar.style.display = sidebar.style.display === "none" ? "" : "none";
}
const sheet = this.shadow.getElementById("mobile-bottom-sheet");
if (sheet) sheet.classList.toggle("expanded");
});
this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => {
this.dropWaypoint();
});
@ -2865,75 +3176,26 @@ class FolkMapViewer extends HTMLElement {
this.fitToParticipants();
});
// Mobile FAB menu
this.shadow.getElementById("fab-main")?.addEventListener("click", () => {
const main = this.shadow.getElementById("fab-main");
const list = this.shadow.getElementById("fab-mini-list");
if (main && list) {
const isOpen = list.classList.contains("open");
list.classList.toggle("open");
main.classList.toggle("open");
// Close mobile privacy popup when closing FAB
if (isOpen) {
const popup = this.shadow.getElementById("mobile-privacy-popup");
if (popup) popup.style.display = "none";
}
}
// Mobile floating buttons
this.shadow.getElementById("mobile-friends-btn")?.addEventListener("click", () => {
const sheet = this.shadow.getElementById("mobile-bottom-sheet");
if (sheet) sheet.classList.toggle("expanded");
});
this.shadow.getElementById("fab-share-loc")?.addEventListener("click", () => {
this.toggleLocationSharing();
this.updateMobileFab();
});
this.shadow.getElementById("fab-privacy")?.addEventListener("click", () => {
const popup = this.shadow.getElementById("mobile-privacy-popup");
if (popup) {
const isVisible = popup.style.display !== "none";
popup.style.display = isVisible ? "none" : "block";
if (!isVisible) this.renderPrivacyPanel(popup);
}
});
this.shadow.getElementById("fab-drop-pin")?.addEventListener("click", () => {
this.closeMobileFab();
this.dropWaypoint();
});
this.shadow.getElementById("fab-share-map")?.addEventListener("click", () => {
this.closeMobileFab();
this.shadow.getElementById("mobile-qr-btn")?.addEventListener("click", () => {
this.showShareModal = true;
this.renderShareModal();
});
this.shadow.getElementById("fab-emoji")?.addEventListener("click", () => {
this.showEmojiPicker = !this.showEmojiPicker;
this.updateEmojiButton();
// Floating share-location button on map
this.shadow.getElementById("map-share-float")?.addEventListener("click", () => {
this.toggleLocationSharing();
});
this.shadow.getElementById("fab-chat")?.addEventListener("click", () => {
this.closeMobileFab();
// Toggle mobile bottom sheet to chat view
const sheet = this.shadow.getElementById("mobile-bottom-sheet");
if (sheet) {
sheet.classList.add("expanded");
const content = this.shadow.getElementById("participant-list-mobile");
if (content && !content.querySelector("map-chat-panel")) {
this.mountChatPanel(content);
}
}
this.unreadCount = 0;
this.updateChatBadge();
});
this.shadow.getElementById("fab-indoor")?.addEventListener("click", () => {
this.closeMobileFab();
if (this.mapMode === "outdoor") {
const event = prompt("c3nav event code (e.g. 39c3):", "39c3");
if (event?.trim()) this.switchToIndoor(event.trim());
} else {
this.switchToOutdoor();
}
// Sheet close button
this.shadow.getElementById("sheet-close-btn")?.addEventListener("click", () => {
const s = this.shadow.getElementById("mobile-bottom-sheet");
if (s) s.classList.remove("expanded");
});
// Mobile bottom sheet
@ -2974,21 +3236,8 @@ class FolkMapViewer extends HTMLElement {
});
}
private closeMobileFab() {
const main = this.shadow.getElementById("fab-main");
const list = this.shadow.getElementById("fab-mini-list");
if (main) main.classList.remove("open");
if (list) list.classList.remove("open");
const popup = this.shadow.getElementById("mobile-privacy-popup");
if (popup) popup.style.display = "none";
}
private updateMobileFab() {
const btn = this.shadow.getElementById("fab-share-loc");
if (!btn) return;
btn.className = `fab-mini-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}`;
const label = btn.parentElement?.querySelector(".fab-mini-label");
if (label) label.textContent = this.privacySettings.ghostMode ? "Ghost" : this.sharingLocation ? "Stop" : "Share";
// No-op — legacy FAB removed; share state updated via updateShareButton()
}
private goBack() {

View File

@ -276,7 +276,7 @@ routes.get("/", (c) => {
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=5"></script>`,
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=6"></script>`,
styles: `<link rel="stylesheet" href="/modules/rmaps/maps.css?v=3">`,
}));
});
@ -295,7 +295,7 @@ routes.get("/:room", (c) => {
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.js" as="script">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.1.2/dist/maplibre-gl.css" as="style">
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=5"></script>`,
<script type="module" src="/modules/rmaps/folk-map-viewer.js?v=6"></script>`,
}));
});

View File

@ -1155,6 +1155,10 @@ export class RStackIdentity extends HTMLElement {
let guardiansLoaded = false;
let guardiansLoading = false;
let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = [];
let devicesLoaded = false;
let devicesLoading = false;
let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = [];
let addressesLoaded = false;
let addressesLoading = false;
@ -1271,15 +1275,44 @@ export class RStackIdentity extends HTMLElement {
const renderDeviceSection = () => {
const isOpen = openSection === "device";
const done = acctStatus ? acctStatus.multiDevice : null;
const body = isOpen ? `
let body = "";
if (isOpen) {
if (devicesLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading devices...</div></div>`;
} else {
const fmtDate = (ts?: number) => ts ? new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "Never";
const transportBadge = (t: string) => `<span style="display:inline-block;background:var(--rs-bg-secondary);border-radius:4px;padding:1px 5px;font-size:0.7rem;color:var(--rs-text-muted)">${t}</span>`;
const deviceListHTML = devices.length > 0
? `<div class="contact-list">${devices.map(d => `
<div class="contact-item" style="flex-wrap:wrap;gap:6px">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
<span style="font-size:1.1rem">🔑</span>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span style="font-weight:500">${(d.label || "Unnamed device").replace(/</g, "&lt;")}</span>
<span style="font-size:0.7rem;color:var(--rs-text-muted)">Added ${fmtDate(d.createdAt)} · Last used ${fmtDate(d.lastUsed)}</span>
${d.transports?.length ? `<div style="display:flex;gap:3px;flex-wrap:wrap">${d.transports.map(transportBadge).join("")}</div>` : ""}
</div>
</div>
<div style="display:flex;gap:4px;align-items:center">
<button class="btn btn--small" data-rename-credential="${d.credentialId}" title="Rename"></button>
<button class="btn btn--small btn--danger" data-remove-credential="${d.credentialId}" title="Remove"${devices.length <= 1 ? " disabled" : ""}>&times;</button>
</div>
</div>
`).join("")}</div>` : "";
body = `
<div class="account-section-body">
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.${acctStatus ? ` <span style="color:var(--rs-text-muted)">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</p>
<div class="actions actions--stack">
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.</p>
${deviceListHTML}
<div class="actions actions--stack" style="margin-top:8px">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="device-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>` : "";
</div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="device">
@ -1469,6 +1502,30 @@ export class RStackIdentity extends HTMLElement {
render();
};
const loadDevices = async () => {
if (devicesLoaded || devicesLoading) return;
devicesLoading = true;
render();
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
devices = (data.credentials || []).map((c: any) => ({
credentialId: c.credentialId,
label: c.label || null,
createdAt: c.createdAt,
lastUsed: c.lastUsed,
transports: c.transports,
}));
}
} catch { /* offline */ }
devicesLoaded = true;
devicesLoading = false;
render();
};
const attachListeners = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
@ -1480,6 +1537,7 @@ export class RStackIdentity extends HTMLElement {
openSection = openSection === section ? null : section;
if (openSection === "recovery") loadGuardians();
if (openSection === "address") loadAddresses();
if (openSection === "device") loadDevices();
render();
if (openSection === "email") setTimeout(() => (overlay.querySelector("#acct-email") as HTMLInputElement)?.focus(), 50);
if (openSection === "recovery") setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
@ -1596,6 +1654,9 @@ export class RStackIdentity extends HTMLElement {
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
localStorage.removeItem("eid_device_nudge_dismissed");
devicesLoaded = false;
loadDevices();
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
render();
@ -1714,6 +1775,50 @@ export class RStackIdentity extends HTMLElement {
});
});
// Device: rename credential
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.renameCredential!;
const device = devices.find(d => d.credentialId === id);
const newLabel = prompt("Enter a label for this device:", device?.label || "");
if (!newLabel || !newLabel.trim()) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ label: newLabel.trim() }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to rename");
if (device) device.label = newLabel.trim();
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
if (err) err.textContent = e.message;
}
});
});
// Device: remove credential
overlay.querySelectorAll("[data-remove-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeCredential!;
if (!confirm("Remove this passkey? You won't be able to sign in with it anymore.")) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to remove");
devices = devices.filter(d => d.credentialId !== id);
if (acctStatus) { acctStatus.credentialCount = devices.length; acctStatus.multiDevice = devices.length > 1; }
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
if (err) err.textContent = e.message;
}
});
});
// Data Storage toggle
const backupToggle = overlay.querySelector("#acct-backup-toggle") as HTMLInputElement;
if (backupToggle) {

View File

@ -39,6 +39,7 @@ export interface StoredCredential {
lastUsed?: number;
transports?: string[];
rpId?: string;
label?: string;
}
export interface StoredChallenge {
@ -136,7 +137,7 @@ export async function updateCredentialUsage(credentialId: string, newCounter: nu
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
const rows = await sql`
SELECT c.credential_id, c.public_key, c.user_id, c.counter,
c.transports, c.created_at, c.last_used, u.username
c.transports, c.created_at, c.last_used, c.label, u.username
FROM credentials c
JOIN users u ON c.user_id = u.id
WHERE c.user_id = ${userId}
@ -151,9 +152,28 @@ export async function getUserCredentials(userId: string): Promise<StoredCredenti
createdAt: new Date(row.created_at).getTime(),
lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined,
transports: row.transports,
label: row.label || undefined,
}));
}
export async function updateCredentialLabel(credentialId: string, userId: string, label: string): Promise<boolean> {
const result = await sql`
UPDATE credentials SET label = ${label}
WHERE credential_id = ${credentialId} AND user_id = ${userId}
RETURNING credential_id
`;
return result.length > 0;
}
export async function deleteCredential(credentialId: string, userId: string): Promise<boolean> {
const result = await sql`
DELETE FROM credentials
WHERE credential_id = ${credentialId} AND user_id = ${userId}
RETURNING credential_id
`;
return result.length > 0;
}
// ============================================================================
// CHALLENGE OPERATIONS
// ============================================================================

View File

@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS credentials (
CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
-- Device label for user-facing credential management
ALTER TABLE credentials ADD COLUMN IF NOT EXISTS label TEXT;
CREATE TABLE IF NOT EXISTS challenges (
challenge TEXT PRIMARY KEY,
user_id TEXT,

View File

@ -100,6 +100,8 @@ import {
getLinkedWallets,
deleteLinkedWallet,
linkedWalletExists,
updateCredentialLabel,
deleteCredential,
createLegacyIdentity,
getLegacyIdentityByPublicKeyHash,
getLegacyIdentitiesByUser,
@ -972,6 +974,7 @@ app.get('/api/user/credentials', async (c) => {
createdAt: cred.createdAt,
lastUsed: cred.lastUsed,
transports: cred.transports,
label: cred.label ?? null,
}));
return c.json({ credentials: credentialList });
@ -980,6 +983,48 @@ app.get('/api/user/credentials', async (c) => {
}
});
/**
* Rename a credential (passkey label)
*/
app.patch('/api/user/credentials/:credentialId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const credentialId = c.req.param('credentialId');
const { label } = await c.req.json();
if (!label || typeof label !== 'string' || label.trim().length === 0 || label.trim().length > 100) {
return c.json({ error: 'Label must be a non-empty string (max 100 chars)' }, 400);
}
const updated = await updateCredentialLabel(credentialId, claims.sub as string, label.trim());
if (!updated) return c.json({ error: 'Credential not found' }, 404);
return c.json({ success: true });
});
/**
* Remove a credential (passkey) refuses to delete the last one
*/
app.delete('/api/user/credentials/:credentialId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
const userId = claims.sub as string;
const credentialId = c.req.param('credentialId');
const creds = await getUserCredentials(userId);
if (creds.length <= 1) {
return c.json({ error: 'Cannot delete your only passkey' }, 400);
}
if (!creds.some(cr => cr.credentialId === credentialId)) {
return c.json({ error: 'Credential not found' }, 404);
}
await deleteCredential(credentialId, userId);
return c.json({ success: true });
});
// ============================================================================
// DID MIGRATION ENDPOINT
// ============================================================================