Merge branch 'dev'
This commit is contained in:
commit
2c710bef68
|
|
@ -134,7 +134,8 @@ export class FolkCampaignWizard extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||||
const token = (window as any).__authToken || localStorage.getItem('encryptid-token') || '';
|
let token = '';
|
||||||
|
try { const s = JSON.parse(localStorage.getItem('encryptid_session') || ''); token = s.accessToken || ''; } catch {}
|
||||||
return fetch(`${this.basePath}${path}`, {
|
return fetch(`${this.basePath}${path}`, {
|
||||||
...opts,
|
...opts,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,9 @@ interface BalanceItem {
|
||||||
export async function enrichWithPrices(
|
export async function enrichWithPrices(
|
||||||
balances: BalanceItem[],
|
balances: BalanceItem[],
|
||||||
chainId: string,
|
chainId: string,
|
||||||
|
options?: { filterSpam?: boolean },
|
||||||
): Promise<BalanceItem[]> {
|
): Promise<BalanceItem[]> {
|
||||||
// Skip testnets and unsupported chains
|
// Skip testnets and unsupported chains — no CoinGecko data to verify against
|
||||||
if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances;
|
if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances;
|
||||||
|
|
||||||
// Check if any balance actually needs pricing
|
// Check if any balance actually needs pricing
|
||||||
|
|
@ -165,7 +166,7 @@ export async function enrichWithPrices(
|
||||||
try {
|
try {
|
||||||
const priceData = await fetchChainPrices(chainId, tokenAddresses);
|
const priceData = await fetchChainPrices(chainId, tokenAddresses);
|
||||||
|
|
||||||
return balances.map((b) => {
|
const enriched = balances.map((b) => {
|
||||||
// Skip if already has a real fiat value
|
// Skip if already has a real fiat value
|
||||||
if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) {
|
if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) {
|
||||||
return b;
|
return b;
|
||||||
|
|
@ -194,6 +195,21 @@ export async function enrichWithPrices(
|
||||||
fiatBalance: String(fiatValue),
|
fiatBalance: String(fiatValue),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options?.filterSpam) {
|
||||||
|
return enriched.filter((b) => {
|
||||||
|
// Native tokens always pass
|
||||||
|
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true;
|
||||||
|
// CoinGecko recognized this token
|
||||||
|
if (priceData.prices.has(b.tokenAddress.toLowerCase())) return true;
|
||||||
|
// Safe API independently valued it at >= $1
|
||||||
|
if (parseFloat(b.fiatBalance || "0") >= 1) return true;
|
||||||
|
// Unknown ERC-20 with no verified value = spam
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e);
|
console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e);
|
||||||
return balances;
|
return balances;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
|
||||||
fiatBalance: item.fiatBalance || "0",
|
fiatBalance: item.fiatBalance || "0",
|
||||||
fiatConversion: item.fiatConversion || "0",
|
fiatConversion: item.fiatConversion || "0",
|
||||||
}));
|
}));
|
||||||
const enriched = (await enrichWithPrices(data, chainId))
|
const enriched = (await enrichWithPrices(data, chainId, { filterSpam: true }))
|
||||||
.filter(b => BigInt(b.balance || "0") > 0n);
|
.filter(b => BigInt(b.balance || "0") > 0n);
|
||||||
c.header("Cache-Control", "public, max-age=30");
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
return c.json(enriched);
|
return c.json(enriched);
|
||||||
|
|
@ -600,7 +600,7 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
|
||||||
|
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
const enriched = await enrichWithPrices(balances, chainId);
|
const enriched = await enrichWithPrices(balances, chainId, { filterSpam: true });
|
||||||
c.header("Cache-Control", "public, max-age=30");
|
c.header("Cache-Control", "public, max-age=30");
|
||||||
return c.json(enriched);
|
return c.json(enriched);
|
||||||
});
|
});
|
||||||
|
|
@ -661,7 +661,7 @@ routes.get("/api/eoa/:address/all-balances", async (c) => {
|
||||||
|
|
||||||
await Promise.allSettled(tokenPromises);
|
await Promise.allSettled(tokenPromises);
|
||||||
if (chainBalances.length > 0) {
|
if (chainBalances.length > 0) {
|
||||||
const enriched = await enrichWithPrices(chainBalances, chainId);
|
const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true });
|
||||||
results.push({ chainId, chainName: info.name, balances: enriched });
|
results.push({ chainId, chainName: info.name, balances: enriched });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -700,7 +700,7 @@ routes.get("/api/safe/:address/all-balances", async (c) => {
|
||||||
})).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n);
|
})).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n);
|
||||||
|
|
||||||
if (chainBalances.length > 0) {
|
if (chainBalances.length > 0) {
|
||||||
const enriched = await enrichWithPrices(chainBalances, chainId);
|
const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true });
|
||||||
results.push({ chainId, chainName: info.name, balances: enriched });
|
results.push({ chainId, chainName: info.name, balances: enriched });
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -1594,7 +1594,7 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: process.env.OLLAMA_MODEL || "qwen2.5:14b",
|
model: process.env.OLLAMA_MODEL || "qwen2.5-coder:7b",
|
||||||
prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`,
|
prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`,
|
||||||
stream: false,
|
stream: false,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1606,6 +1606,9 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
// Extract code block if wrapped in markdown
|
// Extract code block if wrapped in markdown
|
||||||
const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/);
|
const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/);
|
||||||
if (codeMatch) script = codeMatch[1].trim();
|
if (codeMatch) script = codeMatch[1].trim();
|
||||||
|
} else {
|
||||||
|
const errText = await llmRes.text().catch(() => "");
|
||||||
|
console.error(`[blender-gen] Ollama ${llmRes.status}: ${errText}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[blender-gen] LLM error:", e);
|
console.error("[blender-gen] LLM error:", e);
|
||||||
|
|
|
||||||
|
|
@ -413,12 +413,15 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
var session = JSON.parse(raw);
|
var session = JSON.parse(raw);
|
||||||
if (!session || !session.accessToken) return;
|
if (!session || !session.accessToken) return;
|
||||||
fetch('/' + '${escapeAttr(spaceSlug)}' + '/invite/accept', {
|
fetch('/api/spaces/' + encodeURIComponent('${escapeAttr(spaceSlug)}') + '/invite/accept', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
|
||||||
body: JSON.stringify({ inviteToken: inviteToken }),
|
body: JSON.stringify({ inviteToken: inviteToken }),
|
||||||
}).then(function(res) { return res.json(); }).then(function(data) {
|
}).then(function(res) { return res.json(); }).then(function(data) {
|
||||||
if (data.ok) { window.location.reload(); }
|
if (data.ok) {
|
||||||
|
var slug = data.spaceSlug || '${escapeAttr(spaceSlug)}';
|
||||||
|
window.location.href = 'https://' + slug + '.rspace.online';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2194,7 +2194,7 @@ spaces.post("/:slug/invite", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/rspace`,
|
actionUrl: `https://${slug}.rspace.online`,
|
||||||
metadata: { role },
|
metadata: { role },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
|
@ -2209,15 +2209,21 @@ spaces.post("/:slug/invite", async (c) => {
|
||||||
if (inviteTransport) {
|
if (inviteTransport) {
|
||||||
try {
|
try {
|
||||||
const spaceUrl = `https://${slug}.rspace.online`;
|
const spaceUrl = `https://${slug}.rspace.online`;
|
||||||
|
const inviterName = claims.username || "an admin";
|
||||||
await inviteTransport.sendMail({
|
await inviteTransport.sendMail({
|
||||||
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
|
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
|
||||||
to: body.email,
|
to: body.email,
|
||||||
subject: `You've been added to "${slug}" on rSpace`,
|
subject: `${inviterName} added you to "${slug}" on rSpace`,
|
||||||
html: [
|
html: `
|
||||||
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
|
||||||
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`,
|
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You've been added to ${slug}</h2>
|
||||||
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
|
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> added you to the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
|
||||||
].join("\n"),
|
<p style="color:#475569;line-height:1.6;">You now have access to all the collaborative tools in this space — notes, maps, voting, calendar, and more.</p>
|
||||||
|
<p style="text-align:center;margin:2rem 0;">
|
||||||
|
<a href="${spaceUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Open ${slug}</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#94a3b8;font-size:0.85rem;">rSpace — collaborative knowledge work</p>
|
||||||
|
</div>`,
|
||||||
});
|
});
|
||||||
} catch (emailErr: any) {
|
} catch (emailErr: any) {
|
||||||
console.error("Direct-add email notification failed:", emailErr.message);
|
console.error("Direct-add email notification failed:", emailErr.message);
|
||||||
|
|
@ -2309,7 +2315,7 @@ spaces.post("/:slug/members/add", async (c) => {
|
||||||
spaceSlug: slug,
|
spaceSlug: slug,
|
||||||
actorDid: claims.sub,
|
actorDid: claims.sub,
|
||||||
actorUsername: claims.username,
|
actorUsername: claims.username,
|
||||||
actionUrl: `/rspace`,
|
actionUrl: `https://${slug}.rspace.online`,
|
||||||
metadata: { role },
|
metadata: { role },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5407,22 +5407,28 @@ app.post('/api/invites/identity', async (c) => {
|
||||||
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
|
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
|
||||||
if (smtpTransport) {
|
if (smtpTransport) {
|
||||||
try {
|
try {
|
||||||
|
const spaceInfo = spaceSlug
|
||||||
|
? `<p style="color:#475569;line-height:1.6;">You'll automatically join the <strong>${escapeHtml(spaceSlug)}</strong> space as a <strong>${escapeHtml(spaceRole || 'member')}</strong>, with access to collaborative notes, maps, voting, calendar, and more.</p>`
|
||||||
|
: `<p style="color:#475569;line-height:1.6;">rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).</p>`;
|
||||||
|
const subjectLine = spaceSlug
|
||||||
|
? `${payload.username} invited you to join "${spaceSlug}" on rSpace`
|
||||||
|
: `${payload.username} invited you to join rSpace`;
|
||||||
await smtpTransport.sendMail({
|
await smtpTransport.sendMail({
|
||||||
from: CONFIG.smtp.from,
|
from: CONFIG.smtp.from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `${payload.username} invited you to join rSpace`,
|
subject: subjectLine,
|
||||||
html: `
|
html: `
|
||||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
|
||||||
<h2 style="color: #1a1a2e;">You've been invited to rSpace</h2>
|
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}</h2>
|
||||||
<p><strong>${escapeHtml(payload.username)}</strong> wants you to join the rSpace ecosystem — a suite of privacy-first tools powered by passkey authentication.</p>
|
<p style="color:#475569;line-height:1.6;"><strong>${escapeHtml(payload.username)}</strong> wants you to join${spaceSlug ? ` the <strong>${escapeHtml(spaceSlug)}</strong> space on` : ''} rSpace.</p>
|
||||||
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
|
${message ? `<blockquote style="border-left:3px solid #7c3aed;padding:0.5rem 1rem;margin:1rem 0;color:#475569;background:#f8fafc;border-radius:0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
|
||||||
<p>Click below to claim your identity and set up your passkey:</p>
|
${spaceInfo}
|
||||||
<p style="text-align: center; margin: 2rem 0;">
|
<p style="color:#475569;line-height:1.6;">Click below to create your account and set up your passkey:</p>
|
||||||
<a href="${joinLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Claim your rSpace</a>
|
<p style="text-align:center;margin:2rem 0;">
|
||||||
|
<a href="${joinLink}" style="display:inline-block;padding:0.85rem 2rem;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;font-size:1rem;">Accept Invitation</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="color: #94a3b8; font-size: 0.85rem;">This invite expires in 7 days. If you didn't expect this, you can safely ignore it.</p>
|
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. No passwords needed — you'll use a passkey to sign in securely.</p>
|
||||||
</div>
|
</div>`,
|
||||||
`,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('EncryptID: Failed to send invite email:', (err as Error).message);
|
console.error('EncryptID: Failed to send invite email:', (err as Error).message);
|
||||||
|
|
|
||||||
|
|
@ -1406,6 +1406,84 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* First-time forgotten explainer tooltip */
|
||||||
|
#forgotten-explainer {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--rs-bg-surface, #1e293b);
|
||||||
|
border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||||
|
z-index: 300000;
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
animation: explainer-slide-up 0.35s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes explainer-slide-up {
|
||||||
|
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
}
|
||||||
|
#forgotten-explainer.visible { display: block; }
|
||||||
|
#forgotten-explainer h4 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
}
|
||||||
|
#forgotten-explainer p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--rs-text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
#forgotten-explainer .tip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
#forgotten-explainer .tip-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
#forgotten-explainer .tip-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--rs-text-secondary, #94a3b8);
|
||||||
|
}
|
||||||
|
#forgotten-explainer .tip-label strong {
|
||||||
|
color: var(--rs-text-primary, #e2e8f0);
|
||||||
|
}
|
||||||
|
#forgotten-explainer .proto-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(124,58,237,0.15);
|
||||||
|
color: #a78bfa;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
#forgotten-explainer .dismiss-btn {
|
||||||
|
display: block;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
padding: 7px 20px;
|
||||||
|
background: rgba(20,184,166,0.12);
|
||||||
|
border: 1px solid rgba(20,184,166,0.25);
|
||||||
|
color: #14b8a6;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#forgotten-explainer .dismiss-btn:hover {
|
||||||
|
background: rgba(20,184,166,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Cross-space shape styling — colored border + source badge */
|
/* Cross-space shape styling — colored border + source badge */
|
||||||
.rspace-cross-space-shape {
|
.rspace-cross-space-shape {
|
||||||
outline: 2px dashed rgba(99, 102, 241, 0.5) !important;
|
outline: 2px dashed rgba(99, 102, 241, 0.5) !important;
|
||||||
|
|
@ -2128,6 +2206,24 @@
|
||||||
<!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
|
<!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
|
||||||
<button id="toggle-hide-forgotten" style="display:none" title="Hide forgotten items"></button>
|
<button id="toggle-hide-forgotten" style="display:none" title="Hide forgotten items"></button>
|
||||||
|
|
||||||
|
<div id="forgotten-explainer">
|
||||||
|
<h4>👻 Faded shapes are "forgotten"</h4>
|
||||||
|
<p>When you or others close a shape, it doesn't disappear — it fades. This is <strong>collective memory</strong>.</p>
|
||||||
|
<div class="tip-row">
|
||||||
|
<span class="tip-icon">🖱</span>
|
||||||
|
<span class="tip-label"><strong>Right-click</strong> a faded shape to <strong>Remember</strong> it (restore) or <strong>Forget permanently</strong> (delete)</span>
|
||||||
|
</div>
|
||||||
|
<div class="tip-row">
|
||||||
|
<span class="tip-icon">👁</span>
|
||||||
|
<span class="tip-label">Toggle <strong>Hide Forgotten</strong> in your profile menu to show/hide faded shapes</span>
|
||||||
|
</div>
|
||||||
|
<div class="tip-row">
|
||||||
|
<span class="tip-icon">🔮</span>
|
||||||
|
<span class="tip-label">The <strong>Collective Memory</strong> graph <span class="proto-badge">prototype</span> visualizes what the group remembers vs. forgets</span>
|
||||||
|
</div>
|
||||||
|
<button class="dismiss-btn" id="forgotten-explainer-dismiss">Got it</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="memory-panel">
|
<div id="memory-panel">
|
||||||
<div id="memory-panel-header">
|
<div id="memory-panel-header">
|
||||||
<h3>💭 Recent Changes</h3>
|
<h3>💭 Recent Changes</h3>
|
||||||
|
|
@ -6068,6 +6164,33 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
if (memoryPanel.classList.contains("open")) renderMemoryPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// First-time forgotten explainer tooltip
|
||||||
|
{
|
||||||
|
const SEEN_KEY = 'rspace_forgotten_explainer_seen';
|
||||||
|
let explainerShown = false;
|
||||||
|
|
||||||
|
function showForgottenExplainer() {
|
||||||
|
if (explainerShown || localStorage.getItem(SEEN_KEY)) return;
|
||||||
|
explainerShown = true;
|
||||||
|
// Delay slightly so the faded shape is visible first
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById('forgotten-explainer');
|
||||||
|
if (el) el.classList.add('visible');
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('forgotten-explainer-dismiss')?.addEventListener('click', () => {
|
||||||
|
localStorage.setItem(SEEN_KEY, '1');
|
||||||
|
const el = document.getElementById('forgotten-explainer');
|
||||||
|
if (el) el.classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
sync.addEventListener("shape-state-changed", (e) => {
|
||||||
|
if (e.detail?.state === 'forgotten') showForgottenExplainer();
|
||||||
|
});
|
||||||
|
sync.addEventListener("shape-forgotten", () => showForgottenExplainer());
|
||||||
|
}
|
||||||
|
|
||||||
// Re-dispatch comment-pins-changed on window so header bell can update
|
// Re-dispatch comment-pins-changed on window so header bell can update
|
||||||
sync.addEventListener("comment-pins-changed", () => {
|
sync.addEventListener("comment-pins-changed", () => {
|
||||||
window.dispatchEvent(new CustomEvent("comment-pins-changed"));
|
window.dispatchEvent(new CustomEvent("comment-pins-changed"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue