Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 13:16:10 -08:00
commit f52b6a5085
29 changed files with 2766 additions and 801 deletions

View File

@ -982,6 +982,16 @@ export class CommunitySync extends EventTarget {
if (data.hashtags !== undefined) post.hashtags = data.hashtags;
if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber;
}
// Update workflow-block properties
if (data.type === "folk-workflow-block") {
const block = shape as any;
if (data.blockType !== undefined && block.blockType !== data.blockType) block.blockType = data.blockType;
if (data.label !== undefined && block.label !== data.label) block.label = data.label;
if (data.inputs !== undefined) block.inputs = data.inputs;
if (data.outputs !== undefined) block.outputs = data.outputs;
if (data.config !== undefined) block.config = data.config;
}
}
/**

View File

@ -289,6 +289,32 @@ const styles = css`
color: #94a3b8;
font-size: 12px;
}
.wrapper { position: relative; height: 100%; }
.results-drawer {
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
background: white; border-radius: 0 8px 8px 0;
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
overflow-y: auto; display: none; flex-direction: column;
font-size: 12px; z-index: 10;
}
.drawer-open .results-drawer { display: flex; }
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
.drawer-heading {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
}
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
.stat-label { color: #64748b; }
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
`;
// -- Data types --
@ -397,6 +423,92 @@ export function instantRunoff(
return rounds;
}
export function kendallTauB(rankings: UserRanking[], options: RankOption[]): number {
if (rankings.length < 2) return 0;
const optIds = options.map((o) => o.id);
let totalTau = 0;
let pairs = 0;
for (let i = 0; i < rankings.length; i++) {
for (let j = i + 1; j < rankings.length; j++) {
const posA = new Map(rankings[i].ordering.map((id, idx) => [id, idx]));
const posB = new Map(rankings[j].ordering.map((id, idx) => [id, idx]));
let concordant = 0;
let discordant = 0;
for (let a = 0; a < optIds.length; a++) {
for (let b = a + 1; b < optIds.length; b++) {
const dA = (posA.get(optIds[a]) ?? 0) - (posA.get(optIds[b]) ?? 0);
const dB = (posB.get(optIds[a]) ?? 0) - (posB.get(optIds[b]) ?? 0);
if (dA * dB > 0) concordant++;
else if (dA * dB < 0) discordant++;
}
}
const total = concordant + discordant;
if (total > 0) {
totalTau += (concordant - discordant) / total;
pairs++;
}
}
}
return pairs > 0 ? totalTau / pairs : 0;
}
export function positionFrequency(
rankings: UserRanking[],
options: RankOption[],
): Map<string, number[]> {
const result = new Map<string, number[]>();
const n = options.length;
for (const opt of options) result.set(opt.id, new Array(n).fill(0));
for (const r of rankings) {
for (let i = 0; i < r.ordering.length; i++) {
const counts = result.get(r.ordering[i]);
if (counts && i < counts.length) counts[i]++;
}
}
return result;
}
export function headToHead(
rankings: UserRanking[],
options: RankOption[],
): Map<string, Map<string, number>> {
const result = new Map<string, Map<string, number>>();
for (const a of options) {
result.set(a.id, new Map());
for (const b of options) {
if (a.id !== b.id) result.get(a.id)!.set(b.id, 0);
}
}
for (const r of rankings) {
const pos = new Map(r.ordering.map((id, idx) => [id, idx]));
for (const a of options) {
for (const b of options) {
if (a.id === b.id) continue;
if ((pos.get(a.id) ?? Infinity) < (pos.get(b.id) ?? Infinity)) {
result.get(a.id)!.set(b.id, result.get(a.id)!.get(b.id)! + 1);
}
}
}
}
return result;
}
export function condorcetWinner(rankings: UserRanking[], options: RankOption[]): string | null {
const h2h = headToHead(rankings, options);
for (const a of options) {
let wins = true;
for (const b of options) {
if (a.id === b.id) continue;
if ((h2h.get(a.id)?.get(b.id) ?? 0) <= (h2h.get(b.id)?.get(a.id) ?? 0)) {
wins = false;
break;
}
}
if (wins) return a.id;
}
return null;
}
// -- Component --
declare global {
@ -424,15 +536,18 @@ export class FolkChoiceRank extends FolkShape {
#userId = "";
#userName = "";
#activeTab: "rank" | "results" = "rank";
#drawerOpen = false;
// Drag state
#dragIdx: number | null = null;
#myOrdering: string[] = [];
// DOM refs
#wrapperEl: HTMLElement | null = null;
#bodyEl: HTMLElement | null = null;
#rankPanel: HTMLElement | null = null;
#resultsPanel: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null;
get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
@ -505,6 +620,7 @@ export class FolkChoiceRank extends FolkShape {
this.#syncMyOrdering();
const wrapper = document.createElement("div");
wrapper.className = "wrapper";
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
@ -512,6 +628,7 @@ export class FolkChoiceRank extends FolkShape {
<span class="title-text">Rank</span>
</span>
<div class="header-actions">
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
@ -527,6 +644,7 @@ export class FolkChoiceRank extends FolkShape {
<button class="add-btn">Add</button>
</div>
</div>
<div class="results-drawer"></div>
<div class="username-prompt" style="display: none;">
<p>Enter your name to rank:</p>
<input type="text" class="username-input" placeholder="Your name..." />
@ -538,9 +656,21 @@ export class FolkChoiceRank extends FolkShape {
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#wrapperEl = wrapper;
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
this.#rankPanel = wrapper.querySelector(".rank-panel") as HTMLElement;
this.#resultsPanel = wrapper.querySelector(".results-panel") as HTMLElement;
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
drawerToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#drawerOpen = !this.#drawerOpen;
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
drawerToggle.classList.toggle("active", this.#drawerOpen);
if (this.#drawerOpen) this.#renderDrawer();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -607,6 +737,7 @@ export class FolkChoiceRank extends FolkShape {
#render() {
if (this.#activeTab === "rank") this.#renderRankList();
else this.#renderResults();
if (this.#drawerOpen) this.#renderDrawer();
}
#renderRankList() {
@ -756,6 +887,88 @@ export class FolkChoiceRank extends FolkShape {
`<div class="voters-count">${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}</div>`;
}
#renderDrawer() {
if (!this.#drawerEl) return;
const optMap = new Map(this.#options.map((o) => [o.id, o]));
// Borda count
const borda = bordaCount(this.#rankings, this.#options);
const maxBorda = Math.max(1, ...borda.values());
const bordaSorted = [...borda.entries()].sort((a, b) => b[1] - a[1]);
let resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Group Results</div>';
for (const [optId, pts] of bordaSorted) {
const opt = optMap.get(optId);
if (!opt) continue;
const pct = (pts / maxBorda) * 100;
resultsHtml += `<div class="drawer-bar-row">
<span class="drawer-bar-label">${this.#escapeHtml(opt.label)}</span>
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${pct}%;background:#4f46e5"></div></div>
<span class="drawer-bar-val">${pts}</span>
</div>`;
}
// IRV winner
const irvRounds = instantRunoff(this.#rankings, this.#options);
if (irvRounds.length > 0) {
const lastRound = irvRounds[irvRounds.length - 1];
const winnerId = Object.keys(lastRound.counts)[0];
const winLabel = optMap.get(winnerId)?.label || winnerId;
resultsHtml += `<div class="stat-row"><span class="stat-label">IRV Winner</span><span class="stat-value">${this.#escapeHtml(winLabel)}</span></div>`;
}
// Condorcet winner
const cw = condorcetWinner(this.#rankings, this.#options);
if (cw) {
const cwLabel = optMap.get(cw)?.label || cw;
resultsHtml += `<div class="stat-row"><span class="stat-label">Condorcet Winner</span><span class="stat-value" style="color:#22c55e">${this.#escapeHtml(cwLabel)}</span></div>`;
} else {
resultsHtml += `<div class="stat-row"><span class="stat-label">Condorcet Winner</span><span class="stat-value" style="color:#94a3b8">None</span></div>`;
}
resultsHtml += "</div>";
// Statistics
const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size;
const tau = kendallTauB(this.#rankings, this.#options);
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
statsHtml += `<div class="stat-row"><span class="stat-label">Voters</span><span class="stat-value">${uniqueVoters}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Agreement (Kendall)</span><span class="stat-value">${tau.toFixed(2)}</span></div>`;
// Head-to-head
const h2h = headToHead(this.#rankings, this.#options);
for (const a of this.#options) {
for (const b of this.#options) {
if (a.id >= b.id) continue;
const aWins = h2h.get(a.id)?.get(b.id) ?? 0;
const bWins = h2h.get(b.id)?.get(a.id) ?? 0;
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(a.label)} v ${this.#escapeHtml(b.label)}</span><span class="stat-value">${aWins}-${bWins}</span></div>`;
}
}
statsHtml += "</div>";
// Participants
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
for (const r of this.#rankings) {
const ago = this.#timeAgo(r.timestamp);
participantsHtml += `<div class="participant-row">
<span class="participant-dot" style="background:#4f46e5"></span>
<span>${this.#escapeHtml(r.userName)}</span>
<span style="margin-left:auto;color:#94a3b8">${ago}</span>
</div>`;
}
participantsHtml += "</div>";
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
}
#timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;

View File

@ -258,6 +258,32 @@ const styles = css`
color: #94a3b8;
font-size: 12px;
}
.wrapper { position: relative; height: 100%; }
.results-drawer {
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
background: white; border-radius: 0 8px 8px 0;
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
overflow-y: auto; display: none; flex-direction: column;
font-size: 12px; z-index: 10;
}
.drawer-open .results-drawer { display: flex; }
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
.drawer-heading {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
}
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
.stat-label { color: #64748b; }
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
`;
// -- Data types --
@ -377,6 +403,46 @@ export function polygonArea(vertices: { x: number; y: number }[]): number {
return Math.abs(area) / 2;
}
export function criterionStats(
scores: SpiderScore[],
criterionId: string,
optionId: string,
): { mean: number; stdDev: number; min: number; max: number; count: number } {
const vals = scores
.filter((s) => s.criterionId === criterionId && s.optionId === optionId)
.map((s) => s.value);
if (vals.length === 0) return { mean: 0, stdDev: 0, min: 0, max: 0, count: 0 };
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
const variance = vals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / vals.length;
return {
mean,
stdDev: Math.sqrt(variance),
min: Math.min(...vals),
max: Math.max(...vals),
count: vals.length,
};
}
export function consensusIndex(
scores: SpiderScore[],
criteria: SpiderCriterion[],
optionId: string,
): number {
if (criteria.length === 0) return 0;
let totalStdDev = 0;
let counted = 0;
for (const c of criteria) {
const stats = criterionStats(scores, c.id, optionId);
if (stats.count > 0) {
totalStdDev += stats.stdDev;
counted++;
}
}
if (counted === 0) return 0;
const avgStdDev = totalStdDev / counted;
return Math.max(0, 1 - avgStdDev / 4.5);
}
// -- Component --
declare global {
@ -413,14 +479,17 @@ export class FolkChoiceSpider extends FolkShape {
#userId = "";
#userName = "";
#selectedOptionId = "";
#drawerOpen = false;
// DOM refs
#wrapperEl: HTMLElement | null = null;
#bodyEl: HTMLElement | null = null;
#chartArea: HTMLElement | null = null;
#slidersEl: HTMLElement | null = null;
#legendEl: HTMLElement | null = null;
#summaryEl: HTMLElement | null = null;
#optionTabsEl: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null;
get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
@ -493,6 +562,7 @@ export class FolkChoiceSpider extends FolkShape {
if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id;
const wrapper = document.createElement("div");
wrapper.className = "wrapper";
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
@ -500,6 +570,7 @@ export class FolkChoiceSpider extends FolkShape {
<span class="title-text">Spider</span>
</span>
<div class="header-actions">
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
@ -520,6 +591,7 @@ export class FolkChoiceSpider extends FolkShape {
</div>
</div>
</div>
<div class="results-drawer"></div>
<div class="username-prompt" style="display: none;">
<p>Enter your name to score:</p>
<input type="text" class="username-input" placeholder="Your name..." />
@ -531,12 +603,23 @@ export class FolkChoiceSpider extends FolkShape {
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#wrapperEl = wrapper;
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
this.#chartArea = wrapper.querySelector(".chart-area") as HTMLElement;
this.#slidersEl = wrapper.querySelector(".sliders") as HTMLElement;
this.#legendEl = wrapper.querySelector(".legend") as HTMLElement;
this.#summaryEl = wrapper.querySelector(".score-summary") as HTMLElement;
this.#optionTabsEl = wrapper.querySelector(".option-tabs") as HTMLElement;
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
drawerToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#drawerOpen = !this.#drawerOpen;
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
drawerToggle.classList.toggle("active", this.#drawerOpen);
if (this.#drawerOpen) this.#renderDrawer();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
@ -608,6 +691,7 @@ export class FolkChoiceSpider extends FolkShape {
this.#renderSliders();
this.#renderLegend();
this.#renderSummary();
if (this.#drawerOpen) this.#renderDrawer();
}
#renderOptionTabs() {
@ -771,6 +855,93 @@ export class FolkChoiceSpider extends FolkShape {
}
}
#renderDrawer() {
if (!this.#drawerEl) return;
// Group results: weighted mean per option
const results = this.#options.map((opt) => ({
id: opt.id,
label: opt.label,
score: weightedMeanScore(this.#scores, this.#criteria, opt.id),
}));
results.sort((a, b) => b.score - a.score);
const maxScore = Math.max(1, ...results.map((r) => r.score));
let resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Group Results</div>';
for (const r of results) {
const pct = (r.score / maxScore) * 100;
resultsHtml += `<div class="drawer-bar-row">
<span class="drawer-bar-label">${this.#escapeHtml(r.label)}</span>
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${pct}%;background:#059669"></div></div>
<span class="drawer-bar-val">${r.score.toFixed(1)}</span>
</div>`;
}
if (results.length >= 2 && results[0].score > 0) {
const margin = results[0].score - results[1].score;
resultsHtml += `<div class="stat-row"><span class="stat-label">Margin</span><span class="stat-value">${margin.toFixed(1)}</span></div>`;
}
resultsHtml += "</div>";
// Statistics
const allUsers = new Set(this.#scores.map((s) => s.userId));
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
statsHtml += `<div class="stat-row"><span class="stat-label">Participants</span><span class="stat-value">${allUsers.size}</span></div>`;
for (const opt of this.#options) {
const ci = consensusIndex(this.#scores, this.#criteria, opt.id);
const optScorers = new Set(this.#scores.filter((s) => s.optionId === opt.id).map((s) => s.userId));
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(opt.label)} consensus</span><span class="stat-value">${(ci * 100).toFixed(0)}%</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(opt.label)} scorers</span><span class="stat-value">${optScorers.size}</span></div>`;
}
for (const c of this.#criteria) {
const allVals: number[] = [];
for (const opt of this.#options) {
const stats = criterionStats(this.#scores, c.id, opt.id);
if (stats.count > 0) allVals.push(stats.mean);
}
if (allVals.length > 0) {
const avg = allVals.reduce((a, b) => a + b, 0) / allVals.length;
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(c.label)} avg</span><span class="stat-value">${avg.toFixed(1)}</span></div>`;
}
}
statsHtml += "</div>";
// Participants
const userMap = new Map<string, { name: string; optionsScored: number; lastActive: number }>();
for (const s of this.#scores) {
const u = userMap.get(s.userId) || { name: s.userName, optionsScored: 0, lastActive: 0 };
u.name = s.userName;
u.lastActive = Math.max(u.lastActive, s.timestamp);
userMap.set(s.userId, u);
}
for (const [uid, u] of userMap) {
const opts = new Set(this.#scores.filter((s) => s.userId === uid).map((s) => s.optionId));
u.optionsScored = opts.size;
}
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
for (const [uid, u] of userMap) {
const ago = this.#timeAgo(u.lastActive);
participantsHtml += `<div class="participant-row">
<span class="participant-dot" style="background:${userColor(uid)}"></span>
<span>${this.#escapeHtml(u.name)}</span>
<span style="margin-left:auto;color:#94a3b8">${u.optionsScored} opt, ${ago}</span>
</div>`;
}
participantsHtml += "</div>";
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
}
#timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;

View File

@ -267,6 +267,32 @@ const styles = css`
font-weight: 500;
font-size: 13px;
}
.wrapper { position: relative; height: 100%; }
.results-drawer {
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
background: white; border-radius: 0 8px 8px 0;
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
overflow-y: auto; display: none; flex-direction: column;
font-size: 12px; z-index: 10;
}
.drawer-open .results-drawer { display: flex; }
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
.drawer-heading {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
}
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
.stat-label { color: #64748b; }
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
`;
// -- Data types --
@ -312,6 +338,35 @@ export function quadraticCost(allocations: Record<string, number>): number {
return total;
}
export function giniCoefficient(tally: Map<string, number>): number {
const vals = [...tally.values()].sort((a, b) => a - b);
const n = vals.length;
if (n === 0) return 0;
const total = vals.reduce((a, b) => a + b, 0);
if (total === 0) return 0;
let sum = 0;
for (let i = 0; i < n; i++) {
sum += (2 * (i + 1) - n - 1) * vals[i];
}
return sum / (n * total);
}
export function effectiveVotes(
votes: UserVote[],
options: VoteOption[],
): Map<string, number> {
const result = new Map<string, number>();
for (const opt of options) result.set(opt.id, 0);
for (const v of votes) {
for (const [optId, count] of Object.entries(v.allocations)) {
if (result.has(optId)) {
result.set(optId, result.get(optId)! + Math.sqrt(count));
}
}
}
return result;
}
// -- Component --
declare global {
@ -340,12 +395,15 @@ export class FolkChoiceVote extends FolkShape {
#votes: UserVote[] = [];
#userId = "";
#userName = "";
#drawerOpen = false;
// DOM refs
#wrapperEl: HTMLElement | null = null;
#bodyEl: HTMLElement | null = null;
#optionsEl: HTMLElement | null = null;
#budgetEl: HTMLElement | null = null;
#votersEl: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null;
get title() { return this.#title; }
set title(v: string) {
@ -407,6 +465,7 @@ export class FolkChoiceVote extends FolkShape {
this.#ensureIdentity();
const wrapper = document.createElement("div");
wrapper.className = "wrapper";
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
@ -414,6 +473,7 @@ export class FolkChoiceVote extends FolkShape {
<span class="title-text">Poll</span>
</span>
<div class="header-actions">
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button>
</div>
</div>
@ -431,6 +491,7 @@ export class FolkChoiceVote extends FolkShape {
<button class="add-btn">Add</button>
</div>
</div>
<div class="results-drawer"></div>
<div class="username-prompt" style="display: none;">
<p>Enter your name to vote:</p>
<input type="text" class="username-input" placeholder="Your name..." />
@ -442,11 +503,23 @@ export class FolkChoiceVote extends FolkShape {
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#wrapperEl = wrapper;
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement;
this.#budgetEl = wrapper.querySelector(".budget-bar") as HTMLElement;
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
const titleEl = wrapper.querySelector(".title-text") as HTMLElement;
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
drawerToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#drawerOpen = !this.#drawerOpen;
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
drawerToggle.classList.toggle("active", this.#drawerOpen);
if (this.#drawerOpen) this.#renderDrawer();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -549,6 +622,7 @@ export class FolkChoiceVote extends FolkShape {
}
#render() {
if (this.#drawerOpen) this.#renderDrawer();
if (!this.#optionsEl) return;
const tally = tallyVotes(this.#votes, this.#options);
@ -632,6 +706,104 @@ export class FolkChoiceVote extends FolkShape {
}
}
#renderDrawer() {
if (!this.#drawerEl) return;
const tally = tallyVotes(this.#votes, this.#options);
const totalVotes = [...tally.values()].reduce((a, b) => a + b, 0);
const maxVotes = Math.max(1, ...tally.values());
const sorted = [...tally.entries()].sort((a, b) => b[1] - a[1]);
const optMap = new Map(this.#options.map((o) => [o.id, o]));
const uniqueVoters = new Set(this.#votes.map((v) => v.userId)).size;
// Group Results
let resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Group Results</div>';
for (const [optId, count] of sorted) {
const opt = optMap.get(optId);
if (!opt) continue;
const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
const barPct = (count / maxVotes) * 100;
resultsHtml += `<div class="drawer-bar-row">
<span class="drawer-bar-label">${this.#escapeHtml(opt.label)}</span>
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${barPct}%;background:${opt.color}"></div></div>
<span class="drawer-bar-val">${pct.toFixed(0)}%</span>
</div>`;
}
if (sorted.length >= 2) {
const margin = totalVotes > 0 ? ((sorted[0][1] - sorted[1][1]) / totalVotes) * 100 : 0;
resultsHtml += `<div class="stat-row"><span class="stat-label">Margin</span><span class="stat-value">${margin.toFixed(1)}%</span></div>`;
}
// Quadratic effective votes
if (this.#mode === "quadratic") {
const eff = effectiveVotes(this.#votes, this.#options);
resultsHtml += '<div style="margin-top:6px"><div class="drawer-heading">Effective Votes (QV)</div>';
const effSorted = [...eff.entries()].sort((a, b) => b[1] - a[1]);
const maxEff = Math.max(1, ...eff.values());
for (const [optId, ev] of effSorted) {
const opt = optMap.get(optId);
if (!opt) continue;
resultsHtml += `<div class="drawer-bar-row">
<span class="drawer-bar-label">${this.#escapeHtml(opt.label)}</span>
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${(ev / maxEff) * 100}%;background:${opt.color}"></div></div>
<span class="drawer-bar-val">${ev.toFixed(1)}</span>
</div>`;
}
resultsHtml += "</div>";
}
resultsHtml += "</div>";
// Statistics
const gini = giniCoefficient(tally);
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
statsHtml += `<div class="stat-row"><span class="stat-label">Voters</span><span class="stat-value">${uniqueVoters}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Total votes</span><span class="stat-value">${totalVotes}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Gini coefficient</span><span class="stat-value">${gini.toFixed(2)}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Mode</span><span class="stat-value">${this.#mode}</span></div>`;
if (this.#mode === "quadratic") {
const totalCredits = this.#votes.reduce((sum, v) => sum + quadraticCost(v.allocations), 0);
const avgCredits = uniqueVoters > 0 ? totalCredits / uniqueVoters : 0;
statsHtml += `<div class="stat-row"><span class="stat-label">Total credits spent</span><span class="stat-value">${totalCredits}</span></div>`;
statsHtml += `<div class="stat-row"><span class="stat-label">Avg credits/voter</span><span class="stat-value">${avgCredits.toFixed(1)}</span></div>`;
}
if (this.#mode === "approval") {
for (const opt of this.#options) {
const approvals = this.#votes.filter((v) => (v.allocations[opt.id] || 0) > 0).length;
const rate = uniqueVoters > 0 ? (approvals / uniqueVoters) * 100 : 0;
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(opt.label)} approval</span><span class="stat-value">${rate.toFixed(0)}%</span></div>`;
}
}
statsHtml += "</div>";
// Participants
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
for (const v of this.#votes) {
const ago = this.#timeAgo(v.timestamp);
const allocStr = Object.entries(v.allocations)
.filter(([, c]) => c > 0)
.map(([id, c]) => `${optMap.get(id)?.label || id}: ${c}`)
.join(", ");
participantsHtml += `<div class="participant-row">
<span class="participant-dot" style="background:#0d9488"></span>
<span>${this.#escapeHtml(v.userName)}</span>
<span style="margin-left:auto;color:#94a3b8;font-size:10px">${allocStr || "none"} (${ago})</span>
</div>`;
}
participantsHtml += "</div>";
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
}
#timeAgo(ts: number): string {
const diff = Date.now() - ts;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;

View File

@ -1,12 +1,4 @@
/* Books module — additional styles for shell-wrapped pages */
/* Dark theme for reader page */
body[data-theme="dark"] {
background: #0f172a;
}
/* Library grid page */
body[data-theme="light"] main {
background: #0f172a;
/* Books module layout */
main {
min-height: calc(100vh - 56px);
}

View File

@ -1,6 +1,5 @@
/* Cart module theme */
body[data-theme="light"] main {
background: #0f172a;
/* Cart module layout */
main {
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,6 +1,5 @@
/* Choices module theme */
body[data-theme="light"] main {
background: #0f172a;
/* Choices module layout */
main {
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -267,7 +267,7 @@ const fundsScripts = `
const fundsStyles = `<link rel="stylesheet" href="/modules/rfunds/funds.css">`;
// Landing page
// Landing page (also serves demo via centralized /demo → space="demo" rewrite)
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
@ -276,24 +276,9 @@ routes.get("/", (c) => {
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
scripts: `<script type="module" src="/modules/rfunds/folk-funds-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rfunds/funds.css">`,
}));
});
// Demo mode — hardcoded demo data, no API needed
routes.get("/demo", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `TBFF Demo — rFunds | rSpace`,
moduleId: "rfunds",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
styles: fundsStyles,
body: `<folk-funds-app space="${spaceSlug}" mode="demo"></folk-funds-app>`,
body: `<folk-funds-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-funds-app>`,
scripts: fundsScripts,
styles: fundsStyles,
}));
});

View File

@ -25,6 +25,25 @@ import { createSlashCommandPlugin } from './slash-command';
const lowlight = createLowlight(common);
/** Inline SVG icons for toolbar buttons (16×16, stroke-based, currentColor) */
const ICONS: Record<string, string> = {
bold: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h5a2.5 2.5 0 0 1 0 5H4zM4 7.5h5.5a2.5 2.5 0 0 1 0 5H4z"/></svg>',
italic: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="6.5" y1="2.5" x2="11.5" y2="2.5"/><line x1="4.5" y1="13.5" x2="9.5" y2="13.5"/></svg>',
underline: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5v5a4 4 0 0 0 8 0v-5"/><line x1="3" y1="14" x2="13" y2="14"/></svg>',
strike: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4.5c-.5-1-1.8-2-3.5-2C5.5 2.5 4 3.8 4 5.3c0 1 .5 1.7 1.5 2.2"/><line x1="3" y1="8" x2="13" y2="8"/><path d="M5 11c.5 1.2 1.8 2.5 3.5 2.5 2 0 3.5-1.3 3.5-2.8 0-.7-.3-1.3-.8-1.7"/></svg>',
code: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5 4 2 8 5.5 12"/><polyline points="10.5 4 14 8 10.5 12"/></svg>',
bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
taskList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
codeBlock: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
link: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 9.5a3 3 0 0 0 4.2.3l2-2a3 3 0 0 0-4.2-4.3L7 4.8"/><path d="M9.5 6.5a3 3 0 0 0-4.2-.3l-2 2a3 3 0 0 0 4.2 4.3L9 11.2"/></svg>',
image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
undo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 6 2 8 4 10"/><path d="M2 8h8a4 4 0 0 1 0 8H8"/></svg>',
redo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="12 6 14 8 12 10"/><path d="M14 8H6a4 4 0 0 0 0 8h2"/></svg>',
};
interface Notebook {
id: string;
title: string;
@ -830,6 +849,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editorNoteId = note.id;
// Listen for slash command image insert (custom event from slash-command.ts)
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
const coords = this.editor.view.coordsAtPos(from);
const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
});
});
// Wire up title input
const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
if (titleInput) {
@ -863,14 +893,16 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
private renderToolbar(): string {
const btn = (cmd: string, title: string) =>
`<button class="toolbar-btn" data-cmd="${cmd}" title="${title}">${ICONS[cmd]}</button>`;
return `
<div class="editor-toolbar" id="editor-toolbar">
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bold" title="Bold (Ctrl+B)"><strong>B</strong></button>
<button class="toolbar-btn" data-cmd="italic" title="Italic (Ctrl+I)"><em>I</em></button>
<button class="toolbar-btn" data-cmd="underline" title="Underline (Ctrl+U)"><u>U</u></button>
<button class="toolbar-btn" data-cmd="strike" title="Strikethrough"><s>S</s></button>
<button class="toolbar-btn" data-cmd="code" title="Inline Code">&lt;/&gt;</button>
${btn('bold', 'Bold (Ctrl+B)')}
${btn('italic', 'Italic (Ctrl+I)')}
${btn('underline', 'Underline (Ctrl+U)')}
${btn('strike', 'Strikethrough')}
${btn('code', 'Inline Code')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
@ -884,29 +916,87 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bulletList" title="Bullet List">&#8226;</button>
<button class="toolbar-btn" data-cmd="orderedList" title="Numbered List">1.</button>
<button class="toolbar-btn" data-cmd="taskList" title="Task List">&#9745;</button>
${btn('bulletList', 'Bullet List')}
${btn('orderedList', 'Numbered List')}
${btn('taskList', 'Task List')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="blockquote" title="Blockquote">&#8220;</button>
<button class="toolbar-btn" data-cmd="codeBlock" title="Code Block">{}</button>
<button class="toolbar-btn" data-cmd="horizontalRule" title="Divider">&#8212;</button>
${btn('blockquote', 'Blockquote')}
${btn('codeBlock', 'Code Block')}
${btn('horizontalRule', 'Divider')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="link" title="Insert Link">&#128279;</button>
<button class="toolbar-btn" data-cmd="image" title="Insert Image">&#128247;</button>
${btn('link', 'Insert Link')}
${btn('image', 'Insert Image')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="undo" title="Undo (Ctrl+Z)">&#8617;</button>
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Y)">&#8618;</button>
${btn('undo', 'Undo (Ctrl+Z)')}
${btn('redo', 'Redo (Ctrl+Y)')}
</div>
</div>`;
}
private showUrlPopover(anchorRect: DOMRect, placeholder: string): Promise<string | null> {
return new Promise((resolve) => {
this.shadow.querySelector('.url-popover')?.remove();
const popover = document.createElement('div');
popover.className = 'url-popover';
const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
popover.style.left = `${anchorRect.left - hostRect.left}px`;
popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;
const input = document.createElement('input');
input.type = 'url';
input.placeholder = placeholder;
input.className = 'url-popover__input';
const insertBtn = document.createElement('button');
insertBtn.textContent = 'Insert';
insertBtn.className = 'url-popover__btn url-popover__btn--insert';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'url-popover__btn url-popover__btn--cancel';
const btnRow = document.createElement('div');
btnRow.className = 'url-popover__actions';
btnRow.append(cancelBtn, insertBtn);
popover.append(input, btnRow);
this.shadow.appendChild(popover);
input.focus();
const cleanup = (value: string | null) => {
popover.remove();
resolve(value);
};
insertBtn.addEventListener('click', () => {
const val = input.value.trim();
cleanup(val || null);
});
cancelBtn.addEventListener('click', () => cleanup(null));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = input.value.trim();
cleanup(val || null);
}
if (e.key === 'Escape') {
e.preventDefault();
cleanup(null);
}
});
});
}
private attachToolbarListeners() {
const toolbar = this.shadow.getElementById('editor-toolbar');
if (!toolbar || !this.editor) return;
@ -932,13 +1022,19 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break;
case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break;
case 'link': {
const url = prompt('Link URL:');
if (url) this.editor.chain().focus().setLink({ href: url }).run();
const rect = btn.getBoundingClientRect();
this.showUrlPopover(rect, 'Enter link URL...').then(url => {
if (url) this.editor!.chain().focus().setLink({ href: url }).run();
else this.editor!.chain().focus().run();
});
break;
}
case 'image': {
const url = prompt('Image URL:');
if (url) this.editor.chain().focus().setImage({ src: url }).run();
const rect = btn.getBoundingClientRect();
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
else this.editor!.chain().focus().run();
});
break;
}
case 'undo': this.editor.chain().focus().undo().run(); break;
@ -1113,17 +1209,21 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
// Notebooks view
let html = '';
if (this.searchQuery && this.searchResults.length > 0) {
html += `<div style="margin-bottom:16px;font-size:13px;color:#888">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>`;
html += `<div class="search-results-info">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>`;
html += this.searchResults.map((n) => this.renderNoteItem(n)).join("");
}
if (!this.searchQuery) {
html += `<div class="grid">${this.notebooks.map((nb) => `
<div class="notebook-card" data-notebook="${nb.id}" style="background:${nb.cover_color}33;border-color:${nb.cover_color}55">
<div>
<div class="notebook-title">${this.esc(nb.title)}</div>
<div class="notebook-meta">${this.esc(nb.description || "")}</div>
<div class="notebook-card" data-notebook="${nb.id}">
<div class="notebook-card__accent" style="background:${nb.cover_color}"></div>
<div class="notebook-card__body">
<div class="notebook-card__title">${this.esc(nb.title)}</div>
<div class="notebook-card__desc">${this.esc(nb.description || "")}</div>
</div>
<div class="notebook-card__footer">
<span>${nb.note_count} notes</span>
<span>${this.formatDate(nb.updated_at)}</span>
</div>
<div class="notebook-meta">${nb.note_count} notes &middot; ${this.formatDate(nb.updated_at)}</div>
</div>
`).join("")}</div>`;
if (this.notebooks.length === 0) {
@ -1144,8 +1244,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<span>Created: ${this.formatDate(n.created_at)}</span>
<span>Updated: ${this.formatDate(n.updated_at)}</span>
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
${isAutomerge ? '<span style="color:#10b981">Live</span>' : ""}
${isDemo ? '<span style="color:#f59e0b">Demo</span>' : ""}
${isAutomerge ? '<span class="meta-live">Live</span>' : ""}
${isDemo ? '<span class="meta-demo">Demo</span>' : ""}
</div>`;
} else {
this.metaZone.innerHTML = '';
@ -1155,11 +1255,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
private renderNoteItem(n: Note): string {
return `
<div class="note-item" data-note="${n.id}">
<span class="note-icon">${this.getNoteIcon(n.type)}</span>
<div class="note-body">
<div class="note-title">${n.is_pinned ? '<span class="pinned">&#x1F4CC;</span> ' : ""}${this.esc(n.title)}</div>
<div class="note-preview">${this.esc(n.content_plain || "")}</div>
<div class="note-meta">
<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
<div class="note-item__body">
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
<div class="note-item__preview">${this.esc(n.content_plain || "")}</div>
<div class="note-item__meta">
<span>${this.formatDate(n.updated_at)}</span>
<span>${n.type}</span>
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}

View File

@ -1,6 +1,5 @@
/* Pubs module — editor theme */
body[data-theme="light"] main {
background: #0f172a;
/* Pubs module layout */
main {
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -1,6 +1,5 @@
/* Swag module theme */
body[data-theme="light"] main {
background: #0f172a;
/* Swag module layout */
main {
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -11,7 +11,15 @@ import {
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
export type SpaceVisibility = 'public' | 'public_read' | 'authenticated' | 'members_only';
export type SpaceVisibility = 'public' | 'permissioned' | 'private';
/** Normalize legacy visibility values to the current 3-type model. */
export function normalizeVisibility(v: string): SpaceVisibility {
if (v === 'public_read' || v === 'public') return 'public';
if (v === 'authenticated' || v === 'permissioned') return 'permissioned';
if (v === 'members_only' || v === 'private') return 'private';
return 'public';
}
// ── Nest Permissions & Policy ──
@ -155,7 +163,7 @@ export interface ShapeData {
export interface SpaceMember {
did: string;
role: 'viewer' | 'participant' | 'moderator' | 'admin';
role: 'viewer' | 'member' | 'moderator' | 'admin';
joinedAt: number;
displayName?: string;
}
@ -191,6 +199,29 @@ const saveTimers = new Map<string, Timer>();
// Ensure storage directory exists
await mkdir(STORAGE_DIR, { recursive: true });
/**
* Runtime migration: rename 'participant' 'member' in Automerge doc members.
* Returns the (possibly updated) doc. Only mutates if a participant role is found.
*/
function migrateParticipantToMember(
doc: Automerge.Doc<CommunityDoc>,
slug: string,
): Automerge.Doc<CommunityDoc> {
if (!doc.members) return doc;
const needsMigration = Object.values(doc.members).some(
(m) => (m as any).role === 'participant',
);
if (!needsMigration) return doc;
console.log(`[Store] Migrating participant→member roles in ${slug}`);
return Automerge.change(doc, 'Migrate participant→member', (d) => {
for (const did of Object.keys(d.members)) {
if ((d.members[did] as any).role === 'participant') {
d.members[did].role = 'member';
}
}
});
}
/**
* Load community document from disk
*/
@ -217,7 +248,11 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
console.log(`[Store] Decrypted ${slug} (keyId: ${keyId})`);
}
const doc = Automerge.load<CommunityDoc>(bytes);
let doc = Automerge.load<CommunityDoc>(bytes);
// Runtime migration: participant → member
doc = migrateParticipantToMember(doc, slug);
// Runtime migration: normalize legacy visibility values
doc = migrateVisibility(doc, slug);
communities.set(slug, doc);
return doc;
} catch (e) {
@ -233,7 +268,11 @@ export async function loadCommunity(slug: string): Promise<Automerge.Doc<Communi
try {
const data = (await jsonFile.json()) as CommunityDoc;
// Migrate JSON to Automerge
const doc = jsonToAutomerge(data);
let doc = jsonToAutomerge(data);
// Runtime migration: participant → member
doc = migrateParticipantToMember(doc, slug);
// Runtime migration: normalize legacy visibility values
doc = migrateVisibility(doc, slug);
communities.set(slug, doc);
// Save as Automerge binary
await saveCommunity(slug);
@ -321,7 +360,7 @@ export async function createCommunity(
name: string,
slug: string,
ownerDID: string | null = null,
visibility: SpaceVisibility = 'public_read',
visibility: SpaceVisibility = 'public',
options?: {
enabledModules?: string[];
nestPolicy?: NestPolicy;

View File

@ -68,8 +68,9 @@ import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces, createSpace } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderModuleLanding, renderOnboarding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
@ -1201,19 +1202,80 @@ app.get("/:space/:moduleId/template", async (c) => {
return c.redirect(`/${space}/${moduleId}`, 302);
});
// ── Empty-state detection for onboarding ──
import type { RSpaceModule } from "../shared/module";
function moduleHasData(space: string, mod: RSpaceModule): boolean {
if (space === "demo") return true; // demo always has data
if (!mod.docSchemas || mod.docSchemas.length === 0) return true; // no schemas = can't detect
for (const schema of mod.docSchemas) {
if (!schema.pattern.includes('{space}')) return true; // global module, always show
const prefix = schema.pattern.replace('{space}', space).split(':').slice(0, 3).join(':');
if (syncServer.hasDocsWithPrefix(prefix)) return true;
}
return false;
}
// ── Mount module routes under /:space/:moduleId ──
// Enforce enabledModules: if a space has an explicit list, only those modules route.
// The 'rspace' (canvas) module is always allowed as the core module.
for (const mod of getAllModules()) {
app.use(`/:space/${mod.id}/*`, async (c, next) => {
if (mod.id === "rspace") return next();
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return next();
const doc = getDocumentData(space);
if (!doc?.meta?.enabledModules) return next(); // null = all enabled
if (doc.meta.enabledModules.includes(mod.id)) return next();
return c.json({ error: "Module not enabled for this space" }, 404);
// Check enabled modules (skip for core rspace module)
if (mod.id !== "rspace") {
const doc = getDocumentData(space);
if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
return c.json({ error: "Module not enabled for this space" }, 404);
}
}
// Resolve caller's role for write-method blocking
const method = c.req.method;
if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
const token = extractToken(c.req.raw.headers);
let claims: EncryptIDClaims | null = null;
if (token) {
try { claims = await verifyEncryptIDToken(token); } catch {}
}
const resolved = await resolveCallerRole(space, claims);
if (resolved) {
c.set("spaceRole" as any, resolved.role);
c.set("isOwner" as any, resolved.isOwner);
if (resolved.role === "viewer") {
return c.json({ error: "Write access required" }, 403);
}
}
}
return next();
});
// Onboarding: show landing page for empty modules (root page only)
if (mod.id !== "rspace") {
app.use(`/:space/${mod.id}`, async (c, next) => {
const space = c.req.param("space");
if (!space || space === "demo" || space === "api" || space.includes(".")) return next();
if (c.req.method !== "GET") return next();
const path = c.req.path;
const root = `/${space}/${mod.id}`;
if (path !== root && path !== root + '/') return next();
if (!moduleHasData(space, mod)) {
return c.html(renderOnboarding({
moduleId: mod.id,
moduleName: mod.name,
moduleIcon: mod.icon,
moduleDescription: mod.description,
spaceSlug: space,
modules: getModuleInfoList(),
landingHTML: mod.landingPage?.(),
}));
}
return next();
});
}
app.route(`/:space/${mod.id}`, mod.routes);
// Auto-mount browsable output list pages
if (mod.outputPaths) {
@ -1400,6 +1462,7 @@ interface WSData {
peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
spaceRole: 'viewer' | 'member' | 'moderator' | 'admin' | null;
mode: "automerge" | "json";
// Nest context: set when a folk-canvas shape connects to a nested space
nestFrom?: string; // slug of the parent space that contains the nest
@ -1615,6 +1678,7 @@ const server = Bun.serve<WSData>({
const spaceConfig = await getSpaceConfig(communitySlug);
const claims = await authenticateWSUpgrade(req);
let readOnly = false;
let spaceRole: WSData['spaceRole'] = null;
if (spaceConfig) {
const vis = spaceConfig.visibility;
@ -1625,6 +1689,30 @@ const server = Bun.serve<WSData>({
}
}
// Resolve the caller's space role
await loadCommunity(communitySlug);
const spaceData = getDocumentData(communitySlug);
if (spaceData) {
if (claims && spaceData.meta.ownerDID === claims.sub) {
spaceRole = 'admin';
} else if (claims && spaceData.members?.[claims.sub]) {
spaceRole = spaceData.members[claims.sub].role;
} else {
// Non-member defaults by visibility
const vis = spaceConfig?.visibility;
if (vis === 'public' && claims) spaceRole = 'member';
else if (vis === 'public_read' && claims) spaceRole = 'member';
else if (vis === 'authenticated' && claims) spaceRole = 'viewer';
else if (claims) spaceRole = 'viewer';
else spaceRole = 'viewer'; // anonymous
}
}
// Enforce read-only for viewers
if (spaceRole === 'viewer') {
readOnly = true;
}
const peerId = generatePeerId();
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
@ -1652,7 +1740,7 @@ const server = Bun.serve<WSData>({
}
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId, claims, readOnly, mode, nestFrom, nestPermissions, nestFilter } as WSData,
data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter } as WSData,
});
if (upgraded) return undefined;
}
@ -1752,6 +1840,17 @@ const server = Bun.serve<WSData>({
return Response.redirect(`${url.protocol}//${url.host}/${withoutTemplate}`, 302);
}
// Demo route: /{moduleId}/demo → rewrite to /demo/{moduleId}
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "demo") {
const moduleId = pathSegments[0].toLowerCase();
const mod = getModule(moduleId);
if (mod) {
const rewrittenPath = `/demo/${moduleId}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
return app.fetch(new Request(rewrittenUrl, req));
}
}
// Normalize module ID to lowercase (rTrips → rtrips)
const normalizedPath = "/" + pathSegments.map((seg, i) =>
i === 0 ? seg.toLowerCase() : seg
@ -2109,7 +2208,16 @@ try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), {
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
.then(() => ensureTemplateSeeding())
.then(() => {
ensureTemplateSeeding();
// Seed all modules' demo data so /demo routes always have content
for (const mod of getAllModules()) {
if (mod.seedTemplate) {
try { mod.seedTemplate("demo"); } catch { /* already seeded */ }
}
}
console.log("[Demo] All module demo data seeded");
})
.catch((e) => console.error("[DocStore] Startup load failed:", e));
// Restore relay mode for encrypted spaces

View File

@ -33,14 +33,16 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>rSpace Community Platform</title>
<meta name="description" content="A collaborative, local-first community platform with 22+ interoperable tools. Design global, manufacture local.">
<link rel="stylesheet" href="/shell.css">
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<style>${MODULE_LANDING_CSS}</style>
<style>${RICH_LANDING_CSS}</style>
<style>${MAIN_LANDING_CSS}</style>
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
<body data-theme="dark">
<header class="rstack-header" data-theme="dark">
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current=""></rstack-app-switcher>
@ -218,7 +220,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
</footer>
<script type="module">
import '/shell.js';
import '/shell.js?v=7';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Logged-in users: hide header demo btn, swap hero CTA to "Go to My Space"
@ -272,13 +274,15 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
<title>${escapeHtml(displayName)} | rSpace</title>
<link rel="stylesheet" href="/shell.css">
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<style>${MODULE_LANDING_CSS}</style>
<style>${SPACE_DASHBOARD_CSS}</style>
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
<body data-theme="dark">
<header class="rstack-header" data-theme="dark">
<body>
<header class="rstack-header">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current=""></rstack-app-switcher>
@ -309,7 +313,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
</div>` : ""}
<script type="module">
import '/shell.js';
import '/shell.js?v=7';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Fix up dashboard links to be subdomain-aware
@ -330,12 +334,12 @@ const SPACE_DASHBOARD_CSS = `
}
.sd-hero__title {
font-size: 2.25rem; font-weight: 700; margin: 0 0 0.5rem;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.sd-hero__subtitle {
font-size: 1.05rem; color: #94a3b8; margin: 0;
font-size: 1.05rem; color: var(--rs-text-secondary); margin: 0;
max-width: 520px; margin: 0 auto; line-height: 1.5;
}
.sd-container {
@ -350,8 +354,8 @@ const SPACE_DASHBOARD_CSS = `
.sd-card {
display: flex; align-items: flex-start; gap: 1rem;
padding: 1rem 1.25rem;
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.06);
background: var(--rs-card-bg);
border: 1px solid var(--rs-card-border);
border-radius: 0.75rem;
text-decoration: none; color: inherit;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
@ -369,19 +373,19 @@ const SPACE_DASHBOARD_CSS = `
}
.sd-card__body { min-width: 0; }
.sd-card__name {
font-size: 0.9rem; font-weight: 600; color: #e2e8f0;
font-size: 0.9rem; font-weight: 600; color: var(--rs-text-primary);
margin: 0 0 0.2rem;
}
.sd-card__desc {
font-size: 0.78rem; color: #64748b; margin: 0;
font-size: 0.78rem; color: var(--rs-text-muted); margin: 0;
line-height: 1.45;
}
.sd-footer {
text-align: center; padding: 2rem 1.5rem 3rem;
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--rs-border-subtle);
}
.sd-footer p { color: #64748b; font-size: 0.9rem; margin: 0; }
.sd-footer a { color: #14b8a6; text-decoration: none; font-weight: 600; }
.sd-footer p { color: var(--rs-text-muted); font-size: 0.9rem; margin: 0; }
.sd-footer a { color: var(--rs-accent); text-decoration: none; font-weight: 600; }
.sd-footer a:hover { text-decoration: underline; }
@media (max-width: 480px) {
.sd-grid { grid-template-columns: 1fr; }
@ -397,7 +401,7 @@ const MAIN_LANDING_CSS = `
}
@media (min-width: 640px) { .main-wordmark { font-size: 4.5rem; } }
.main-wordmark__accent {
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
@ -406,7 +410,7 @@ const MAIN_LANDING_CSS = `
margin-top: 1.5rem;
}
.main-footer {
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--rs-border-subtle);
padding: 2.5rem 1.5rem;
}
`;

View File

@ -248,6 +248,16 @@ export class SyncServer {
return doc;
}
/**
* Check if any documents exist whose ID starts with the given prefix.
*/
hasDocsWithPrefix(prefix: string): boolean {
for (const key of this.#docs.keys()) {
if (key.startsWith(prefix)) return true;
}
return false;
}
/**
* Get all document IDs held by the server.
*/

View File

@ -84,7 +84,7 @@ export function renderShell(opts: ShellOptions): string {
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<link rel="stylesheet" href="/shell.css?v=8">
<style>
/* When loaded inside an iframe (e.g. standalone domain redirecting back),
hide the shell chrome the parent rSpace page already provides it. */
@ -105,7 +105,8 @@ export function renderShell(opts: ShellOptions): string {
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>${spaceEncrypted ? '<span class="rstack-header__encrypted" title="End-to-end encrypted space">&#x1F512;</span>' : ''}
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>${spaceEncrypted ? '<span class="rstack-header__encrypted" title="End-to-end encrypted space">&#x1F512;</span>' : ''}<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings" style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:4px;margin-left:4px;display:flex;align-items:center;border-radius:4px;transition:color 0.15s,background 0.15s;" onmouseover="this.style.color='#e2e8f0';this.style.background='rgba(255,255,255,0.08)'" onmouseout="this.style.color='#94a3b8';this.style.background='none'"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<rstack-space-settings space="${escapeAttr(spaceSlug)}"></rstack-space-settings>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
@ -125,7 +126,43 @@ export function renderShell(opts: ShellOptions): string {
${renderWelcomeOverlay()}
<script type="module">
import '/shell.js?v=7';
import '/shell.js?v=8';
// ── Settings panel toggle ──
document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings');
if (panel) panel.toggle();
});
// ── Invite acceptance on page load ──
(function() {
var params = new URLSearchParams(window.location.search);
var inviteToken = params.get('invite');
if (!inviteToken) return;
// Remove token from URL immediately
params.delete('invite');
var newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
history.replaceState(null, '', newUrl);
// Wait for auth then accept
function tryAccept() {
try {
var raw = localStorage.getItem('encryptid_session');
if (!raw) return;
var session = JSON.parse(raw);
if (!session || !session.accessToken) return;
fetch('/' + '${escapeAttr(spaceSlug)}' + '/invite/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
body: JSON.stringify({ inviteToken: inviteToken }),
}).then(function(res) { return res.json(); }).then(function(data) {
if (data.ok) { window.location.reload(); }
});
} catch(e) {}
}
tryAccept();
// Also try after auth-change (if user signs in after landing)
document.addEventListener('auth-change', tryAccept);
})();
// Restore saved theme preference across header / tab-row
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
// Provide module list to app switcher
@ -472,7 +509,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
<title>${escapeHtml(title)}</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<link rel="stylesheet" href="/shell.css?v=8">
<style>
html.rspace-embedded .rstack-header { display: none !important; }
html.rspace-embedded .rstack-tab-row { display: none !important; }
@ -515,7 +552,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string {
</div>
<script type="module">
import '/shell.js?v=7';
import '/shell.js?v=8';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
const tabBar = document.querySelector('rstack-tab-bar');
@ -587,7 +624,7 @@ const ACCESS_GATE_CSS = `
background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(8px);
}
.access-gate__card {
text-align: center; color: white; max-width: 400px; padding: 2rem;
text-align: center; color: var(--rs-text-primary); max-width: 400px; padding: 2rem;
}
.access-gate__icon { font-size: 3rem; margin-bottom: 1rem; }
.access-gate__title {
@ -595,7 +632,7 @@ const ACCESS_GATE_CSS = `
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.access-gate__desc { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin: 0 0 1.5rem; }
.access-gate__desc { color: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin: 0 0 1.5rem; }
.access-gate__btn {
padding: 12px 32px; border-radius: 8px; border: none;
font-size: 1rem; font-weight: 600; cursor: pointer;
@ -613,9 +650,9 @@ const WELCOME_CSS = `
.rspace-welcome__popup {
position: relative;
width: min(380px, 44vw); max-height: 50vh;
background: #1e293b; border: 1px solid rgba(255,255,255,0.12);
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 24px 24px 18px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); color: #e2e8f0;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); color: var(--rs-text-primary);
overflow-y: auto; animation: rspace-welcome-in 0.3s ease-out;
}
@keyframes rspace-welcome-in {
@ -624,24 +661,24 @@ const WELCOME_CSS = `
}
.rspace-welcome__close {
position: absolute; top: 10px; right: 12px;
background: none; border: none; color: #64748b;
background: none; border: none; color: var(--rs-text-muted);
font-size: 1.4rem; cursor: pointer; line-height: 1;
padding: 4px; border-radius: 4px;
}
.rspace-welcome__close:hover { color: #e2e8f0; background: rgba(255,255,255,0.08); }
.rspace-welcome__close:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.rspace-welcome__title {
font-size: 1.35rem; margin: 0 0 8px;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rspace-welcome__text {
font-size: 0.85rem; color: #94a3b8; margin: 0 0 14px; line-height: 1.55;
font-size: 0.85rem; color: var(--rs-text-secondary); margin: 0 0 14px; line-height: 1.55;
}
.rspace-welcome__text strong { color: #e2e8f0; }
.rspace-welcome__text strong { color: var(--rs-text-primary); }
.rspace-welcome__grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 5px; margin-bottom: 14px; font-size: 0.8rem; color: #cbd5e1;
gap: 5px; margin-bottom: 14px; font-size: 0.8rem; color: var(--rs-text-primary);
}
.rspace-welcome__grid span { padding: 3px 0; }
.rspace-welcome__actions {
@ -654,22 +691,22 @@ const WELCOME_CSS = `
}
.rspace-welcome__btn:hover { transform: translateY(-1px); }
.rspace-welcome__btn--primary {
background: linear-gradient(135deg, #14b8a6, #0d9488); color: white;
background: var(--rs-gradient-cta); color: white;
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
}
.rspace-welcome__btn--secondary {
background: rgba(255,255,255,0.08); color: #94a3b8;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary);
}
.rspace-welcome__btn--secondary:hover { color: #e2e8f0; }
.rspace-welcome__btn--secondary:hover { color: var(--rs-text-primary); }
.rspace-welcome__footer {
display: flex; align-items: center; gap: 6px;
}
.rspace-welcome__link {
font-size: 0.72rem; color: #64748b; text-decoration: none;
font-size: 0.72rem; color: var(--rs-text-muted); text-decoration: none;
transition: color 0.15s;
}
.rspace-welcome__link:hover { color: #c4b5fd; }
.rspace-welcome__dot { color: #475569; font-size: 0.6rem; }
.rspace-welcome__dot { color: var(--rs-text-muted); font-size: 0.6rem; }
@media (max-width: 600px) {
.rspace-welcome { bottom: 12px; right: 12px; left: 12px; }
.rspace-welcome__popup { width: 100%; max-width: none; }
@ -725,7 +762,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
<title>${escapeHtml(mod.name)} rSpace</title>
<script>(function(){var t=localStorage.getItem('canvas-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/theme.css?v=1">
<link rel="stylesheet" href="/shell.css?v=7">
<link rel="stylesheet" href="/shell.css?v=8">
${cssBlock}
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>
</head>
@ -745,7 +782,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
</header>
${bodyContent}
<script type="module">
import '/shell.js?v=7';
import '/shell.js?v=8';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
function _updateDemoBtn() {
var btn = document.querySelector('.rstack-header__demo-btn');
@ -780,8 +817,8 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
export const MODULE_LANDING_CSS = `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white; min-height: 100vh;
background: var(--rs-bg-page); color: var(--rs-text-primary);
min-height: 100vh;
display: flex; flex-direction: column; align-items: center;
padding-top: 56px;
}
@ -793,38 +830,38 @@ body {
.ml-icon { font-size: 4rem; display: block; margin-bottom: 1rem; }
.ml-name {
font-size: 2.5rem; margin-bottom: 0.75rem;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.ml-desc { font-size: 1.15rem; color: #94a3b8; margin-bottom: 2.5rem; line-height: 1.6; }
.ml-desc { font-size: 1.15rem; color: var(--rs-text-secondary); margin-bottom: 2.5rem; line-height: 1.6; }
.ml-ctas { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.ml-cta-primary {
display: inline-block; padding: 14px 32px; border-radius: 8px;
background: linear-gradient(135deg, #14b8a6, #0d9488);
background: var(--rs-gradient-cta);
color: white; font-size: 1rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, box-shadow 0.2s;
}
.ml-cta-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.ml-cta-secondary {
display: inline-block; padding: 14px 32px; border-radius: 8px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.2);
color: #94a3b8; font-size: 1rem; font-weight: 600;
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 1rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, border-color 0.2s, color 0.2s;
}
.ml-cta-secondary:hover { transform: translateY(-2px); border-color: rgba(255,255,255,0.4); color: white; }
.ml-cta-secondary:hover { transform: translateY(-2px); border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.ml-back { padding: 2rem 0 3rem; text-align: center; }
.ml-back a { font-size: 0.85rem; color: #64748b; text-decoration: none; transition: color 0.2s; }
.ml-back a:hover { color: #e2e8f0; }
.ml-back a { font-size: 0.85rem; color: var(--rs-text-muted); text-decoration: none; transition: color 0.2s; }
.ml-back a:hover { color: var(--rs-text-primary); }
@media (max-width: 600px) { .ml-name { font-size: 2rem; } .ml-icon { font-size: 3rem; } }
`;
export const RICH_LANDING_CSS = `
/* ── Rich Landing Page Utilities ── */
.rl-section {
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--rs-border-subtle);
padding: 4rem 1.5rem;
}
.rl-section--alt { background: rgba(255,255,255,0.015); }
.rl-section--alt { background: var(--rs-bg-hover); }
.rl-container { max-width: 1100px; margin: 0 auto; }
.rl-hero {
text-align: center; padding: 5rem 1.5rem 3rem;
@ -833,27 +870,27 @@ export const RICH_LANDING_CSS = `
.rl-tagline {
display: inline-block; font-size: 0.7rem; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase;
color: #14b8a6; background: rgba(20,184,166,0.1);
color: var(--rs-accent); background: rgba(20,184,166,0.1);
border: 1px solid rgba(20,184,166,0.2);
padding: 0.35rem 1rem; border-radius: 9999px; margin-bottom: 1.5rem;
}
.rl-heading {
font-size: 2rem; font-weight: 700; line-height: 1.15;
margin-bottom: 0.75rem; letter-spacing: -0.01em;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.rl-hero .rl-heading { font-size: 2.5rem; }
@media (min-width: 640px) { .rl-hero .rl-heading { font-size: 3rem; } }
.rl-subtitle {
font-size: 1.25rem; font-weight: 500; color: #cbd5e1;
font-size: 1.25rem; font-weight: 500; color: var(--rs-text-primary);
margin-bottom: 1rem; letter-spacing: -0.005em;
}
.rl-hero .rl-subtitle { font-size: 1.35rem; }
@media (min-width: 640px) { .rl-hero .rl-subtitle { font-size: 1.5rem; } }
.rl-subtext {
font-size: 1.05rem; color: #94a3b8; line-height: 1.65;
font-size: 1.05rem; color: var(--rs-text-secondary); line-height: 1.65;
max-width: 640px; margin: 0 auto 2rem;
}
.rl-hero .rl-subtext { font-size: 1.15rem; }
@ -873,13 +910,13 @@ export const RICH_LANDING_CSS = `
/* Card */
.rl-card {
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06);
background: var(--rs-card-bg); border: 1px solid var(--rs-card-border);
border-radius: 1rem; padding: 1.75rem;
transition: border-color 0.2s;
}
.rl-card:hover { border-color: rgba(20,184,166,0.3); }
.rl-card h3 { font-size: 0.95rem; font-weight: 600; color: #e2e8f0; margin-bottom: 0.5rem; }
.rl-card p { font-size: 0.875rem; color: #94a3b8; line-height: 1.6; }
.rl-card h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.5rem; }
.rl-card p { font-size: 0.875rem; color: var(--rs-text-secondary); line-height: 1.6; }
.rl-card--center { text-align: center; }
/* Step circles */
@ -888,12 +925,12 @@ export const RICH_LANDING_CSS = `
}
.rl-step__num {
width: 2.5rem; height: 2.5rem; border-radius: 9999px;
background: rgba(20,184,166,0.1); color: #14b8a6;
background: rgba(20,184,166,0.1); color: var(--rs-accent);
display: flex; align-items: center; justify-content: center;
font-size: 0.8rem; font-weight: 700; margin-bottom: 0.75rem;
}
.rl-step h3 { font-size: 0.95rem; font-weight: 600; color: #e2e8f0; margin-bottom: 0.25rem; }
.rl-step p { font-size: 0.82rem; color: #94a3b8; line-height: 1.55; }
.rl-step h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.25rem; }
.rl-step p { font-size: 0.82rem; color: var(--rs-text-secondary); line-height: 1.55; }
/* CTA row */
.rl-cta-row {
@ -902,35 +939,35 @@ export const RICH_LANDING_CSS = `
}
.rl-cta-primary {
display: inline-block; padding: 0.8rem 2rem; border-radius: 0.5rem;
background: linear-gradient(135deg, #14b8a6, #0d9488);
background: var(--rs-gradient-cta);
color: white; font-size: 0.95rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, box-shadow 0.2s;
}
.rl-cta-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.rl-cta-secondary {
display: inline-block; padding: 0.8rem 2rem; border-radius: 0.5rem;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15);
color: #94a3b8; font-size: 0.95rem; font-weight: 600;
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 0.95rem; font-weight: 600;
text-decoration: none; transition: transform 0.2s, border-color 0.2s, color 0.2s;
}
.rl-cta-secondary:hover { transform: translateY(-2px); border-color: rgba(255,255,255,0.35); color: white; }
.rl-cta-secondary:hover { transform: translateY(-2px); border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
/* Check list */
.rl-check-list { list-style: none; padding: 0; margin: 0; }
.rl-check-list li {
display: flex; align-items: flex-start; gap: 0.5rem;
font-size: 0.875rem; color: #94a3b8; line-height: 1.55;
font-size: 0.875rem; color: var(--rs-text-secondary); line-height: 1.55;
padding: 0.35rem 0;
}
.rl-check-list li::before {
content: "✓"; color: #14b8a6; font-weight: 700; flex-shrink: 0; margin-top: 0.05em;
content: "✓"; color: var(--rs-accent); font-weight: 700; flex-shrink: 0; margin-top: 0.05em;
}
.rl-check-list li strong { color: #e2e8f0; font-weight: 600; }
.rl-check-list li strong { color: var(--rs-text-primary); font-weight: 600; }
/* Badge */
.rl-badge {
display: inline-block; font-size: 0.65rem; font-weight: 700;
color: white; background: #14b8a6;
color: white; background: var(--rs-accent);
padding: 0.15rem 0.5rem; border-radius: 9999px;
}
@ -939,14 +976,14 @@ export const RICH_LANDING_CSS = `
display: flex; align-items: center; gap: 0.75rem; margin: 1.5rem 0;
}
.rl-divider::before, .rl-divider::after {
content: ""; flex: 1; height: 1px; background: rgba(255,255,255,0.06);
content: ""; flex: 1; height: 1px; background: var(--rs-border-subtle);
}
.rl-divider span { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
.rl-divider span { font-size: 0.75rem; color: var(--rs-text-muted); white-space: nowrap; }
/* Icon box */
.rl-icon-box {
width: 3rem; height: 3rem; border-radius: 0.75rem;
background: rgba(20,184,166,0.12); color: #14b8a6;
background: rgba(20,184,166,0.12); color: var(--rs-accent);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; margin-bottom: 1rem;
}
@ -958,17 +995,17 @@ export const RICH_LANDING_CSS = `
background: rgba(20,184,166,0.04); border: 1px solid rgba(20,184,166,0.15);
border-radius: 1rem; padding: 1.5rem;
}
.rl-integration h3 { font-size: 0.95rem; font-weight: 600; color: #e2e8f0; margin-bottom: 0.35rem; }
.rl-integration p { font-size: 0.85rem; color: #94a3b8; line-height: 1.55; }
.rl-integration h3 { font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 0.35rem; }
.rl-integration p { font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.55; }
/* Back link */
.rl-back { padding: 2rem 0 3rem; text-align: center; }
.rl-back a { font-size: 0.85rem; color: #64748b; text-decoration: none; transition: color 0.2s; }
.rl-back a:hover { color: #e2e8f0; }
.rl-back a { font-size: 0.85rem; color: var(--rs-text-muted); text-decoration: none; transition: color 0.2s; }
.rl-back a:hover { color: var(--rs-text-primary); }
/* Progress bar */
.rl-progress { height: 0.5rem; border-radius: 9999px; background: rgba(255,255,255,0.06); overflow: hidden; }
.rl-progress__fill { height: 100%; border-radius: 9999px; background: #14b8a6; }
.rl-progress { height: 0.5rem; border-radius: 9999px; background: var(--rs-border-subtle); overflow: hidden; }
.rl-progress__fill { height: 100%; border-radius: 9999px; background: var(--rs-accent); }
/* Tier row */
.rl-tier {
@ -976,23 +1013,23 @@ export const RICH_LANDING_CSS = `
}
.rl-tier__item {
flex: 1; text-align: center; border-radius: 0.5rem;
border: 1px solid rgba(255,255,255,0.06); padding: 0.5rem; font-size: 0.75rem;
border: 1px solid var(--rs-border-subtle); padding: 0.5rem; font-size: 0.75rem;
}
.rl-tier__item--active {
border-color: rgba(20,184,166,0.4); background: rgba(20,184,166,0.05); color: #14b8a6;
border-color: rgba(20,184,166,0.4); background: rgba(20,184,166,0.05); color: var(--rs-accent);
}
.rl-tier__item--active strong { color: #14b8a6; }
.rl-tier__item--active strong { color: var(--rs-accent); }
/* Temporal zoom bar */
.rl-zoom-bar { display: flex; flex-direction: column; gap: 0.5rem; }
.rl-zoom-bar__row { display: flex; align-items: center; gap: 0.75rem; }
.rl-zoom-bar__label { font-size: 0.7rem; color: #64748b; width: 1.2rem; text-align: right; font-family: monospace; }
.rl-zoom-bar__label { font-size: 0.7rem; color: var(--rs-text-muted); width: 1.2rem; text-align: right; font-family: monospace; }
.rl-zoom-bar__bar {
height: 1.5rem; border-radius: 0.375rem; background: rgba(99,102,241,0.15);
display: flex; align-items: center; padding: 0 0.75rem;
}
.rl-zoom-bar__name { font-size: 0.75rem; font-weight: 600; color: #e2e8f0; white-space: nowrap; }
.rl-zoom-bar__span { font-size: 0.6rem; color: #64748b; margin-left: auto; white-space: nowrap; }
.rl-zoom-bar__name { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; }
.rl-zoom-bar__span { font-size: 0.6rem; color: var(--rs-text-muted); margin-left: auto; white-space: nowrap; }
/* Responsive helpers */
@media (max-width: 600px) {
@ -1002,6 +1039,103 @@ export const RICH_LANDING_CSS = `
}
`;
// ── Onboarding page (empty rApp state) ──
export interface OnboardingOptions {
moduleId: string;
moduleName: string;
moduleIcon: string;
moduleDescription: string;
spaceSlug: string;
modules: ModuleInfo[];
/** Pre-rendered landing page HTML from the module (feature cards, etc.) */
landingHTML?: string;
}
export function renderOnboarding(opts: OnboardingOptions): string {
const { moduleId, moduleName, moduleIcon, moduleDescription, spaceSlug, modules, landingHTML } = opts;
const demoUrl = `/${moduleId}/demo`;
const templateUrl = `/${moduleId}/template`;
const featuresBlock = landingHTML
? `<div class="onboarding__features">${landingHTML}</div>`
: '';
const body = `
<div class="onboarding">
<div class="onboarding__card">
<span class="onboarding__icon">${moduleIcon}</span>
<h1 class="onboarding__title">${escapeHtml(moduleName)}</h1>
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
<div class="onboarding__ctas">
<a href="${escapeAttr(templateUrl)}" class="onboarding__btn onboarding__btn--primary">Get Started</a>
<a href="${escapeAttr(demoUrl)}" class="onboarding__btn onboarding__btn--secondary">Try Demo</a>
</div>
</div>
${featuresBlock}
</div>`;
return renderShell({
title: `${moduleName}${spaceSlug} | rSpace`,
moduleId,
spaceSlug,
modules,
body,
styles: `<style>${ONBOARDING_CSS}</style>${landingHTML ? `<style>${RICH_LANDING_CSS}</style>` : ''}`,
});
}
const ONBOARDING_CSS = `
.onboarding {
display: flex; flex-direction: column; align-items: center;
min-height: calc(80vh - 56px); padding: 3rem 1.5rem 2rem;
}
.onboarding__card {
text-align: center; max-width: 520px; width: 100%;
padding: 2.5rem 2rem; margin-bottom: 2rem;
}
.onboarding__icon { font-size: 3.5rem; display: block; margin-bottom: 1rem; }
.onboarding__title {
font-size: 2rem; margin: 0 0 0.75rem; font-weight: 700;
background: var(--rs-gradient-brand);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.onboarding__desc {
font-size: 1.05rem; color: var(--rs-text-secondary);
line-height: 1.6; margin: 0 0 2rem;
}
.onboarding__ctas {
display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;
}
.onboarding__btn {
display: inline-block; padding: 0.75rem 1.75rem; border-radius: 0.5rem;
font-size: 0.95rem; font-weight: 600; text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.onboarding__btn:hover { transform: translateY(-2px); }
.onboarding__btn--primary {
background: var(--rs-gradient-cta); color: white;
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
}
.onboarding__btn--primary:hover { box-shadow: 0 8px 20px rgba(20,184,166,0.3); }
.onboarding__btn--secondary {
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-secondary);
}
.onboarding__btn--secondary:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
.onboarding__features {
width: 100%; max-width: 1100px;
border-top: 1px solid var(--rs-border-subtle);
padding-top: 2rem; margin-top: 1rem;
}
@media (max-width: 600px) {
.onboarding { padding: 2rem 1rem 1.5rem; }
.onboarding__title { font-size: 1.6rem; }
.onboarding__icon { font-size: 2.5rem; }
}
`;
// ── Demo page CSS utilities (rd-* prefix, parallel to rl-* landing pages) ──
export function escapeHtml(s: string): string {

View File

@ -48,6 +48,41 @@ import type { SpaceLifecycleContext } from "../shared/module";
import { syncServer } from "./sync-instance";
import { seedTemplateShapes } from "./seed-template";
// ── Role types and helpers ──
export type SpaceRoleString = 'viewer' | 'member' | 'moderator' | 'admin';
const ROLE_LEVELS: Record<SpaceRoleString, number> = {
viewer: 0,
member: 1,
moderator: 2,
admin: 3,
};
export function roleAtLeast(actual: SpaceRoleString, required: SpaceRoleString): boolean {
return ROLE_LEVELS[actual] >= ROLE_LEVELS[required];
}
export async function resolveCallerRole(
slug: string,
claims: EncryptIDClaims | null,
): Promise<{ role: SpaceRoleString; isOwner: boolean } | null> {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return null;
if (!claims) return { role: 'viewer', isOwner: false };
const isOwner = data.meta.ownerDID === claims.sub;
if (isOwner) return { role: 'admin', isOwner: true };
const member = data.members?.[claims.sub];
if (member) return { role: member.role, isOwner: false };
// Non-member defaults
return { role: 'viewer', isOwner: false };
}
// ── Unified space creation ──
export interface CreateSpaceOpts {
@ -658,8 +693,8 @@ spaces.patch("/:slug/members/:did", async (c) => {
return c.json({ error: "Cannot change the owner's role" }, 403);
}
const body = await c.req.json<{ role: "viewer" | "participant" | "moderator" | "admin" }>();
const validRoles = ["viewer", "participant", "moderator", "admin"];
const body = await c.req.json<{ role: "viewer" | "member" | "moderator" | "admin" }>();
const validRoles = ["viewer", "member", "moderator", "admin"];
if (!validRoles.includes(body.role)) {
return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
}
@ -1255,7 +1290,7 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
const body = await c.req.json<{
action: "approve" | "deny";
role?: "viewer" | "participant";
role?: "viewer" | "member";
}>();
if (body.action === "deny") {
@ -1394,35 +1429,207 @@ if (process.env.SMTP_PASS) {
});
}
// ── Enhanced invite by email (with token + role) ──
spaces.post("/:slug/invite", async (c) => {
const { slug } = c.req.param();
const { email, shareUrl } = await c.req.json<{ email: string; shareUrl: string }>();
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
if (!email || !shareUrl) {
return c.json({ error: "email and shareUrl required" }, 400);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const callerMember = data.members?.[claims.sub];
if (!isOwner && callerMember?.role !== "admin") {
return c.json({ error: "Admin access required" }, 403);
}
const body = await c.req.json<{ email: string; role?: string }>();
if (!body.email) return c.json({ error: "email is required" }, 400);
const role = body.role || "member";
const validRoles = ["viewer", "member", "moderator", "admin"];
if (!validRoles.includes(role)) {
return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
}
// Create invite token via EncryptID API
const inviteId = crypto.randomUUID();
const inviteToken = crypto.randomUUID();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
// Store invite (call EncryptID API internally)
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000";
try {
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ email: body.email, role }),
});
} catch (e) {
console.error("Failed to create invite in EncryptID:", e);
}
// Send email
const inviteUrl = `https://${slug}.rspace.online/?invite=${inviteToken}`;
if (!inviteTransport) {
console.warn("Invite email skipped (SMTP not configured) —", email, shareUrl);
return c.json({ error: "Email not configured" }, 503);
console.warn("Invite email skipped (SMTP not configured) —", body.email, inviteUrl);
return c.json({ ok: true, inviteUrl, note: "Email not configured — share the link manually" });
}
try {
await inviteTransport.sendMail({
from: process.env.SMTP_FROM || "rSpace <noreply@rspace.online>",
to: email,
to: body.email,
subject: `You're invited to join "${slug}" on rSpace`,
html: [
`<p>You've been invited to collaborate on <strong>${slug}</strong>.</p>`,
`<p><a href="${shareUrl}">Join the space</a></p>`,
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`,
`<p>You've been invited to collaborate on <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`,
`<p><a href="${inviteUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Join Space</a></p>`,
`<p style="color:#64748b;font-size:12px;">This invite expires in 7 days. rSpace — collaborative knowledge work</p>`,
].join("\n"),
});
return c.json({ ok: true });
return c.json({ ok: true, inviteUrl });
} catch (err: any) {
console.error("Invite email failed:", err.message);
return c.json({ error: "Failed to send email" }, 500);
}
});
// ── Add member by username (direct add, no invite needed) ──
spaces.post("/:slug/members/add", async (c) => {
const { slug } = c.req.param();
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const callerMember = data.members?.[claims.sub];
if (!isOwner && callerMember?.role !== "admin") {
return c.json({ error: "Admin access required" }, 403);
}
const body = await c.req.json<{ username: string; role?: string }>();
if (!body.username) return c.json({ error: "username is required" }, 400);
const role = body.role || "member";
const validRoles = ["viewer", "member", "moderator", "admin"];
if (!validRoles.includes(role)) {
return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
}
// Look up user via EncryptID
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000";
const lookupRes = await fetch(`${ENCRYPTID_URL}/api/users/lookup?username=${encodeURIComponent(body.username)}`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (!lookupRes.ok) {
return c.json({ error: "User not found" }, 404);
}
const user = await lookupRes.json() as { did: string; username: string; displayName: string };
if (!user.did) return c.json({ error: "User has no DID" }, 400);
// Add to Automerge doc
setMember(slug, user.did, role as any, user.displayName || user.username);
// Also add to PostgreSQL via EncryptID
try {
await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/members`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ userDID: user.did, role }),
});
} catch (e) {
console.error("Failed to sync member to EncryptID:", e);
}
return c.json({ ok: true, did: user.did, username: user.username, role });
});
// ── Accept invite via token ──
spaces.post("/:slug/invite/accept", async (c) => {
const { slug } = c.req.param();
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required — sign in first" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json<{ inviteToken: string }>();
if (!body.inviteToken) return c.json({ error: "inviteToken is required" }, 400);
// Accept via EncryptID API
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000";
const acceptRes = await fetch(`${ENCRYPTID_URL}/api/invites/${body.inviteToken}/accept`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` },
});
if (!acceptRes.ok) {
const err = await acceptRes.json().catch(() => ({ error: "Failed to accept invite" }));
return c.json(err as any, acceptRes.status as any);
}
const result = await acceptRes.json() as { ok: boolean; spaceSlug: string; role: string };
// Also add to Automerge doc
await loadCommunity(slug);
setMember(slug, claims.sub, result.role as any, (claims as any).username);
return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role });
});
// ── List invites for settings panel ──
spaces.get("/:slug/invites", async (c) => {
const { slug } = c.req.param();
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const callerMember = data.members?.[claims.sub];
if (!isOwner && callerMember?.role !== "admin") {
return c.json({ error: "Admin access required" }, 403);
}
// Fetch from EncryptID
const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://localhost:3000";
try {
const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
headers: { "Authorization": `Bearer ${token}` },
});
const data = await res.json();
return c.json(data as any);
} catch {
return c.json({ invites: [], total: 0 });
}
});
export { spaces };

View File

@ -306,16 +306,9 @@ const STYLES = `
padding: 6px 14px; border-radius: 8px; border: none;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s;
background: rgba(255,255,255,0.08); color: inherit;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
:host-context([data-theme="light"]) .trigger {
background: rgba(0,0,0,0.05); color: #0f172a;
}
:host-context([data-theme="dark"]) .trigger {
background: rgba(255,255,255,0.08); color: #e2e8f0;
}
.trigger:hover { background: rgba(255,255,255,0.12); }
:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); }
.trigger:hover { background: var(--rs-bg-hover); }
.trigger-badge {
display: inline-flex; align-items: center; justify-content: center;
@ -334,23 +327,18 @@ const STYLES = `
min-width: 300px; border-radius: 12px; overflow: hidden;
overflow-y: auto; max-height: 70vh;
box-shadow: 0 8px 30px rgba(0,0,0,0.25); display: none; z-index: 10001;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
}
.menu.open { display: block; }
:host-context([data-theme="light"]) .menu {
background: white; border: 1px solid rgba(0,0,0,0.1);
}
:host-context([data-theme="dark"]) .menu {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
}
/* rStack header */
a.rstack-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-bottom: 1px solid rgba(128,128,128,0.15);
padding: 12px 14px; border-bottom: 1px solid var(--rs-border-subtle);
text-decoration: none; color: inherit; cursor: pointer;
transition: background 0.12s;
}
a.rstack-header:hover { background: rgba(255,255,255,0.05); }
a.rstack-header:hover { background: var(--rs-bg-hover); }
.rstack-badge {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 8px;
@ -359,34 +347,27 @@ a.rstack-header:hover { background: rgba(255,255,255,0.05); }
flex-shrink: 0;
}
.rstack-info { display: flex; flex-direction: column; }
.rstack-title { font-size: 0.875rem; font-weight: 700; }
.rstack-title { font-size: 0.875rem; font-weight: 700; color: var(--rs-text-primary); }
.rstack-subtitle { font-size: 0.65rem; opacity: 0.5; }
:host-context([data-theme="light"]) .rstack-title { color: #0f172a; }
:host-context([data-theme="dark"]) .rstack-title { color: white; }
/* Footer */
.rstack-footer {
padding: 10px 14px; text-align: center;
border-top: 1px solid rgba(128,128,128,0.15);
border-top: 1px solid var(--rs-border-subtle);
}
.rstack-footer a {
font-size: 0.7rem; opacity: 0.4; text-decoration: none; color: inherit;
transition: opacity 0.15s;
}
.rstack-footer a:hover { opacity: 0.8; }
:host-context([data-theme="light"]) .rstack-footer a:hover { color: #06b6d4; }
:host-context([data-theme="dark"]) .rstack-footer a:hover { color: #22d3ee; }
.rstack-footer a:hover { opacity: 0.8; color: var(--rs-accent); }
.item-row {
display: flex; align-items: center;
transition: background 0.12s;
color: var(--rs-text-primary);
}
:host-context([data-theme="light"]) .item-row { color: #374151; }
:host-context([data-theme="light"]) .item-row:hover { background: #f1f5f9; }
:host-context([data-theme="light"]) .item-row.active { background: #e0f2fe; }
:host-context([data-theme="dark"]) .item-row { color: #e2e8f0; }
:host-context([data-theme="dark"]) .item-row:hover { background: rgba(255,255,255,0.05); }
:host-context([data-theme="dark"]) .item-row.active { background: rgba(6,182,212,0.1); }
.item-row:hover { background: var(--rs-bg-hover); }
.item-row.active { background: var(--rs-bg-active); }
.item {
display: flex; align-items: center; gap: 10px;
@ -399,11 +380,10 @@ a.rstack-header:hover { background: rgba(255,255,255,0.05); }
width: 32px; height: 100%; flex-shrink: 0;
font-size: 0.8rem; text-decoration: none; opacity: 0;
transition: opacity 0.15s;
color: var(--rs-accent);
}
.item-row:hover .item-ext { opacity: 0.5; }
.item-ext:hover { opacity: 1 !important; }
:host-context([data-theme="light"]) .item-ext { color: #06b6d4; }
:host-context([data-theme="dark"]) .item-ext { color: #22d3ee; }
.item-badge {
display: flex; align-items: center; justify-content: center;
@ -430,7 +410,7 @@ a.rstack-header:hover { background: rgba(255,255,255,0.05); }
user-select: none;
}
.category-header:not(:first-child) {
border-top: 1px solid rgba(128,128,128,0.15);
border-top: 1px solid var(--rs-border-subtle);
margin-top: 4px; padding-top: 10px;
}
`;

View File

@ -373,7 +373,6 @@ export class RStackIdentity extends HTMLElement {
#render() {
const session = getSession();
const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light";
if (session) {
const username = session.claims.username || "";
@ -403,7 +402,7 @@ export class RStackIdentity extends HTMLElement {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="user ${theme}" id="user-toggle">
<div class="user" id="user-toggle">
<div class="avatar-wrap">
<div class="avatar">${initial}</div>
<span class="notif-badge" style="display:${notifCount > 0 ? "flex" : "none"}">${notifCount > 0 ? notifCount : ""}</span>
@ -484,7 +483,7 @@ export class RStackIdentity extends HTMLElement {
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="signin-btn ${theme}" id="signin-btn">🔑 Sign In</button>
<button class="signin-btn" id="signin-btn">🔑 Sign In</button>
`;
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
@ -1429,10 +1428,7 @@ const STYLES = `
font-size: 0.875rem; font-weight: 600; cursor: pointer;
transition: all 0.2s; text-decoration: none;
}
.signin-btn.light {
background: linear-gradient(135deg, #06b6d4, #0891b2); color: white;
}
.signin-btn.dark {
.signin-btn {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
@ -1451,8 +1447,7 @@ const STYLES = `
font-size: 0.8rem; max-width: 140px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.user.light .name { color: #64748b; }
.user.dark .name { color: #94a3b8; }
.name { color: var(--rs-text-muted); }
.dropdown {
position: absolute; top: 100%; right: 0; margin-top: 8px;
@ -1460,8 +1455,7 @@ const STYLES = `
box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100;
}
.dropdown.open { display: block; }
.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); }
.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.dropdown.open { display: block; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); }
.dropdown-item {
display: flex; align-items: center; gap: 10px;
@ -1469,10 +1463,8 @@ const STYLES = `
transition: background 0.15s; border: none; background: none;
width: 100%; text-align: left;
}
.user.light .dropdown-item { color: #374151; }
.user.light .dropdown-item:hover { background: #f1f5f9; }
.user.dark .dropdown-item { color: #e2e8f0; }
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
.dropdown-item { color: var(--rs-text-primary); }
.dropdown-item:hover { background: var(--rs-bg-hover); }
.dropdown-item--danger { color: #ef4444 !important; }
.dropdown-header {
@ -1480,11 +1472,9 @@ const STYLES = `
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-header { color: var(--rs-text-primary); }
.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); }
.dropdown-divider { background: var(--rs-border-subtle); }
/* Avatar wrapper + notification badge */
.avatar-wrap { position: relative; }
@ -1495,7 +1485,7 @@ const STYLES = `
display: flex; align-items: center; justify-content: center;
padding: 0 4px; border: 2px solid #1e293b; line-height: 1;
}
.user.light .notif-badge { border-color: white; }
.notif-badge { border-color: var(--rs-bg-surface); }
/* Notification items in dropdown */
.dropdown-section-label {
@ -1505,9 +1495,7 @@ const STYLES = `
.notif-item {
padding: 10px 16px; border-left: 3px solid #fbbf24;
}
.notif-text { font-size: 0.8rem; line-height: 1.4; }
.user.light .notif-text { color: #374151; }
.user.dark .notif-text { color: #e2e8f0; }
.notif-text { font-size: 0.8rem; line-height: 1.4; color: var(--rs-text-primary); }
.notif-msg {
font-size: 0.75rem; color: #94a3b8; font-style: italic;
margin-top: 4px; overflow: hidden; text-overflow: ellipsis;
@ -1536,7 +1524,7 @@ const STYLES = `
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; border-radius: 10px;
background: rgba(255,255,255,0.15); cursor: pointer;
background: var(--rs-border); cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::before {
@ -1547,8 +1535,6 @@ const STYLES = `
}
.toggle-switch input:checked + .toggle-slider { background: #059669; }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.user.light .toggle-slider { background: rgba(0,0,0,0.15); }
.user.light .toggle-switch input:checked + .toggle-slider { background: #059669; }
`;
const MODAL_STYLES = `
@ -1558,9 +1544,9 @@ const MODAL_STYLES = `
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.auth-modal {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%;
text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
text-align: center; color: var(--rs-text-primary); box-shadow: var(--rs-shadow-lg);
animation: slideUp 0.3s;
}
.auth-modal h2 {
@ -1568,15 +1554,15 @@ const MODAL_STYLES = `
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.auth-modal p { color: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.input {
width: 100%; padding: 12px 16px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: white; font-size: 1rem; margin-bottom: 1rem; outline: none;
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
color: var(--rs-input-text); font-size: 1rem; margin-bottom: 1rem; outline: none;
transition: border-color 0.2s; box-sizing: border-box;
}
.input:focus { border-color: #06b6d4; }
.input::placeholder { color: #64748b; }
.input::placeholder { color: var(--rs-text-muted); }
.actions { display: flex; gap: 12px; margin-top: 0.5rem; }
.btn {
flex: 1; padding: 12px 20px; border-radius: 8px; border: none;
@ -1585,10 +1571,10 @@ const MODAL_STYLES = `
.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); }
.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; }
.btn--secondary { background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary); border: 1px solid var(--rs-border); }
.btn--secondary:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: var(--rs-text-muted); }
.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; }
.toggle a:hover { text-decoration: underline; }
.spinner {
@ -1599,21 +1585,21 @@ const MODAL_STYLES = `
}
.close-btn {
position: absolute; top: 12px; right: 16px;
background: none; border: none; color: #64748b; font-size: 1.5rem;
background: none; border: none; color: var(--rs-text-muted); 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); }
.close-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.auth-modal { position: relative; }
.actions--stack { flex-direction: column; }
.btn--outline {
background: transparent; color: #94a3b8;
border: 1px solid rgba(255,255,255,0.15);
background: transparent; color: var(--rs-text-secondary);
border: 1px solid var(--rs-border);
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; }
.btn--outline:hover { border-color: #06b6d4; color: var(--rs-text-primary); background: rgba(6,182,212,0.08); }
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: var(--rs-text-muted); }
.learn-more a { color: #06b6d4; text-decoration: none; }
.learn-more a:hover { text-decoration: underline; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@ -1716,7 +1702,7 @@ const ACCOUNT_MODAL_STYLES = `
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; border-radius: 10px;
background: rgba(255,255,255,0.15); cursor: pointer;
background: var(--rs-border); cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::before {

View File

@ -310,11 +310,9 @@ const STYLES = `
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 10px;
transition: all 0.2s;
background: var(--rs-btn-secondary-bg);
}
:host-context([data-theme="dark"]) .mi-bar { background: rgba(255,255,255,0.06); }
:host-context([data-theme="light"]) .mi-bar { background: rgba(0,0,0,0.04); }
:host-context([data-theme="dark"]) .mi-bar.focused { background: rgba(255,255,255,0.1); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
:host-context([data-theme="light"]) .mi-bar.focused { background: rgba(0,0,0,0.06); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
.mi-bar.focused { background: var(--rs-bg-hover); box-shadow: 0 0 0 1px rgba(6,182,212,0.3); }
.mi-icon {
font-size: 0.9rem; flex-shrink: 0;
@ -326,11 +324,9 @@ const STYLES = `
flex: 1; border: none; outline: none; background: none;
font-size: 0.85rem; min-width: 0;
font-family: inherit;
color: var(--rs-text-primary);
}
:host-context([data-theme="dark"]) .mi-input { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-input { color: #0f172a; }
:host-context([data-theme="dark"]) .mi-input::placeholder { color: #64748b; }
:host-context([data-theme="light"]) .mi-input::placeholder { color: #94a3b8; }
.mi-input::placeholder { color: var(--rs-text-muted); }
.mi-panel {
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
@ -338,10 +334,9 @@ const STYLES = `
border-radius: 14px; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
display: none; z-index: 300;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
}
.mi-panel.open { display: flex; flex-direction: column; }
:host-context([data-theme="dark"]) .mi-panel { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
:host-context([data-theme="light"]) .mi-panel { background: white; border: 1px solid rgba(0,0,0,0.1); }
.mi-messages {
flex: 1; overflow-y: auto; padding: 16px;
@ -355,15 +350,12 @@ const STYLES = `
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
:host-context([data-theme="dark"]) .mi-welcome p { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-welcome p { color: #374151; }
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; }
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; color: var(--rs-text-primary); }
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-who { color: #06b6d4; }
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-who { color: #0891b2; }
.mi-msg--user .mi-msg-who { color: #06b6d4; }
.mi-msg--assistant .mi-msg-who {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
@ -371,26 +363,22 @@ const STYLES = `
.mi-msg-body {
font-size: 0.85rem; line-height: 1.6; word-break: break-word;
color: var(--rs-text-secondary);
}
:host-context([data-theme="dark"]) .mi-msg-body { color: #cbd5e1; }
:host-context([data-theme="light"]) .mi-msg-body { color: #374151; }
:host-context([data-theme="dark"]) .mi-msg--user .mi-msg-body { color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-msg--user .mi-msg-body { color: #0f172a; }
.mi-msg--user .mi-msg-body { color: var(--rs-text-primary); }
.mi-code {
padding: 1px 5px; border-radius: 4px; font-size: 0.8rem;
font-family: 'SF Mono', Monaco, Consolas, monospace;
background: var(--rs-btn-secondary-bg); color: #0ea5e9;
}
:host-context([data-theme="dark"]) .mi-code { background: rgba(255,255,255,0.08); color: #7dd3fc; }
:host-context([data-theme="light"]) .mi-code { background: rgba(0,0,0,0.06); color: #0284c7; }
.mi-typing { display: inline-flex; gap: 4px; padding: 4px 0; }
.mi-typing span {
width: 6px; height: 6px; border-radius: 50%;
animation: miBounce 1.2s ease-in-out infinite;
background: var(--rs-text-muted);
}
:host-context([data-theme="dark"]) .mi-typing span { background: #64748b; }
:host-context([data-theme="light"]) .mi-typing span { background: #94a3b8; }
.mi-typing span:nth-child(2) { animation-delay: 0.15s; }
.mi-typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes miBounce {
@ -401,20 +389,17 @@ const STYLES = `
.mi-action-chip {
display: inline-block; margin-top: 6px; padding: 3px 10px;
border-radius: 12px; font-size: 0.75rem; font-weight: 600;
background: rgba(6,182,212,0.12); color: #06b6d4;
}
:host-context([data-theme="dark"]) .mi-action-chip { background: rgba(6,182,212,0.15); color: #67e8f9; }
:host-context([data-theme="light"]) .mi-action-chip { background: rgba(6,182,212,0.1); color: #0891b2; }
.mi-tool-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.mi-tool-chip {
padding: 4px 10px; border-radius: 8px; border: none;
font-size: 0.75rem; cursor: pointer; transition: background 0.15s;
font-family: inherit;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
:host-context([data-theme="dark"]) .mi-tool-chip { background: rgba(255,255,255,0.08); color: #e2e8f0; }
:host-context([data-theme="light"]) .mi-tool-chip { background: rgba(0,0,0,0.05); color: #374151; }
:host-context([data-theme="dark"]) .mi-tool-chip:hover { background: rgba(255,255,255,0.15); }
:host-context([data-theme="light"]) .mi-tool-chip:hover { background: rgba(0,0,0,0.1); }
.mi-tool-chip:hover { background: var(--rs-bg-hover); }
@media (max-width: 640px) {
.mi { max-width: none; width: 100%; }

View File

@ -0,0 +1,770 @@
/**
* <rstack-space-settings> Space settings slide-out panel.
*
* Shows members list, add member (by username or email), pending invites.
* Only admins/owners see the full panel; others see a read-only member list.
*/
const SESSION_KEY = "encryptid_session";
interface MemberInfo {
did: string;
role: string;
displayName?: string;
joinedAt?: number;
}
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch { return null; }
}
function getToken(): string | null {
return getSession()?.accessToken || null;
}
export class RStackSpaceSettings extends HTMLElement {
private _open = false;
private _space = "";
private _members: MemberInfo[] = [];
private _ownerDID = "";
private _myRole: string = "viewer";
private _isOwner = false;
private _invites: any[] = [];
private _addMode: "username" | "email" = "username";
private _lookupResult: { did: string; username: string; displayName: string } | null = null;
private _lookupError = "";
static define() {
if (!customElements.get("rstack-space-settings")) {
customElements.define("rstack-space-settings", RStackSpaceSettings);
}
}
static get observedAttributes() { return ["space"]; }
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "space") this._space = val;
}
connectedCallback() {
this._space = this.getAttribute("space") || "";
this.attachShadow({ mode: "open" });
this._render();
}
open() {
this._open = true;
this._loadData();
this._render();
}
close() {
this._open = false;
this._render();
}
toggle() {
if (this._open) this.close(); else this.open();
}
private async _loadData() {
if (!this._space) return;
const session = getSession();
const token = getToken();
// Load community data from WS-synced doc (via global)
const sync = (window as any).__rspaceCommunitySync;
if (sync?.doc) {
const data = sync.doc;
this._ownerDID = data.meta?.ownerDID || "";
this._members = [];
if (data.members) {
for (const [did, m] of Object.entries(data.members)) {
const member = m as MemberInfo;
this._members.push({ did, role: member.role, displayName: member.displayName, joinedAt: member.joinedAt });
}
}
} else {
// Fallback: fetch from API
try {
const res = await fetch(`/${this._space}/rspace/api/meta`, {
headers: token ? { "Authorization": `Bearer ${token}` } : {},
});
if (res.ok) {
const json = await res.json();
this._ownerDID = json.meta?.ownerDID || "";
if (json.meta?.members) {
this._members = [];
for (const [did, m] of Object.entries(json.meta.members)) {
const member = m as MemberInfo;
this._members.push({ did, role: member.role, displayName: member.displayName });
}
}
}
} catch {}
}
// Determine my role
if (session?.claims?.sub) {
this._isOwner = session.claims.sub === this._ownerDID;
const myMember = this._members.find(m => m.did === session.claims.sub);
this._myRole = this._isOwner ? "admin" : (myMember?.role || "viewer");
}
// Load invites (admin only)
if (this._isAdmin && token) {
try {
const res = await fetch(`/${this._space}/invites`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (res.ok) {
const json = await res.json();
this._invites = json.invites || [];
}
} catch {}
}
this._render();
}
private get _isAdmin(): boolean {
return this._isOwner || this._myRole === "admin";
}
private _render() {
if (!this.shadowRoot) return;
if (!this._open) {
this.shadowRoot.innerHTML = "";
return;
}
const roleOptions = (currentRole: string) => {
const roles = ["viewer", "member", "moderator", "admin"];
return roles.map(r => `<option value="${r}" ${r === currentRole ? "selected" : ""}>${r}</option>`).join("");
};
const membersHTML = this._members.map(m => {
const isOwnerRow = m.did === this._ownerDID;
const displayName = m.displayName || m.did.slice(0, 16) + "…";
const initial = displayName.charAt(0).toUpperCase();
const roleBadge = isOwnerRow
? `<span class="role-badge role-owner">Owner</span>`
: `<span class="role-badge role-${m.role}">${m.role}</span>`;
let controls = "";
if (this._isAdmin && !isOwnerRow) {
controls = `
<select class="role-select" data-did="${m.did}">
${roleOptions(m.role)}
</select>
<button class="remove-btn" data-did="${m.did}" title="Remove member">&times;</button>
`;
}
return `
<div class="member-row">
<div class="member-avatar">${initial}</div>
<div class="member-info">
<div class="member-name">${this._esc(displayName)}</div>
${roleBadge}
</div>
<div class="member-controls">${controls}</div>
</div>
`;
}).join("");
const invitesHTML = this._invites
.filter(inv => inv.status === "pending")
.map(inv => {
const expiry = new Date(inv.expiresAt).toLocaleDateString();
return `
<div class="invite-row">
<div class="invite-info">
<span class="invite-email">${this._esc(inv.email || "Link invite")}</span>
<span class="invite-role">${inv.role}</span>
<span class="invite-expiry">expires ${expiry}</span>
</div>
<button class="revoke-btn" data-invite-id="${inv.id}">Revoke</button>
</div>
`;
}).join("") || '<div class="empty-state">No pending invites</div>';
this.shadowRoot.innerHTML = `
<style>${PANEL_CSS}</style>
<div class="overlay" id="overlay"></div>
<div class="panel">
<div class="panel-header">
<h2>Space Settings</h2>
<button class="close-btn" id="close-btn">&times;</button>
</div>
<div class="panel-content">
<section class="section">
<h3>Members <span class="count">${this._members.length}</span></h3>
<div class="members-list">${membersHTML}</div>
</section>
${this._isAdmin ? `
<section class="section">
<h3>Add Member</h3>
<div class="add-toggle">
<button class="toggle-btn ${this._addMode === "username" ? "active" : ""}" data-mode="username">By Username</button>
<button class="toggle-btn ${this._addMode === "email" ? "active" : ""}" data-mode="email">By Email</button>
</div>
${this._addMode === "username" ? `
<div class="add-form">
<input type="text" class="input" id="add-username" placeholder="Username…" />
${this._lookupResult ? `<div class="lookup-result">Found: <strong>${this._esc(this._lookupResult.displayName)}</strong> (@${this._esc(this._lookupResult.username)})</div>` : ""}
${this._lookupError ? `<div class="error-msg">${this._esc(this._lookupError)}</div>` : ""}
<div class="add-row">
<select class="input role-input" id="add-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="add-btn" id="add-by-username">Add</button>
</div>
</div>
` : `
<div class="add-form">
<input type="email" class="input" id="add-email" placeholder="Email address…" />
<div class="add-row">
<select class="input role-input" id="add-email-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="add-btn" id="add-by-email">Send Invite</button>
</div>
</div>
`}
</section>
<section class="section">
<h3>Pending Invites</h3>
<div class="invites-list">${invitesHTML}</div>
</section>
` : ""}
</div>
</div>
`;
this._bindEvents();
}
private _bindEvents() {
const sr = this.shadowRoot!;
sr.getElementById("close-btn")?.addEventListener("click", () => this.close());
sr.getElementById("overlay")?.addEventListener("click", () => this.close());
// Toggle add mode
sr.querySelectorAll(".toggle-btn").forEach(btn => {
btn.addEventListener("click", () => {
this._addMode = (btn as HTMLElement).dataset.mode as "username" | "email";
this._lookupResult = null;
this._lookupError = "";
this._render();
});
});
// Username lookup with debounce
const usernameInput = sr.getElementById("add-username") as HTMLInputElement;
if (usernameInput) {
let debounce: ReturnType<typeof setTimeout>;
usernameInput.addEventListener("input", () => {
clearTimeout(debounce);
this._lookupResult = null;
this._lookupError = "";
debounce = setTimeout(() => this._lookupUser(usernameInput.value), 300);
});
}
// Add by username
sr.getElementById("add-by-username")?.addEventListener("click", () => this._addByUsername());
// Add by email
sr.getElementById("add-by-email")?.addEventListener("click", () => this._addByEmail());
// Role changes
sr.querySelectorAll(".role-select").forEach(sel => {
sel.addEventListener("change", (e) => {
const target = e.target as HTMLSelectElement;
this._changeRole(target.dataset.did!, target.value);
});
});
// Remove member
sr.querySelectorAll(".remove-btn").forEach(btn => {
btn.addEventListener("click", () => {
const did = (btn as HTMLElement).dataset.did!;
this._removeMember(did);
});
});
// Revoke invite
sr.querySelectorAll(".revoke-btn").forEach(btn => {
btn.addEventListener("click", () => {
const id = (btn as HTMLElement).dataset.inviteId!;
this._revokeInvite(id);
});
});
}
private async _lookupUser(username: string) {
if (!username || username.length < 2) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`https://auth.rspace.online/api/users/lookup?username=${encodeURIComponent(username)}`, {
headers: { "Authorization": `Bearer ${token}` },
});
if (res.ok) {
this._lookupResult = await res.json();
this._lookupError = "";
} else {
this._lookupResult = null;
this._lookupError = username.length > 2 ? "User not found" : "";
}
} catch {
this._lookupError = "Lookup failed";
}
this._render();
}
private async _addByUsername() {
const sr = this.shadowRoot!;
const input = sr.getElementById("add-username") as HTMLInputElement;
const roleSelect = sr.getElementById("add-role") as HTMLSelectElement;
if (!input?.value) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`/${this._space}/members/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ username: input.value, role: roleSelect.value }),
});
if (res.ok) {
input.value = "";
this._lookupResult = null;
this._loadData();
} else {
const err = await res.json();
this._lookupError = err.error || "Failed to add member";
this._render();
}
} catch {
this._lookupError = "Network error";
this._render();
}
}
private async _addByEmail() {
const sr = this.shadowRoot!;
const input = sr.getElementById("add-email") as HTMLInputElement;
const roleSelect = sr.getElementById("add-email-role") as HTMLSelectElement;
if (!input?.value) return;
const token = getToken();
if (!token) return;
try {
const res = await fetch(`/${this._space}/invite`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ email: input.value, role: roleSelect.value }),
});
if (res.ok) {
input.value = "";
this._loadData();
}
} catch {}
}
private async _changeRole(did: string, newRole: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`/${this._space}/members/${encodeURIComponent(did)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ role: newRole }),
});
this._loadData();
} catch {}
}
private async _removeMember(did: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`/${this._space}/members/${encodeURIComponent(did)}`, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` },
});
this._loadData();
} catch {}
}
private async _revokeInvite(id: string) {
const token = getToken();
if (!token) return;
try {
await fetch(`https://auth.rspace.online/api/spaces/${this._space}/invites/${id}/revoke`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` },
});
this._loadData();
} catch {}
}
private _esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
const PANEL_CSS = `
:host {
display: contents;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
animation: fadeIn 0.2s ease;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(380px, 90vw);
background: #1e293b;
border-left: 1px solid rgba(255,255,255,0.1);
z-index: 9999;
display: flex;
flex-direction: column;
animation: slideIn 0.25s ease;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.panel-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #14b8a6, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.close-btn {
background: none;
border: none;
color: #64748b;
font-size: 1.5rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.close-btn:hover { color: #e2e8f0; background: rgba(255,255,255,0.08); }
.panel-content {
flex: 1;
overflow-y: auto;
padding: 0 20px 20px;
}
.section {
padding-top: 16px;
}
.section h3 {
font-size: 0.82rem;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px;
}
.count {
font-weight: 400;
color: #64748b;
font-size: 0.75rem;
margin-left: 4px;
}
/* Members */
.members-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.member-row:hover { background: rgba(255,255,255,0.04); }
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(20,184,166,0.15);
color: #14b8a6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 0.85rem;
font-weight: 500;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.role-owner { background: rgba(124,58,237,0.2); color: #a78bfa; }
.role-admin { background: rgba(239,68,68,0.15); color: #f87171; }
.role-moderator { background: rgba(245,158,11,0.15); color: #fbbf24; }
.role-member { background: rgba(20,184,166,0.15); color: #14b8a6; }
.role-viewer { background: rgba(148,163,184,0.15); color: #94a3b8; }
.member-controls {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.role-select {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
border-radius: 6px;
padding: 3px 6px;
font-size: 0.72rem;
cursor: pointer;
}
.role-select:focus { outline: none; border-color: #14b8a6; }
.remove-btn {
background: none;
border: none;
color: #64748b;
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.remove-btn:hover { color: #f87171; background: rgba(239,68,68,0.1); }
/* Add member */
.add-toggle {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.toggle-btn {
flex: 1;
padding: 6px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
color: #94a3b8;
border-radius: 6px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: rgba(20,184,166,0.1);
border-color: rgba(20,184,166,0.3);
color: #14b8a6;
}
.add-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.input {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
border-radius: 8px;
padding: 8px 12px;
font-size: 0.85rem;
width: 100%;
box-sizing: border-box;
}
.input:focus { outline: none; border-color: #14b8a6; }
.input::placeholder { color: #64748b; }
.add-row {
display: flex;
gap: 8px;
}
.role-input {
flex: 0 0 auto;
width: auto;
padding: 8px 10px;
}
.add-btn {
flex-shrink: 0;
padding: 8px 16px;
background: linear-gradient(135deg, #14b8a6, #0d9488);
border: none;
color: white;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.add-btn:hover { opacity: 0.9; }
.lookup-result {
font-size: 0.78rem;
color: #14b8a6;
padding: 4px 0;
}
.error-msg {
font-size: 0.78rem;
color: #f87171;
padding: 4px 0;
}
/* Invites */
.invites-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255,255,255,0.02);
}
.invite-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.invite-email {
font-size: 0.82rem;
color: #e2e8f0;
}
.invite-role {
font-size: 0.7rem;
color: #14b8a6;
}
.invite-expiry {
font-size: 0.65rem;
color: #64748b;
}
.revoke-btn {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.2);
color: #f87171;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
}
.revoke-btn:hover { background: rgba(239,68,68,0.2); }
.empty-state {
font-size: 0.8rem;
color: #64748b;
padding: 12px 0;
text-align: center;
}
`;

View File

@ -839,12 +839,10 @@ const STYLES = `
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px; border: none;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s; color: inherit;
transition: background 0.15s;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
: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); }
.trigger:hover { background: var(--rs-bg-hover); }
.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.caret { font-size: 0.7em; opacity: 0.6; }
@ -853,10 +851,9 @@ const STYLES = `
min-width: 260px; max-height: 400px; overflow-y: auto;
border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.25);
display: none; z-index: 10001;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
}
.menu.open { display: block; }
:host-context([data-theme="light"]) .menu { background: white; border: 1px solid rgba(0,0,0,0.1); }
:host-context([data-theme="dark"]) .menu { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
.section-label {
padding: 8px 14px 4px; font-size: 0.7rem; font-weight: 600;
@ -867,13 +864,10 @@ const STYLES = `
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; text-decoration: none; cursor: pointer;
transition: background 0.12s; border-left: 3px solid transparent;
color: var(--rs-text-primary);
}
:host-context([data-theme="light"]) .item { color: #374151; }
:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; }
:host-context([data-theme="light"]) .item.active { background: #e0f2fe; }
:host-context([data-theme="dark"]) .item { color: #e2e8f0; }
:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); }
:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); }
.item:hover { background: var(--rs-bg-hover); }
.item.active { background: var(--rs-bg-active); }
/* Visibility color accents — left border */
.item.vis-public { border-left-color: #34d399; }
@ -891,9 +885,6 @@ const STYLES = `
.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; }
.item-vis.vis-permissioned { background: rgba(251,191,36,0.15); color: #fbbf24; }
:host-context([data-theme="light"]) .item-vis.vis-public { background: #d1fae5; color: #059669; }
:host-context([data-theme="light"]) .item-vis.vis-private { background: #fee2e2; color: #dc2626; }
:host-context([data-theme="light"]) .item-vis.vis-permissioned { background: #fef3c7; color: #d97706; }
/* Item row: wraps link + gear icon */
.item-row {
@ -903,10 +894,8 @@ const STYLES = `
.item-row .item {
flex: 1; border-left: none;
}
:host-context([data-theme="light"]) .item-row:hover { background: #f1f5f9; }
:host-context([data-theme="light"]) .item-row.active { background: #e0f2fe; }
:host-context([data-theme="dark"]) .item-row:hover { background: rgba(255,255,255,0.05); }
:host-context([data-theme="dark"]) .item-row.active { background: rgba(6,182,212,0.1); }
.item-row:hover { background: var(--rs-bg-hover); }
.item-row.active { background: var(--rs-bg-active); }
.item-row.vis-public { border-left-color: #34d399; }
.item-row.vis-private { border-left-color: #f87171; }
.item-row.vis-permissioned { border-left-color: #fbbf24; }
@ -916,8 +905,7 @@ const STYLES = `
font-size: 1rem; opacity: 0.4; transition: opacity 0.15s; flex-shrink: 0;
}
.item-gear:hover { opacity: 1; }
:host-context([data-theme="light"]) .item-gear { color: #374151; }
:host-context([data-theme="dark"]) .item-gear { color: #e2e8f0; }
.item-gear { color: var(--rs-text-primary); }
.item--create {
font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important;
@ -928,10 +916,9 @@ const STYLES = `
/* (you)rSpace CTA */
.item--yourspace {
border-left-color: #f87171; padding: 12px 14px;
background: var(--rs-bg-hover);
}
.item--yourspace .item-name { font-weight: 700; font-size: 0.9rem; }
:host-context([data-theme="light"]) .item--yourspace { background: #fff5f5; }
:host-context([data-theme="dark"]) .item--yourspace { background: rgba(248,113,113,0.06); }
.yourspace-btn {
margin-left: auto; padding: 5px 12px; border-radius: 6px; border: none;
font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap;
@ -940,9 +927,7 @@ const STYLES = `
}
.yourspace-btn:hover { opacity: 0.85; transform: translateY(-1px); }
.divider { height: 1px; margin: 4px 0; }
:host-context([data-theme="light"]) .divider { background: rgba(0,0,0,0.08); }
:host-context([data-theme="dark"]) .divider { background: rgba(255,255,255,0.08); }
.divider { height: 1px; margin: 4px 0; background: var(--rs-border-subtle); }
.menu-loading, .menu-empty {
padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8;
@ -951,8 +936,6 @@ const STYLES = `
/* Discover section — non-navigable items */
.item--discover { cursor: default; flex-wrap: wrap; }
.item--discover:hover { background: transparent !important; }
:host-context([data-theme="light"]) .item--discover:hover { background: transparent !important; }
:host-context([data-theme="dark"]) .item--discover:hover { background: transparent !important; }
.item-request-btn {
margin-left: auto; padding: 4px 10px; border-radius: 6px; border: none;
@ -966,7 +949,6 @@ const STYLES = `
.item-badge--pending {
background: rgba(251,191,36,0.15); color: #fbbf24; font-weight: 600;
}
:host-context([data-theme="light"]) .item-badge--pending { background: #fef3c7; color: #d97706; }
`;
const REQUEST_MODAL_STYLES = `

View File

@ -1155,32 +1155,16 @@ const STYLES = `
user-select: none;
position: relative;
flex-shrink: 0;
}
:host-context([data-theme="dark"]) .tab {
color: #94a3b8;
color: var(--rs-text-muted);
background: transparent;
}
:host-context([data-theme="dark"]) .tab:hover {
background: rgba(255,255,255,0.05);
color: #e2e8f0;
.tab:hover {
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
:host-context([data-theme="dark"]) .tab.active {
background: rgba(255,255,255,0.08);
color: #f1f5f9;
}
:host-context([data-theme="light"]) .tab {
color: #64748b;
background: transparent;
}
:host-context([data-theme="light"]) .tab:hover {
background: rgba(0,0,0,0.04);
color: #1e293b;
}
:host-context([data-theme="light"]) .tab.active {
background: rgba(0,0,0,0.06);
color: #0f172a;
.tab.active {
background: var(--rs-btn-secondary-bg);
color: var(--rs-text-primary);
}
/* Active indicator line at bottom */
@ -1279,14 +1263,8 @@ const STYLES = `
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
scrollbar-width: thin;
}
:host-context([data-theme="dark"]) .add-menu {
background: #1e293b;
border: 1px solid rgba(255,255,255,0.1);
}
:host-context([data-theme="light"]) .add-menu {
background: white;
border: 1px solid rgba(0,0,0,0.1);
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
}
.add-menu-category {
@ -1319,8 +1297,7 @@ const STYLES = `
text-align: left;
transition: background 0.12s;
}
:host-context([data-theme="dark"]) .add-menu-item:hover { background: rgba(255,255,255,0.06); }
:host-context([data-theme="light"]) .add-menu-item:hover { background: rgba(0,0,0,0.04); }
.add-menu-item:hover { background: var(--rs-bg-hover); }
.add-menu-badge {
display: inline-flex;
@ -1404,12 +1381,8 @@ const STYLES = `
transition: background 0.15s, color 0.15s;
}
.view-toggle:hover {
background: rgba(255,255,255,0.08);
color: #e2e8f0;
}
:host-context([data-theme="light"]) .view-toggle:hover {
background: rgba(0,0,0,0.05);
color: #1e293b;
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
.view-toggle.active {
color: #22d3ee;
@ -1424,15 +1397,8 @@ const STYLES = `
max-height: 60vh;
transition: max-height 0.3s ease;
position: relative;
}
:host-context([data-theme="dark"]) .stack-view {
background: rgba(15,23,42,0.5);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
:host-context([data-theme="light"]) .stack-view {
background: rgba(248,250,252,0.8);
border-bottom: 1px solid rgba(0,0,0,0.06);
background: color-mix(in srgb, var(--rs-bg-surface) 50%, transparent);
border-bottom: 1px solid var(--rs-border-subtle);
}
.stack-view-3d {
@ -1466,19 +1432,10 @@ const STYLES = `
transition: border-color 0.2s, box-shadow 0.2s;
transform-style: preserve-3d;
backface-visibility: hidden;
}
:host-context([data-theme="dark"]) .layer-plane {
background: rgba(15,23,42,0.65);
background: color-mix(in srgb, var(--rs-bg-surface) 65%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #e2e8f0;
}
:host-context([data-theme="light"]) .layer-plane {
background: rgba(255,255,255,0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #1e293b;
color: var(--rs-text-primary);
}
.layer-plane:hover {
@ -1489,12 +1446,7 @@ const STYLES = `
.layer-plane--active {
border-width: 2px;
box-shadow: 0 0 24px color-mix(in srgb, var(--layer-color) 40%, transparent);
}
:host-context([data-theme="dark"]) .layer-plane--active {
background: rgba(15,23,42,0.85);
}
:host-context([data-theme="light"]) .layer-plane--active {
background: rgba(255,255,255,0.9);
background: color-mix(in srgb, var(--rs-bg-surface) 85%, transparent);
}
/* Drag-to-connect visual states */
@ -1799,16 +1751,9 @@ const STYLES = `
padding: 16px;
z-index: 50;
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
}
:host-context([data-theme="dark"]) .flow-dialog {
background: #1e293b;
border: 1px solid rgba(255,255,255,0.1);
color: #e2e8f0;
}
:host-context([data-theme="light"]) .flow-dialog {
background: white;
border: 1px solid rgba(0,0,0,0.1);
color: #1e293b;
background: var(--rs-bg-surface);
border: 1px solid var(--rs-border);
color: var(--rs-text-primary);
}
.flow-dialog-header {
@ -1911,18 +1856,14 @@ const STYLES = `
.flow-dialog-input {
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(255,255,255,0.1);
border: 1px solid var(--rs-border);
border-radius: 5px;
background: rgba(255,255,255,0.04);
background: var(--rs-btn-secondary-bg);
color: inherit;
font-size: 0.75rem;
outline: none;
}
.flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); }
:host-context([data-theme="light"]) .flow-dialog-input {
border-color: rgba(0,0,0,0.1);
background: rgba(0,0,0,0.02);
}
.flow-dialog-range {
width: 100%;

View File

@ -833,4 +833,89 @@ export async function checkDatabaseHealth(): Promise<boolean> {
}
}
// ============================================================================
// SPACE INVITE OPERATIONS
// ============================================================================
export interface StoredSpaceInvite {
id: string;
spaceSlug: string;
email: string | null;
role: string;
token: string;
invitedBy: string;
status: 'pending' | 'accepted' | 'expired' | 'revoked';
createdAt: number;
expiresAt: number;
acceptedAt: number | null;
acceptedByDid: string | null;
}
function rowToInvite(row: any): StoredSpaceInvite {
return {
id: row.id,
spaceSlug: row.space_slug,
email: row.email || null,
role: row.role,
token: row.token,
invitedBy: row.invited_by,
status: row.status,
createdAt: new Date(row.created_at).getTime(),
expiresAt: new Date(row.expires_at).getTime(),
acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null,
acceptedByDid: row.accepted_by_did || null,
};
}
export async function createSpaceInvite(
id: string,
spaceSlug: string,
token: string,
invitedBy: string,
role: string,
expiresAt: number,
email?: string,
): Promise<StoredSpaceInvite> {
const rows = await sql`
INSERT INTO space_invites (id, space_slug, email, role, token, invited_by, expires_at)
VALUES (${id}, ${spaceSlug}, ${email || null}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)})
RETURNING *
`;
return rowToInvite(rows[0]);
}
export async function getSpaceInviteByToken(token: string): Promise<StoredSpaceInvite | null> {
const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`;
if (rows.length === 0) return null;
return rowToInvite(rows[0]);
}
export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceInvite[]> {
const rows = await sql`
SELECT * FROM space_invites
WHERE space_slug = ${spaceSlug}
ORDER BY created_at DESC
`;
return rows.map(rowToInvite);
}
export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise<StoredSpaceInvite | null> {
const rows = await sql`
UPDATE space_invites
SET status = 'accepted', accepted_at = NOW(), accepted_by_did = ${acceptedByDid}
WHERE token = ${token} AND status = 'pending' AND expires_at > NOW()
RETURNING *
`;
if (rows.length === 0) return null;
return rowToInvite(rows[0]);
}
export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise<boolean> {
const result = await sql`
UPDATE space_invites SET status = 'revoked'
WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending'
`;
return result.count > 0;
}
export { sql };

View File

@ -151,3 +151,34 @@ CREATE TABLE IF NOT EXISTS encrypted_addresses (
);
CREATE INDEX IF NOT EXISTS idx_encrypted_addresses_user_id ON encrypted_addresses(user_id);
-- ============================================================================
-- ROLE RENAME: participant → member
-- ============================================================================
UPDATE space_members SET role = 'member' WHERE role = 'participant';
ALTER TABLE space_members DROP CONSTRAINT IF EXISTS space_members_role_check;
ALTER TABLE space_members ADD CONSTRAINT space_members_role_check
CHECK (role IN ('viewer', 'member', 'moderator', 'admin'));
-- ============================================================================
-- SPACE INVITES
-- ============================================================================
CREATE TABLE IF NOT EXISTS space_invites (
id TEXT PRIMARY KEY,
space_slug TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('viewer', 'member', 'moderator', 'admin')),
token TEXT UNIQUE NOT NULL,
invited_by TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
accepted_by_did TEXT
);
CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites(space_slug);

View File

@ -29,6 +29,7 @@ import {
setUserEmail,
getUserByEmail,
getUserById,
getUserByUsername,
storeRecoveryToken,
getRecoveryToken,
markRecoveryTokenUsed,
@ -67,6 +68,11 @@ import {
listAllUsers,
deleteUser,
deleteSpaceMembers,
createSpaceInvite,
getSpaceInviteByToken,
listSpaceInvites,
acceptSpaceInvite,
revokeSpaceInvite,
sql,
} from './db.js';
import {
@ -2516,7 +2522,7 @@ app.post('/encryptid/api/safe/verify', async (c) => {
// SPACE MEMBERSHIP ROUTES
// ============================================================================
const VALID_ROLES = ['viewer', 'participant', 'moderator', 'admin'];
const VALID_ROLES = ['viewer', 'member', 'moderator', 'admin'];
// GET /api/spaces/:slug/members — list all members
app.get('/api/spaces/:slug/members', async (c) => {
@ -2611,6 +2617,128 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
return c.json({ success: true });
});
// ============================================================================
// USER LOOKUP
// ============================================================================
// GET /api/users/lookup?username=<username> — look up user by username
app.get('/api/users/lookup', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const username = c.req.query('username');
if (!username) return c.json({ error: 'username query parameter required' }, 400);
const user = await getUserByUsername(username);
if (!user) return c.json({ error: 'User not found' }, 404);
return c.json({
did: user.did,
username: user.username,
displayName: user.display_name || user.username,
});
});
// ============================================================================
// SPACE INVITE ROUTES
// ============================================================================
// POST /api/spaces/:slug/invites — create an invite
app.post('/api/spaces/:slug/invites', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
// Require admin role
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const body = await c.req.json<{ email?: string; role?: string }>();
const role = body.role || 'member';
if (!VALID_ROLES.includes(role)) {
return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
}
const id = crypto.randomUUID();
const token = crypto.randomUUID();
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
const invite = await createSpaceInvite(id, slug, token, claims.sub, role, expiresAt, body.email || undefined);
return c.json(invite, 201);
});
// GET /api/spaces/:slug/invites — list invites for a space
app.get('/api/spaces/:slug/invites', async (c) => {
const { slug } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const invites = await listSpaceInvites(slug);
return c.json({ invites, total: invites.length });
});
// POST /api/spaces/:slug/invites/:id/revoke — revoke an invite
app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => {
const { slug, id } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
const callerMember = await getSpaceMember(slug, claims.sub);
if (!callerMember || callerMember.role !== 'admin') {
return c.json({ error: 'Admin role required' }, 403);
}
const revoked = await revokeSpaceInvite(id, slug);
if (!revoked) return c.json({ error: 'Invite not found or already used' }, 404);
return c.json({ ok: true });
});
// GET /api/invites/:token — get invite details by token (public, no auth needed)
app.get('/api/invites/:token', async (c) => {
const { token } = c.req.param();
const invite = await getSpaceInviteByToken(token);
if (!invite) return c.json({ error: 'Invite not found' }, 404);
if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);
return c.json({
spaceSlug: invite.spaceSlug,
role: invite.role,
expiresAt: invite.expiresAt,
});
});
// POST /api/invites/:token/accept — accept an invite
app.post('/api/invites/:token/accept', async (c) => {
const { token } = c.req.param();
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required — sign in to accept' }, 401);
const invite = await getSpaceInviteByToken(token);
if (!invite) return c.json({ error: 'Invite not found' }, 404);
if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);
// Accept the invite
const accepted = await acceptSpaceInvite(token, claims.sub);
if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);
// Add to space_members with the invite's role
await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy);
return c.json({
ok: true,
spaceSlug: accepted.spaceSlug,
role: accepted.role,
});
});
// ============================================================================
// ADMIN ROUTES
// ============================================================================

File diff suppressed because it is too large Load Diff

View File

@ -5,13 +5,8 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh;
background: #0f172a;
color: #e2e8f0;
}
body[data-theme="light"] {
background: #f8fafc;
color: #0f172a;
background: var(--rs-bg-page);
color: var(--rs-text-primary);
}
/* ── Header bar ── */
@ -27,19 +22,9 @@ body[data-theme="light"] {
z-index: 9999;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rstack-header[data-theme="light"] {
background: rgba(255, 255, 255, 0.9);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
color: #0f172a;
}
.rstack-header[data-theme="dark"] {
background: rgba(15, 23, 42, 0.85);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #e2e8f0;
background: var(--rs-glass-bg);
border-bottom: 1px solid var(--rs-glass-border);
color: var(--rs-text-primary);
}
.rstack-header__left {
@ -72,7 +57,7 @@ body[data-theme="light"] {
text-decoration: none;
white-space: nowrap;
transition: background 0.15s, opacity 0.15s;
background: linear-gradient(135deg, #14b8a6, #0d9488);
background: var(--rs-gradient-cta);
color: #fff;
box-shadow: 0 1px 4px rgba(20, 184, 166, 0.25);
}
@ -100,7 +85,7 @@ body[data-theme="light"] {
}
.rstack-header__brand-gradient {
background: linear-gradient(135deg, #14b8a6, #22d3ee);
background: var(--rs-gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@ -115,15 +100,8 @@ body[data-theme="light"] {
z-index: 9998;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(128,128,128,0.1);
}
.rstack-tab-row[data-theme="dark"] {
background: rgba(15, 23, 42, 0.8);
}
.rstack-tab-row[data-theme="light"] {
background: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid var(--rs-border-subtle);
background: var(--rs-glass-bg);
}
/* ── Main content area ── */
@ -160,9 +138,9 @@ body[data-theme="light"] {
.rapp-nav__back {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
border: 1px solid var(--rs-border);
background: transparent;
color: #94a3b8;
color: var(--rs-text-secondary);
cursor: pointer;
font-size: 13px;
text-decoration: none;
@ -170,14 +148,14 @@ body[data-theme="light"] {
}
.rapp-nav__back:hover {
color: #e2e8f0;
border-color: rgba(255,255,255,0.2);
color: var(--rs-text-primary);
border-color: var(--rs-border-strong);
}
.rapp-nav__title {
font-size: 15px;
font-weight: 600;
color: #e2e8f0;
color: var(--rs-text-primary);
flex: 1;
min-width: 0;
overflow: hidden;
@ -196,7 +174,7 @@ body[data-theme="light"] {
padding: 6px 14px;
border-radius: 6px;
border: none;
background: #4f46e5;
background: var(--rs-primary);
color: #fff;
font-weight: 600;
cursor: pointer;
@ -205,24 +183,24 @@ body[data-theme="light"] {
}
.rapp-nav__btn:hover {
background: #6366f1;
background: var(--rs-primary-hover);
}
.rapp-nav__btn--secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
color: #94a3b8;
border: 1px solid var(--rs-border);
color: var(--rs-text-secondary);
}
.rapp-nav__btn--secondary:hover {
border-color: rgba(255,255,255,0.3);
color: #e2e8f0;
border-color: var(--rs-border-strong);
color: var(--rs-text-primary);
}
.rapp-nav__badge {
font-size: 11px;
font-weight: 600;
color: #fbbf24;
color: var(--rs-warning);
background: rgba(251,191,36,0.15);
border: 1px solid rgba(251,191,36,0.3);
border-radius: 4px;
@ -244,7 +222,7 @@ body[data-theme="light"] {
width: 100%;
height: 100%;
border: none;
background: #0f172a;
background: var(--rs-bg-page);
}
.rspace-iframe-loading {
@ -255,8 +233,8 @@ body[data-theme="light"] {
align-items: center;
justify-content: center;
gap: 12px;
background: #0f172a;
color: #94a3b8;
background: var(--rs-bg-page);
color: var(--rs-text-secondary);
font-size: 0.9rem;
z-index: 2;
}
@ -264,8 +242,8 @@ body[data-theme="light"] {
.rspace-iframe-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #14b8a6;
border: 3px solid var(--rs-spinner-track);
border-top-color: var(--rs-spinner-head);
border-radius: 50%;
animation: rspace-spin 0.8s linear infinite;
}
@ -279,24 +257,24 @@ body[data-theme="light"] {
bottom: 12px;
right: 16px;
font-size: 0.72rem;
color: #64748b;
color: var(--rs-text-muted);
text-decoration: none;
padding: 4px 10px;
border-radius: 4px;
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255,255,255,0.08);
background: var(--rs-glass-bg);
border: 1px solid var(--rs-glass-border);
z-index: 3;
transition: color 0.15s, border-color 0.15s;
}
.rspace-iframe-newtab:hover {
color: #e2e8f0;
border-color: rgba(255,255,255,0.2);
color: var(--rs-text-primary);
border-color: var(--rs-border-strong);
}
/* "Open Full App" button used in module demo views */
.rapp-nav__btn--app-toggle {
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--rs-gradient-primary);
color: #fff;
font-size: 0.78rem;
padding: 5px 14px;
@ -407,8 +385,8 @@ body[data-theme="light"] {
.rspace-tab-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #14b8a6;
border: 3px solid var(--rs-spinner-track);
border-top-color: var(--rs-spinner-head);
border-radius: 50%;
animation: rspace-spin 0.8s linear infinite;
}

View File

@ -12,6 +12,7 @@ import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
import { RStackMi } from "../shared/components/rstack-mi";
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
import { rspaceNavUrl } from "../shared/url-helpers";
import { TabCache } from "../shared/tab-cache";
@ -27,6 +28,7 @@ RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackTabBar.define();
RStackMi.define();
RStackSpaceSettings.define();
// Reload space list when user signs in/out (to show/hide private spaces)
document.addEventListener("auth-change", () => {