feat: redesign identity modal and space switcher UX
- Auth modal: unified "Sign up / Sign in" landing with stacked passkey buttons, close X button, and "Powered by EncryptID" link to ridentity.online - Logged-in dropdown: replace Profile/Recovery (auth.ridentity.online) with Add Email, Add Second Device, Add Social Recovery settings modals - Add Email: two-step flow (enter email → verify code) - Add Second Device: WebAuthn credential registration for backup access - Add Social Recovery: trusted contacts with configurable threshold - Space switcher: emoji visibility badges (🔓 green / 🔑 yellow / 🔒 red), remove slash prefix, match app-switcher button styling - Add rdata.online to standalone domain list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b872e8e053
commit
cd33f7c050
|
|
@ -490,6 +490,7 @@ for (const mod of getAllModules()) {
|
|||
// Domains we keep on their own containers (do NOT rewrite)
|
||||
const keepStandalone = new Set([
|
||||
"rcart.online",
|
||||
"rdata.online",
|
||||
"rfiles.online",
|
||||
"swag.mycofi.earth",
|
||||
"providers.mycofi.earth",
|
||||
|
|
|
|||
|
|
@ -138,8 +138,11 @@ export class RStackIdentity extends HTMLElement {
|
|||
<div class="avatar">${initial}</div>
|
||||
<span class="name">${displayName}</span>
|
||||
<div class="dropdown" id="dropdown">
|
||||
<button class="dropdown-item" data-action="profile">👤 Profile</button>
|
||||
<button class="dropdown-item" data-action="recovery">🛡️ Recovery</button>
|
||||
<div class="dropdown-header">${displayName}</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" data-action="add-email">✉️ Add Email</button>
|
||||
<button class="dropdown-item" data-action="add-device">📱 Add Second Device</button>
|
||||
<button class="dropdown-item" data-action="add-recovery">🛡️ Add Social Recovery</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
|
||||
</div>
|
||||
|
|
@ -165,10 +168,12 @@ export class RStackIdentity extends HTMLElement {
|
|||
clearSession();
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
} else if (action === "profile") {
|
||||
window.open(ENCRYPTID_URL, "_blank");
|
||||
} else if (action === "recovery") {
|
||||
window.open(`${ENCRYPTID_URL}/recover`, "_blank");
|
||||
} else if (action === "add-email") {
|
||||
this.#showAddEmailModal();
|
||||
} else if (action === "add-device") {
|
||||
this.#showAddDeviceModal();
|
||||
} else if (action === "add-recovery") {
|
||||
this.#showAddRecoveryModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -200,29 +205,31 @@ export class RStackIdentity extends HTMLElement {
|
|||
const signinHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<h2>Sign in with EncryptID</h2>
|
||||
<p>Use your passkey to sign in. No passwords needed.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
|
||||
<button class="close-btn" data-action="cancel">×</button>
|
||||
<h2>Sign up / Sign in</h2>
|
||||
<p>Secure, passwordless authentication powered by passkeys.</p>
|
||||
<div class="actions actions--stack">
|
||||
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
|
||||
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
|
||||
</div>
|
||||
<div class="error" id="auth-error"></div>
|
||||
<div class="toggle">Don't have an account? <a data-action="switch-register">Create one</a></div>
|
||||
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const registerHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="cancel">×</button>
|
||||
<h2>Create your EncryptID</h2>
|
||||
<p>Set up a secure, passwordless identity.</p>
|
||||
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
|
||||
<button class="btn btn--secondary" data-action="switch-signin">Back</button>
|
||||
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
|
||||
</div>
|
||||
<div class="error" id="auth-error"></div>
|
||||
<div class="toggle">Already have an account? <a data-action="switch-signin">Sign in</a></div>
|
||||
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -383,6 +390,303 @@ export class RStackIdentity extends HTMLElement {
|
|||
render();
|
||||
}
|
||||
|
||||
// ── Settings modals ──
|
||||
|
||||
#showAddEmailModal(): void {
|
||||
if (document.querySelector(".rstack-auth-overlay")) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "rstack-auth-overlay";
|
||||
let step: "input" | "verify" = "input";
|
||||
let emailAddr = "";
|
||||
|
||||
const render = () => {
|
||||
overlay.innerHTML = step === "input" ? `
|
||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="close">×</button>
|
||||
<h2>Add Email</h2>
|
||||
<p>Link an email for notifications and account recovery.</p>
|
||||
<input class="input" id="s-email" type="email" placeholder="you@example.com" />
|
||||
<div class="actions">
|
||||
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
|
||||
</div>
|
||||
<div class="error" id="s-error"></div>
|
||||
</div>
|
||||
` : `
|
||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="close">×</button>
|
||||
<h2>Verify Email</h2>
|
||||
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "<")}</strong></p>
|
||||
<input class="input" id="s-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="back">Back</button>
|
||||
<button class="btn btn--primary" data-action="verify">Verify</button>
|
||||
</div>
|
||||
<div class="error" id="s-error"></div>
|
||||
</div>
|
||||
`;
|
||||
attach();
|
||||
};
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
const attach = () => {
|
||||
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('[data-action="send-code"]')?.addEventListener("click", async () => {
|
||||
const input = overlay.querySelector("#s-email") as HTMLInputElement;
|
||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="send-code"]') as HTMLButtonElement;
|
||||
emailAddr = input.value.trim();
|
||||
if (!emailAddr || !emailAddr.includes("@")) { err.textContent = "Enter a valid email address."; input.focus(); return; }
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...';
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||
body: JSON.stringify({ email: emailAddr }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to send verification code");
|
||||
step = "verify"; render();
|
||||
setTimeout(() => (overlay.querySelector("#s-code") as HTMLInputElement)?.focus(), 50);
|
||||
} catch (e: any) {
|
||||
btn.disabled = false; btn.innerHTML = "Send Verification Code";
|
||||
err.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => { step = "input"; render(); });
|
||||
|
||||
overlay.querySelector('[data-action="verify"]')?.addEventListener("click", async () => {
|
||||
const input = overlay.querySelector("#s-code") as HTMLInputElement;
|
||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="verify"]') as HTMLButtonElement;
|
||||
const code = input.value.trim();
|
||||
if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; }
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Verifying...';
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/account/email/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||
body: JSON.stringify({ email: emailAddr, code }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed");
|
||||
close();
|
||||
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } }));
|
||||
} catch (e: any) {
|
||||
btn.disabled = false; btn.innerHTML = "Verify";
|
||||
err.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="send-code"]') as HTMLElement)?.click();
|
||||
});
|
||||
overlay.querySelector("#s-code")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify"]') as HTMLElement)?.click();
|
||||
});
|
||||
};
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
}
|
||||
|
||||
#showAddDeviceModal(): void {
|
||||
if (document.querySelector(".rstack-auth-overlay")) return;
|
||||
const session = getSession();
|
||||
if (!session) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "rstack-auth-overlay";
|
||||
|
||||
overlay.innerHTML = `
|
||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="close">×</button>
|
||||
<h2>Add Second Device</h2>
|
||||
<p>Register an additional passkey for backup access. Use this on a different device or browser.</p>
|
||||
<div class="actions actions--stack">
|
||||
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
|
||||
</div>
|
||||
<div class="error" id="s-error"></div>
|
||||
<div class="info-text">Each device you register can independently sign in to your account.</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => overlay.remove();
|
||||
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
|
||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
|
||||
err.textContent = "";
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
|
||||
try {
|
||||
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||
});
|
||||
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
|
||||
const { options: serverOptions, userId } = await startRes.json();
|
||||
const username = session.claims.username || "";
|
||||
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
|
||||
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
|
||||
user: {
|
||||
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
|
||||
name: username || session.claims.sub,
|
||||
displayName: username || session.claims.sub,
|
||||
},
|
||||
pubKeyCredParams: serverOptions.pubKeyCredParams || [
|
||||
{ alg: -7, type: "public-key" as const },
|
||||
{ alg: -257, type: "public-key" as const },
|
||||
],
|
||||
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
|
||||
attestation: "none",
|
||||
timeout: 60000,
|
||||
},
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) throw new Error("Passkey creation failed");
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
const publicKey = response.getPublicKey?.();
|
||||
|
||||
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||
body: JSON.stringify({
|
||||
challenge: serverOptions.challenge,
|
||||
credential: {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
|
||||
transports: response.getTransports?.() || [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
|
||||
|
||||
btn.innerHTML = "Device Registered";
|
||||
btn.className = "btn btn--success";
|
||||
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
|
||||
setTimeout(close, 1500);
|
||||
} catch (e: any) {
|
||||
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
|
||||
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
#showAddRecoveryModal(): void {
|
||||
if (document.querySelector(".rstack-auth-overlay")) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "rstack-auth-overlay";
|
||||
const contacts: string[] = [];
|
||||
|
||||
const render = () => {
|
||||
const contactsHTML = contacts.length > 0
|
||||
? `<div class="contact-list">${contacts.map((c, i) => `
|
||||
<div class="contact-item">
|
||||
<span>${c.replace(/</g, "<")}</span>
|
||||
<button class="contact-remove" data-remove="${i}">×</button>
|
||||
</div>
|
||||
`).join("")}</div>`
|
||||
: "";
|
||||
|
||||
const thresholdHTML = contacts.length >= 2
|
||||
? `<div class="threshold-row">
|
||||
<label>Recovery threshold:</label>
|
||||
<select id="s-threshold">${
|
||||
Array.from({ length: contacts.length - 1 }, (_, i) => i + 2)
|
||||
.map(n => `<option value="${n}"${n === Math.ceil(contacts.length * 0.6) ? " selected" : ""}>${n} of ${contacts.length}</option>`)
|
||||
.join("")
|
||||
}</select>
|
||||
<span class="threshold-hint">contacts needed to recover</span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
overlay.innerHTML = `
|
||||
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<button class="close-btn" data-action="close">×</button>
|
||||
<h2>Social Recovery</h2>
|
||||
<p>Choose trusted contacts who can help recover your account.</p>
|
||||
<div class="input-row">
|
||||
<input class="input input--inline" id="s-contact" type="text" placeholder="Enter username" />
|
||||
<button class="btn btn--small btn--primary" data-action="add-contact">Add</button>
|
||||
</div>
|
||||
${contactsHTML}
|
||||
${thresholdHTML}
|
||||
<div class="actions" style="margin-top:1rem">
|
||||
${contacts.length >= 2 ? '<button class="btn btn--primary" data-action="save-recovery">Save Recovery Settings</button>' : ""}
|
||||
</div>
|
||||
<div class="error" id="s-error"></div>
|
||||
${contacts.length < 2 ? '<div class="info-text">Add at least 2 trusted contacts to enable social recovery.</div>' : ""}
|
||||
</div>
|
||||
`;
|
||||
attach();
|
||||
};
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
const attach = () => {
|
||||
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
overlay.querySelector('[data-action="add-contact"]')?.addEventListener("click", () => {
|
||||
const input = overlay.querySelector("#s-contact") as HTMLInputElement;
|
||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||
const name = input.value.trim();
|
||||
if (!name) { err.textContent = "Enter a username."; input.focus(); return; }
|
||||
if (contacts.includes(name)) { err.textContent = "Already added."; return; }
|
||||
contacts.push(name);
|
||||
render();
|
||||
setTimeout(() => (overlay.querySelector("#s-contact") as HTMLInputElement)?.focus(), 50);
|
||||
});
|
||||
|
||||
overlay.querySelector("#s-contact")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-contact"]') as HTMLElement)?.click();
|
||||
});
|
||||
|
||||
overlay.querySelectorAll("[data-remove]").forEach(el => {
|
||||
el.addEventListener("click", () => {
|
||||
contacts.splice(parseInt((el as HTMLElement).dataset.remove!, 10), 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
overlay.querySelector('[data-action="save-recovery"]')?.addEventListener("click", async () => {
|
||||
const err = overlay.querySelector("#s-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="save-recovery"]') as HTMLButtonElement;
|
||||
const threshold = parseInt((overlay.querySelector("#s-threshold") as HTMLSelectElement)?.value || "2", 10);
|
||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Saving...';
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/account/recovery/setup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
||||
body: JSON.stringify({ contacts, threshold }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to save recovery settings");
|
||||
btn.innerHTML = "Saved";
|
||||
btn.className = "btn btn--success";
|
||||
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "recovery-configured", contacts, threshold } }));
|
||||
setTimeout(close, 1500);
|
||||
} catch (e: any) {
|
||||
btn.disabled = false; btn.innerHTML = "Save Recovery Settings";
|
||||
err.textContent = e.message;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
}
|
||||
|
||||
static define(tag = "rstack-identity") {
|
||||
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
|
||||
}
|
||||
|
|
@ -456,6 +760,13 @@ const STYLES = `
|
|||
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.dropdown-item--danger { color: #ef4444 !important; }
|
||||
|
||||
.dropdown-header {
|
||||
padding: 10px 16px 6px; font-size: 0.8rem; font-weight: 700;
|
||||
letter-spacing: 0.02em; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; max-width: 200px;
|
||||
}
|
||||
.user.light .dropdown-header { color: #1e293b; }
|
||||
.user.dark .dropdown-header { color: #e2e8f0; }
|
||||
.dropdown-divider { height: 1px; margin: 4px 0; }
|
||||
.user.light .dropdown-divider { background: rgba(0,0,0,0.08); }
|
||||
.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); }
|
||||
|
|
@ -507,7 +818,55 @@ const MODAL_STYLES = `
|
|||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
vertical-align: middle; margin-right: 6px;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute; top: 12px; right: 16px;
|
||||
background: none; border: none; color: #64748b; font-size: 1.5rem;
|
||||
cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.close-btn:hover { color: white; background: rgba(255,255,255,0.1); }
|
||||
.auth-modal { position: relative; }
|
||||
.actions--stack { flex-direction: column; }
|
||||
.btn--outline {
|
||||
background: transparent; color: #94a3b8;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
padding: 12px 20px; border-radius: 8px; font-size: 0.95rem;
|
||||
font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn--outline:hover { border-color: #06b6d4; color: white; background: rgba(6,182,212,0.08); }
|
||||
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: #475569; }
|
||||
.learn-more a { color: #06b6d4; text-decoration: none; }
|
||||
.learn-more a:hover { text-decoration: underline; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
const SETTINGS_STYLES = `
|
||||
.info-text { margin-top: 1rem; font-size: 0.8rem; color: #475569; line-height: 1.5; }
|
||||
.btn--success { background: #059669 !important; color: white; cursor: default; }
|
||||
.btn--small { padding: 10px 16px; flex: none; }
|
||||
.input-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.input--inline { flex: 1; margin-bottom: 0; }
|
||||
.contact-list { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1); font-size: 0.9rem; color: #e2e8f0;
|
||||
}
|
||||
.contact-remove {
|
||||
background: none; border: none; color: #64748b; font-size: 1.2rem;
|
||||
cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1;
|
||||
}
|
||||
.contact-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
|
||||
.threshold-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 12px;
|
||||
font-size: 0.85rem; color: #94a3b8;
|
||||
}
|
||||
.threshold-row label { white-space: nowrap; }
|
||||
.threshold-row select {
|
||||
padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15); color: white; font-size: 0.85rem;
|
||||
}
|
||||
.threshold-hint { color: #64748b; font-size: 0.8rem; }
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
<style>${STYLES}</style>
|
||||
<div class="switcher">
|
||||
<button class="trigger" id="trigger">
|
||||
<span class="slash">/</span>
|
||||
<span class="space-name">${name || "Select space"}</span>
|
||||
<span class="caret">▾</span>
|
||||
</button>
|
||||
|
|
@ -108,9 +107,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
|
||||
#visibilityInfo(s: SpaceInfo): { cls: string; label: string } {
|
||||
const v = s.visibility || "public_read";
|
||||
if (v === "members_only") return { cls: "vis-private", label: "PRIVATE" };
|
||||
if (v === "authenticated") return { cls: "vis-permissioned", label: "PERMISSIONED" };
|
||||
return { cls: "vis-public", label: "PUBLIC" };
|
||||
if (v === "members_only") return { cls: "vis-private", label: "\uD83D\uDD12" };
|
||||
if (v === "authenticated") return { cls: "vis-permissioned", label: "\uD83D\uDD11" };
|
||||
return { cls: "vis-public", label: "\uD83D\uDD13" };
|
||||
}
|
||||
|
||||
#renderMenu(menu: HTMLElement, current: string) {
|
||||
|
|
@ -191,15 +190,13 @@ const STYLES = `
|
|||
.trigger {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 8px; border: none;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer;
|
||||
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.15s; color: inherit;
|
||||
}
|
||||
:host-context([data-theme="light"]) .trigger { background: rgba(0,0,0,0.05); color: #374151; }
|
||||
:host-context([data-theme="light"]) .trigger { background: rgba(0,0,0,0.05); color: #0f172a; }
|
||||
:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); }
|
||||
:host-context([data-theme="dark"]) .trigger { background: rgba(255,255,255,0.08); color: #e2e8f0; }
|
||||
:host-context([data-theme="dark"]) .trigger:hover { background: rgba(255,255,255,0.12); }
|
||||
|
||||
.slash { opacity: 0.4; font-weight: 300; margin-right: 2px; }
|
||||
.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.caret { font-size: 0.7em; opacity: 0.6; }
|
||||
|
||||
|
|
@ -240,9 +237,8 @@ const STYLES = `
|
|||
|
||||
/* Visibility badge */
|
||||
.item-vis {
|
||||
font-size: 0.55rem; font-weight: 700; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
|
||||
letter-spacing: 0.04em; line-height: 1.4;
|
||||
font-size: 0.9rem; padding: 2px 4px; border-radius: 4px; flex-shrink: 0;
|
||||
line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.item-vis.vis-public { background: rgba(52,211,153,0.15); color: #34d399; }
|
||||
.item-vis.vis-private { background: rgba(248,113,113,0.15); color: #f87171; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue