feat: brandedAppName helper, rData cloud refactor, branding color tweaks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
166182a82a
commit
184da55813
|
|
@ -9,6 +9,7 @@
|
||||||
import { TourEngine } from '../../../shared/tour-engine';
|
import { TourEngine } from '../../../shared/tour-engine';
|
||||||
import type { TourStep } from '../../../shared/tour-engine';
|
import type { TourStep } from '../../../shared/tour-engine';
|
||||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
|
import { rspaceNavUrl } from '../../../shared/url-helpers';
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
|
@ -284,10 +285,8 @@ class FolkContentTree extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private navigate(modId: string) {
|
private navigate(modId: string) {
|
||||||
const base = window.location.pathname.split("/").slice(0, -1).join("/");
|
|
||||||
// Navigate to the module: /{space}/r{modId} or /{space}/{modId}
|
|
||||||
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
|
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
|
||||||
window.location.href = `${base}/${modPath}`;
|
window.open(rspaceNavUrl(this.space, modPath), "_blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,99 @@
|
||||||
/**
|
/**
|
||||||
* folk-data-cloud — Concentric-ring SVG visualization of data objects
|
* folk-data-cloud — Graph visualization of all data objects (documents)
|
||||||
* across user spaces, grouped by visibility level (private/permissioned/public).
|
* across the user's spaces. Nodes represent individual documents,
|
||||||
|
* grouped radially by module around a central space node.
|
||||||
*
|
*
|
||||||
* Two-level interaction: click space bubble → detail panel with modules,
|
* Click any node → opens that module in a new tab.
|
||||||
* click module row → navigate to that module page.
|
* Demo mode shows dummy document nodes when unauthenticated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
|
import { rspaceNavUrl } from '../../../shared/url-helpers';
|
||||||
|
|
||||||
interface SpaceInfo {
|
// ── Types ──
|
||||||
slug: string;
|
|
||||||
name: string;
|
interface DocNode {
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
modId: string;
|
||||||
|
modName: string;
|
||||||
|
modIcon: string;
|
||||||
|
space: string;
|
||||||
|
spaceName: string;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
role?: string;
|
|
||||||
relationship?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleSummary {
|
interface GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
docCount: number;
|
type: "space" | "module" | "doc";
|
||||||
|
modId?: string;
|
||||||
|
space?: string;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
r: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpaceBubble extends SpaceInfo {
|
interface GraphEdge {
|
||||||
docCount: number;
|
from: string;
|
||||||
modules: ModuleSummary[];
|
to: string;
|
||||||
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ring = "private" | "permissioned" | "public";
|
// ── Colors ──
|
||||||
|
|
||||||
const RING_CONFIG: Record<Ring, { color: string; label: string; radius: number }> = {
|
const VIS_COLORS: Record<string, string> = {
|
||||||
private: { color: "#ef4444", label: "Private", radius: 0.28 },
|
private: "#ef4444",
|
||||||
permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 },
|
permissioned: "#eab308",
|
||||||
public: { color: "#22c55e", label: "Public", radius: 0.80 },
|
public: "#22c55e",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RINGS: Ring[] = ["private", "permissioned", "public"];
|
const MOD_COLORS: Record<string, string> = {
|
||||||
|
notes: "#f97316", docs: "#f97316", vote: "#a855f7", tasks: "#3b82f6",
|
||||||
|
cal: "#06b6d4", wallet: "#eab308", flows: "#14b8a6", pubs: "#ec4899",
|
||||||
|
files: "#64748b", forum: "#8b5cf6", inbox: "#f43f5e", network: "#22d3ee",
|
||||||
|
trips: "#10b981", tube: "#f59e0b", choices: "#6366f1", cart: "#84cc16",
|
||||||
|
};
|
||||||
|
|
||||||
const DEMO_SPACES: SpaceBubble[] = [
|
function modColor(modId: string): string {
|
||||||
{ slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [
|
return MOD_COLORS[modId] || "#94a3b8";
|
||||||
{ id: "notes", name: "rNotes", icon: "📝", docCount: 5 },
|
}
|
||||||
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
|
|
||||||
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
|
// ── Demo data ──
|
||||||
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 2 },
|
|
||||||
]},
|
const DEMO_DOCS: DocNode[] = [
|
||||||
{ slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [
|
{ docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "docs", name: "rDocs", icon: "📓", docCount: 3 },
|
{ docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
|
{ docId: "demo:notes:notebooks:nb3", title: "Research Log", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
]},
|
{ docId: "demo:vote:proposals:p1", title: "Dark mode proposal", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [
|
{ docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "docs", name: "rDocs", icon: "📓", docCount: 6 },
|
{ docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "vote", name: "rVote", icon: "🗳", docCount: 4 },
|
{ docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 3 },
|
{ docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
|
{ docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "cal", name: "rCal", icon: "📅", docCount: 4 },
|
{ docId: "demo:cal:calendars:c2", title: "Personal", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
]},
|
{ docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", modId: "wallet", modName: "rWallet", modIcon: "💰", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [
|
{ docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "vote", name: "rVote", icon: "🗳", docCount: 7 },
|
{ docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 4 },
|
{ docId: "demo:docs:notebooks:d1", title: "Onboarding Guide", modId: "docs", modName: "rDocs", modIcon: "📓", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
]},
|
{ docId: "demo:pubs:pages:pub1", title: "Launch Announcement", modId: "pubs", modName: "rPubs", modIcon: "📰", space: "demo", spaceName: "Demo Space", visibility: "public" },
|
||||||
{ slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [
|
|
||||||
{ id: "notes", name: "rNotes", icon: "📝", docCount: 3 },
|
|
||||||
{ id: "vote", name: "rVote", icon: "🗳", docCount: 2 },
|
|
||||||
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
|
|
||||||
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
|
|
||||||
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 1 },
|
|
||||||
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 5 },
|
|
||||||
]},
|
|
||||||
{ slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [
|
|
||||||
{ id: "docs", name: "rDocs", icon: "📓", docCount: 4 },
|
|
||||||
{ id: "pubs", name: "rPubs", icon: "📰", docCount: 5 },
|
|
||||||
]},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
class FolkDataCloud extends HTMLElement {
|
class FolkDataCloud extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = "demo";
|
private space = "demo";
|
||||||
private spaces: SpaceBubble[] = [];
|
private docs: DocNode[] = [];
|
||||||
|
private nodes: GraphNode[] = [];
|
||||||
|
private edges: GraphEdge[] = [];
|
||||||
private loading = true;
|
private loading = true;
|
||||||
private isDemo = false;
|
private isDemo = false;
|
||||||
private selected: string | null = null;
|
private hoveredId: string | null = null;
|
||||||
private hoveredSlug: string | null = null;
|
private width = 700;
|
||||||
private width = 600;
|
private height = 700;
|
||||||
private height = 600;
|
|
||||||
private _stopPresence: (() => void) | null = null;
|
private _stopPresence: (() => void) | null = null;
|
||||||
private _resizeObserver: ResizeObserver | null = null;
|
private _resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
|
@ -95,10 +105,10 @@ class FolkDataCloud extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
this._resizeObserver = new ResizeObserver((entries) => {
|
this._resizeObserver = new ResizeObserver((entries) => {
|
||||||
const w = entries[0]?.contentRect.width || 600;
|
const w = entries[0]?.contentRect.width || 700;
|
||||||
this.width = Math.min(w, 800);
|
this.width = Math.min(w, 900);
|
||||||
this.height = this.width;
|
this.height = Math.max(this.width * 0.85, 500);
|
||||||
if (!this.loading) this.render();
|
if (!this.loading) { this.layout(); this.render(); }
|
||||||
});
|
});
|
||||||
this._resizeObserver.observe(this);
|
this._resizeObserver.observe(this);
|
||||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
|
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
|
||||||
|
|
@ -110,6 +120,8 @@ class FolkDataCloud extends HTMLElement {
|
||||||
this._resizeObserver?.disconnect();
|
this._resizeObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──
|
||||||
|
|
||||||
private async loadData() {
|
private async loadData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -117,9 +129,8 @@ class FolkDataCloud extends HTMLElement {
|
||||||
const token = localStorage.getItem("rspace_auth");
|
const token = localStorage.getItem("rspace_auth");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
this.isDemo = true;
|
this.isDemo = true;
|
||||||
this.spaces = DEMO_SPACES;
|
this.docs = DEMO_DOCS;
|
||||||
this.loading = false;
|
this.finalize();
|
||||||
this.render();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,237 +140,346 @@ class FolkDataCloud extends HTMLElement {
|
||||||
signal: AbortSignal.timeout(8000),
|
signal: AbortSignal.timeout(8000),
|
||||||
});
|
});
|
||||||
if (!spacesResp.ok) throw new Error("spaces fetch failed");
|
if (!spacesResp.ok) throw new Error("spaces fetch failed");
|
||||||
const spacesData: { spaces: SpaceInfo[] } = await spacesResp.json();
|
const { spaces } = await spacesResp.json() as { spaces: Array<{ slug: string; name: string; visibility: string }> };
|
||||||
|
|
||||||
// Fetch content-tree for each space in parallel
|
|
||||||
const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, "");
|
const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, "");
|
||||||
const bubbles: SpaceBubble[] = await Promise.all(
|
const allDocs: DocNode[] = [];
|
||||||
spacesData.spaces.map(async (sp) => {
|
|
||||||
|
await Promise.all(spaces.map(async (sp) => {
|
||||||
try {
|
try {
|
||||||
const treeResp = await fetch(
|
const resp = await fetch(
|
||||||
`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
|
`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
|
||||||
{ signal: AbortSignal.timeout(8000) }
|
{ signal: AbortSignal.timeout(8000) }
|
||||||
);
|
);
|
||||||
if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] };
|
if (!resp.ok) return;
|
||||||
const tree = await treeResp.json();
|
const tree = await resp.json();
|
||||||
const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({
|
for (const mod of (tree.modules || [])) {
|
||||||
id: m.id,
|
for (const col of (mod.collections || [])) {
|
||||||
name: m.name,
|
for (const item of (col.items || [])) {
|
||||||
icon: m.icon,
|
allDocs.push({
|
||||||
docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0),
|
docId: item.docId,
|
||||||
}));
|
title: item.title || col.collection,
|
||||||
const docCount = modules.reduce((s, m) => s + m.docCount, 0);
|
modId: mod.id,
|
||||||
return { ...sp, docCount, modules };
|
modName: mod.name,
|
||||||
} catch {
|
modIcon: mod.icon,
|
||||||
return { ...sp, docCount: 0, modules: [] };
|
space: sp.slug,
|
||||||
|
spaceName: sp.name,
|
||||||
|
visibility: sp.visibility || "private",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
);
|
}
|
||||||
|
} catch { /* skip space */ }
|
||||||
|
}));
|
||||||
|
|
||||||
this.spaces = bubbles;
|
this.docs = allDocs;
|
||||||
this.isDemo = false;
|
this.isDemo = false;
|
||||||
} catch {
|
} catch {
|
||||||
this.isDemo = true;
|
this.isDemo = true;
|
||||||
this.spaces = DEMO_SPACES;
|
this.docs = DEMO_DOCS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finalize() {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.layout();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private groupByRing(): Record<Ring, SpaceBubble[]> {
|
// ── Graph layout ──
|
||||||
const groups: Record<Ring, SpaceBubble[]> = { private: [], permissioned: [], public: [] };
|
// Central node per space, module nodes around it, doc nodes orbiting modules.
|
||||||
for (const sp of this.spaces) {
|
|
||||||
const ring = (sp.visibility as Ring) || "private";
|
private layout() {
|
||||||
(groups[ring] || groups.private).push(sp);
|
this.nodes = [];
|
||||||
|
this.edges = [];
|
||||||
|
|
||||||
|
const cx = this.width / 2;
|
||||||
|
const cy = this.height / 2;
|
||||||
|
const mobile = this.width < 500;
|
||||||
|
|
||||||
|
// Group docs by space, then by module
|
||||||
|
const spaceMap = new Map<string, { name: string; vis: string; mods: Map<string, DocNode[]> }>();
|
||||||
|
for (const doc of this.docs) {
|
||||||
|
if (!spaceMap.has(doc.space)) {
|
||||||
|
spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() });
|
||||||
}
|
}
|
||||||
return groups;
|
const sp = spaceMap.get(doc.space)!;
|
||||||
|
if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []);
|
||||||
|
sp.mods.get(doc.modId)!.push(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMobile(): boolean {
|
const spaceKeys = [...spaceMap.keys()];
|
||||||
return this.width < 500;
|
const spaceCount = spaceKeys.length;
|
||||||
|
|
||||||
|
if (spaceCount === 0) return;
|
||||||
|
|
||||||
|
// Single space → center layout. Multiple → distribute around center.
|
||||||
|
const spaceR = mobile ? 18 : 24;
|
||||||
|
const modR = mobile ? 12 : 16;
|
||||||
|
const docR = mobile ? 6 : 8;
|
||||||
|
const orbitMod = mobile ? 70 : 100; // module distance from space center
|
||||||
|
const orbitDoc = mobile ? 28 : 38; // doc distance from module center
|
||||||
|
|
||||||
|
for (let si = 0; si < spaceCount; si++) {
|
||||||
|
const spaceSlug = spaceKeys[si];
|
||||||
|
const sp = spaceMap.get(spaceSlug)!;
|
||||||
|
const visColor = VIS_COLORS[sp.vis] || VIS_COLORS.private;
|
||||||
|
|
||||||
|
// Space position
|
||||||
|
let sx: number, sy: number;
|
||||||
|
if (spaceCount === 1) {
|
||||||
|
sx = cx; sy = cy;
|
||||||
|
} else {
|
||||||
|
const spaceOrbit = Math.min(this.width, this.height) * 0.3;
|
||||||
|
const spAngle = (2 * Math.PI * si / spaceCount) - Math.PI / 2;
|
||||||
|
sx = cx + spaceOrbit * Math.cos(spAngle);
|
||||||
|
sy = cy + spaceOrbit * Math.sin(spAngle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spaceNodeId = `space:${spaceSlug}`;
|
||||||
|
this.nodes.push({
|
||||||
|
id: spaceNodeId,
|
||||||
|
label: sp.name,
|
||||||
|
icon: "",
|
||||||
|
type: "space",
|
||||||
|
space: spaceSlug,
|
||||||
|
color: visColor,
|
||||||
|
x: sx, y: sy, r: spaceR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modules around space
|
||||||
|
const modKeys = [...sp.mods.keys()];
|
||||||
|
const modCount = modKeys.length;
|
||||||
|
const actualModOrbit = Math.min(orbitMod, (spaceCount === 1 ? orbitMod * 1.5 : orbitMod));
|
||||||
|
|
||||||
|
for (let mi = 0; mi < modCount; mi++) {
|
||||||
|
const mId = modKeys[mi];
|
||||||
|
const docs = sp.mods.get(mId)!;
|
||||||
|
const firstDoc = docs[0];
|
||||||
|
const mAngle = (2 * Math.PI * mi / modCount) - Math.PI / 2;
|
||||||
|
const mx = sx + actualModOrbit * Math.cos(mAngle);
|
||||||
|
const my = sy + actualModOrbit * Math.sin(mAngle);
|
||||||
|
|
||||||
|
const modNodeId = `mod:${spaceSlug}:${mId}`;
|
||||||
|
this.nodes.push({
|
||||||
|
id: modNodeId,
|
||||||
|
label: firstDoc.modName,
|
||||||
|
icon: firstDoc.modIcon,
|
||||||
|
type: "module",
|
||||||
|
modId: mId,
|
||||||
|
space: spaceSlug,
|
||||||
|
color: modColor(mId),
|
||||||
|
x: mx, y: my, r: modR,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor });
|
||||||
|
|
||||||
|
// Doc nodes around module
|
||||||
|
for (let di = 0; di < docs.length; di++) {
|
||||||
|
const doc = docs[di];
|
||||||
|
const dAngle = (2 * Math.PI * di / docs.length) - Math.PI / 2;
|
||||||
|
// Offset by module angle to spread outward
|
||||||
|
const dx = mx + orbitDoc * Math.cos(dAngle);
|
||||||
|
const dy = my + orbitDoc * Math.sin(dAngle);
|
||||||
|
|
||||||
|
const docNodeId = `doc:${doc.docId}`;
|
||||||
|
this.nodes.push({
|
||||||
|
id: docNodeId,
|
||||||
|
label: doc.title,
|
||||||
|
icon: "",
|
||||||
|
type: "doc",
|
||||||
|
modId: mId,
|
||||||
|
space: spaceSlug,
|
||||||
|
color: modColor(mId),
|
||||||
|
x: dx, y: dy, r: docR,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendering ──
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null;
|
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>${this.styles()}</style>
|
<style>${this.styles()}</style>
|
||||||
<div class="dc">
|
<div class="dc">
|
||||||
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
|
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
|
||||||
${this.loading ? this.renderLoading() : this.renderSVG()}
|
${this.loading ? this.renderLoading() : this.renderGraph()}
|
||||||
${selected ? this.renderDetailPanel(selected) : ""}
|
${!this.loading ? this.renderLegend() : ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.attachEvents();
|
this.attachEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderLoading(): string {
|
private renderLoading(): string {
|
||||||
const cx = this.width / 2;
|
|
||||||
const cy = this.height / 2;
|
|
||||||
return `
|
return `
|
||||||
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
|
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
|
||||||
${RINGS.map(ring => {
|
<text x="${this.width / 2}" y="${this.height / 2}" text-anchor="middle" dominant-baseline="central"
|
||||||
const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9;
|
|
||||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
|
|
||||||
stroke="var(--rs-border)" stroke-width="1" stroke-dasharray="4 4" opacity="0.3"/>`;
|
|
||||||
}).join("")}
|
|
||||||
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
|
|
||||||
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud…</text>
|
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud…</text>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSVG(): string {
|
private renderGraph(): string {
|
||||||
const groups = this.groupByRing();
|
if (this.nodes.length === 0) {
|
||||||
const cx = this.width / 2;
|
return `<div class="dc-empty">No data objects found</div>`;
|
||||||
const cy = this.height / 2;
|
}
|
||||||
const scale = (this.width / 2) * 0.9;
|
|
||||||
const mobile = this.isMobile();
|
|
||||||
const bubbleR = mobile ? 20 : 28;
|
|
||||||
const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount));
|
|
||||||
|
|
||||||
|
const mobile = this.width < 500;
|
||||||
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
|
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
|
||||||
|
|
||||||
// Render rings (outer to inner so inner draws on top)
|
// Edges first (behind nodes)
|
||||||
for (const ring of [...RINGS].reverse()) {
|
for (const edge of this.edges) {
|
||||||
const cfg = RING_CONFIG[ring];
|
const from = this.nodes.find(n => n.id === edge.from);
|
||||||
const r = cfg.radius * scale;
|
const to = this.nodes.find(n => n.id === edge.to);
|
||||||
svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
|
if (!from || !to) continue;
|
||||||
stroke="${cfg.color}" stroke-width="1.5" stroke-dasharray="6 4" opacity="0.4"/>`;
|
svg += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"
|
||||||
|
stroke="${edge.color}" stroke-width="1" opacity="0.2"/>`;
|
||||||
// Ring label at top
|
|
||||||
const labelY = cy - r - 8;
|
|
||||||
svg += `<text x="${cx}" y="${labelY}" text-anchor="middle" fill="${cfg.color}"
|
|
||||||
font-size="${mobile ? 10 : 12}" font-weight="600" opacity="0.7">${cfg.label}</text>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render bubbles per ring
|
// Nodes
|
||||||
for (const ring of RINGS) {
|
for (const node of this.nodes) {
|
||||||
const cfg = RING_CONFIG[ring];
|
const isHovered = this.hoveredId === node.id;
|
||||||
const ringR = cfg.radius * scale;
|
const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2);
|
||||||
const ringSpaces = groups[ring];
|
const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15);
|
||||||
if (ringSpaces.length === 0) continue;
|
const hoverOpacity = isHovered ? 0.8 : fillOpacity;
|
||||||
|
|
||||||
const angleStep = (2 * Math.PI) / ringSpaces.length;
|
svg += `<g class="dc-node" data-id="${this.escAttr(node.id)}" data-space="${this.escAttr(node.space || "")}" data-mod="${this.escAttr(node.modId || "")}" style="cursor:pointer">`;
|
||||||
const startAngle = -Math.PI / 2; // Start from top
|
|
||||||
|
|
||||||
for (let i = 0; i < ringSpaces.length; i++) {
|
// Circle
|
||||||
const sp = ringSpaces[i];
|
svg += `<circle cx="${node.x}" cy="${node.y}" r="${node.r}"
|
||||||
const angle = startAngle + i * angleStep;
|
fill="${node.color}" fill-opacity="${hoverOpacity}"
|
||||||
const bx = cx + ringR * Math.cos(angle);
|
stroke="${node.color}" stroke-width="${strokeW}"/>`;
|
||||||
const by = cy + ringR * Math.sin(angle);
|
|
||||||
|
|
||||||
// Scale bubble size by doc count (min 60%, max 100%)
|
// Labels
|
||||||
const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount);
|
if (node.type === "space") {
|
||||||
const r = bubbleR * sizeScale;
|
const label = mobile ? node.label.slice(0, 8) : (node.label.length > 14 ? node.label.slice(0, 13) + "…" : node.label);
|
||||||
const isSelected = this.selected === sp.slug;
|
svg += `<text x="${node.x}" y="${node.y + node.r + 14}" text-anchor="middle"
|
||||||
const isHovered = this.hoveredSlug === sp.slug;
|
fill="var(--rs-text-primary)" font-size="${mobile ? 9 : 11}" font-weight="600"
|
||||||
const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5);
|
|
||||||
const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1);
|
|
||||||
|
|
||||||
// Bubble circle
|
|
||||||
svg += `<g class="dc-bubble" data-slug="${this.escAttr(sp.slug)}" style="cursor:pointer">`;
|
|
||||||
if (isSelected) {
|
|
||||||
svg += `<circle cx="${bx}" cy="${by}" r="${r + 5}" fill="none"
|
|
||||||
stroke="${cfg.color}" stroke-width="2" stroke-dasharray="4 3" opacity="0.6">
|
|
||||||
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1s" repeatCount="indefinite"/>
|
|
||||||
</circle>`;
|
|
||||||
}
|
|
||||||
svg += `<circle cx="${bx}" cy="${by}" r="${r}" fill="${cfg.color}" fill-opacity="${fillOpacity}"
|
|
||||||
stroke="${cfg.color}" stroke-width="${strokeW}"/>`;
|
|
||||||
|
|
||||||
// Label
|
|
||||||
const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name);
|
|
||||||
svg += `<text x="${bx}" y="${by - 2}" text-anchor="middle" dominant-baseline="central"
|
|
||||||
fill="var(--rs-text-primary)" font-size="${mobile ? 8 : 10}" font-weight="500"
|
|
||||||
pointer-events="none">${this.esc(label)}</text>`;
|
pointer-events="none">${this.esc(label)}</text>`;
|
||||||
|
} else if (node.type === "module") {
|
||||||
|
svg += `<text x="${node.x}" y="${node.y + 1}" text-anchor="middle" dominant-baseline="central"
|
||||||
|
fill="var(--rs-text-primary)" font-size="${mobile ? 10 : 12}"
|
||||||
|
pointer-events="none">${node.icon}</text>`;
|
||||||
|
if (!mobile) {
|
||||||
|
svg += `<text x="${node.x}" y="${node.y + node.r + 12}" text-anchor="middle"
|
||||||
|
fill="var(--rs-text-secondary)" font-size="9" pointer-events="none">${this.esc(node.label)}</text>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Doc — show label on hover via title
|
||||||
|
}
|
||||||
|
|
||||||
// Doc count badge
|
// Tooltip
|
||||||
svg += `<text x="${bx}" y="${by + (mobile ? 9 : 11)}" text-anchor="middle"
|
const tooltipText = node.type === "space"
|
||||||
fill="${cfg.color}" font-size="${mobile ? 7 : 9}" font-weight="600"
|
? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)`
|
||||||
pointer-events="none">${sp.docCount}</text>`;
|
: node.type === "module"
|
||||||
|
? `${node.label} — click to open in new tab`
|
||||||
|
: `${node.label} — click to open in new tab`;
|
||||||
|
svg += `<title>${this.esc(tooltipText)}</title>`;
|
||||||
|
|
||||||
// Tooltip (title element)
|
|
||||||
svg += `<title>${this.esc(sp.name)} — ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})</title>`;
|
|
||||||
svg += `</g>`;
|
svg += `</g>`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Center label
|
|
||||||
const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0);
|
|
||||||
svg += `<text x="${cx}" y="${cy - 6}" text-anchor="middle" fill="var(--rs-text-primary)"
|
|
||||||
font-size="${mobile ? 16 : 20}" font-weight="700">${totalDocs}</text>`;
|
|
||||||
svg += `<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="var(--rs-text-muted)"
|
|
||||||
font-size="${mobile ? 9 : 11}">total documents</text>`;
|
|
||||||
|
|
||||||
svg += `</svg>`;
|
svg += `</svg>`;
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderDetailPanel(sp: SpaceBubble): string {
|
private renderLegend(): string {
|
||||||
const ring = (sp.visibility as Ring) || "private";
|
// Collect unique modules present
|
||||||
const cfg = RING_CONFIG[ring];
|
const mods = new Map<string, { name: string; icon: string }>();
|
||||||
const visBadgeColor = cfg.color;
|
for (const doc of this.docs) {
|
||||||
|
if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon });
|
||||||
|
}
|
||||||
|
// Collect unique visibility levels
|
||||||
|
const visLevels = new Set<string>();
|
||||||
|
for (const doc of this.docs) visLevels.add(doc.visibility);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="dc-panel">
|
<div class="dc-legend">
|
||||||
<div class="dc-panel__header">
|
<div class="dc-legend__section">
|
||||||
<span class="dc-panel__name">${this.esc(sp.name)}</span>
|
${[...visLevels].map(v => `
|
||||||
<span class="dc-panel__vis" style="color:${visBadgeColor};border-color:${visBadgeColor}">${sp.visibility}</span>
|
<span class="dc-legend__item">
|
||||||
<span class="dc-panel__count">${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}</span>
|
<span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
|
||||||
</div>
|
${this.esc(v)}
|
||||||
${sp.modules.length === 0
|
</span>
|
||||||
? `<div class="dc-panel__empty">No documents in this space</div>`
|
|
||||||
: `<div class="dc-panel__modules">
|
|
||||||
${sp.modules.map(m => `
|
|
||||||
<div class="dc-panel__mod" data-nav-space="${this.escAttr(sp.slug)}" data-nav-mod="${this.escAttr(m.id)}">
|
|
||||||
<span class="dc-panel__mod-icon">${m.icon}</span>
|
|
||||||
<span class="dc-panel__mod-name">${this.esc(m.name)}</span>
|
|
||||||
<span class="dc-panel__mod-count">${m.docCount}</span>
|
|
||||||
</div>
|
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>`
|
</div>
|
||||||
}
|
<div class="dc-legend__section">
|
||||||
|
${[...mods.entries()].map(([, m]) => `
|
||||||
|
<span class="dc-legend__item">${m.icon} ${this.esc(m.name)}</span>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="dc-legend__hint">Click any node to open in new tab</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Events ──
|
||||||
|
|
||||||
private attachEvents() {
|
private attachEvents() {
|
||||||
// Bubble click — toggle selection
|
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-node")) {
|
||||||
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-bubble")) {
|
const nodeId = g.dataset.id!;
|
||||||
const slug = g.dataset.slug!;
|
const space = g.dataset.space || "";
|
||||||
|
const modId = g.dataset.mod || "";
|
||||||
|
|
||||||
g.addEventListener("click", () => {
|
g.addEventListener("click", () => {
|
||||||
this.selected = this.selected === slug ? null : slug;
|
if (!space) return;
|
||||||
this.render();
|
const modPath = modId
|
||||||
|
? (modId.startsWith("r") ? modId : `r${modId}`)
|
||||||
|
: "rspace";
|
||||||
|
window.open(rspaceNavUrl(space, modPath), "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
g.addEventListener("mouseenter", () => {
|
g.addEventListener("mouseenter", () => {
|
||||||
this.hoveredSlug = slug;
|
this.hoveredId = nodeId;
|
||||||
// Update stroke without full re-render for perf
|
const circle = g.querySelector("circle") as SVGCircleElement;
|
||||||
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
|
if (circle) {
|
||||||
if (circle) circle.setAttribute("stroke-width", "2.5");
|
circle.setAttribute("stroke-width", "2.5");
|
||||||
|
circle.setAttribute("fill-opacity", "0.8");
|
||||||
|
}
|
||||||
|
// Highlight connected edges
|
||||||
|
const connectedEdges = this.edges.filter(e => e.from === nodeId || e.to === nodeId);
|
||||||
|
for (const edge of connectedEdges) {
|
||||||
|
const lines = this.shadow.querySelectorAll<SVGLineElement>("line");
|
||||||
|
for (const line of lines) {
|
||||||
|
const fromNode = this.nodes.find(n => n.id === edge.from);
|
||||||
|
const toNode = this.nodes.find(n => n.id === edge.to);
|
||||||
|
if (!fromNode || !toNode) continue;
|
||||||
|
if (Math.abs(parseFloat(line.getAttribute("x1")!) - fromNode.x) < 1 &&
|
||||||
|
Math.abs(parseFloat(line.getAttribute("y1")!) - fromNode.y) < 1) {
|
||||||
|
line.setAttribute("opacity", "0.6");
|
||||||
|
line.setAttribute("stroke-width", "2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
g.addEventListener("mouseleave", () => {
|
g.addEventListener("mouseleave", () => {
|
||||||
this.hoveredSlug = null;
|
this.hoveredId = null;
|
||||||
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
|
const circle = g.querySelector("circle") as SVGCircleElement;
|
||||||
if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5");
|
if (circle) {
|
||||||
|
const node = this.nodes.find(n => n.id === nodeId);
|
||||||
|
if (node) {
|
||||||
|
circle.setAttribute("stroke-width", node.type === "space" ? "2" : "1.2");
|
||||||
|
const fo = node.type === "doc" ? "0.5" : (node.type === "module" ? "0.3" : "0.15");
|
||||||
|
circle.setAttribute("fill-opacity", fo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset edges
|
||||||
|
for (const line of this.shadow.querySelectorAll<SVGLineElement>("line")) {
|
||||||
|
line.setAttribute("opacity", "0.2");
|
||||||
|
line.setAttribute("stroke-width", "1");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Module row click — navigate
|
// ── Helpers ──
|
||||||
for (const row of this.shadow.querySelectorAll<HTMLElement>(".dc-panel__mod")) {
|
|
||||||
row.addEventListener("click", () => {
|
|
||||||
const spaceSlug = row.dataset.navSpace!;
|
|
||||||
const modId = row.dataset.navMod!;
|
|
||||||
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
|
|
||||||
window.location.href = `/${spaceSlug}/${modPath}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private esc(s: string): string {
|
private esc(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
@ -381,53 +501,38 @@ class FolkDataCloud extends HTMLElement {
|
||||||
border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem;
|
border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dc-empty {
|
||||||
|
text-align: center; padding: 3rem 1rem;
|
||||||
|
color: var(--rs-text-muted); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
|
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
|
||||||
|
|
||||||
/* Detail panel */
|
/* Legend */
|
||||||
.dc-panel {
|
.dc-legend {
|
||||||
width: 100%; max-width: 500px; margin-top: 1rem;
|
width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem;
|
||||||
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border);
|
justify-content: center; align-items: center;
|
||||||
border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out;
|
padding: 0.75rem; margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--rs-border);
|
||||||
}
|
}
|
||||||
@keyframes dc-slideIn {
|
.dc-legend__section {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
}
|
||||||
|
.dc-legend__item {
|
||||||
.dc-panel__header {
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
|
font-size: 0.75rem; color: var(--rs-text-secondary);
|
||||||
padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border);
|
|
||||||
}
|
}
|
||||||
.dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; }
|
.dc-legend__dot {
|
||||||
.dc-panel__vis {
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 10px;
|
|
||||||
border: 1px solid; text-transform: uppercase; font-weight: 600; letter-spacing: 0.03em;
|
|
||||||
}
|
}
|
||||||
.dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); }
|
.dc-legend__hint {
|
||||||
|
width: 100%; text-align: center;
|
||||||
.dc-panel__empty {
|
font-size: 0.7rem; color: var(--rs-text-muted); margin-top: 0.25rem;
|
||||||
text-align: center; padding: 1rem; color: var(--rs-text-muted); font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dc-panel__modules { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
||||||
|
|
||||||
.dc-panel__mod {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.dc-panel__mod:hover { background: rgba(34, 211, 238, 0.08); }
|
|
||||||
.dc-panel__mod-icon { font-size: 1rem; flex-shrink: 0; }
|
|
||||||
.dc-panel__mod-name { flex: 1; font-size: 0.85rem; }
|
|
||||||
.dc-panel__mod-count {
|
|
||||||
padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem;
|
|
||||||
background: var(--rs-bg-primary, #0f172a); border: 1px solid var(--rs-border);
|
|
||||||
color: var(--rs-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
.dc-panel { max-height: 50vh; overflow-y: auto; }
|
.dc-legend { gap: 0.4rem; padding: 0.5rem; }
|
||||||
.dc-panel__name { font-size: 0.9rem; }
|
.dc-legend__item { font-size: 0.65rem; }
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModuleInfo } from "../shared/module";
|
import type { ModuleInfo } from "../shared/module";
|
||||||
import { escapeHtml, escapeAttr, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell";
|
import { escapeHtml, escapeAttr, brandedAppName, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell";
|
||||||
|
|
||||||
/** Category → module IDs mapping for the tabbed showcase. */
|
/** Category → module IDs mapping for the tabbed showcase. */
|
||||||
const CATEGORY_GROUPS: Record<string, { label: string; icon: string; ids: string[] }> = {
|
const CATEGORY_GROUPS: Record<string, { label: string; icon: string; ids: string[] }> = {
|
||||||
|
|
@ -40,7 +40,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
|
||||||
return `<a href="/${escapeAttr(m.id)}" class="lp-app-card">
|
return `<a href="/${escapeAttr(m.id)}" class="lp-app-card">
|
||||||
<span class="lp-app-card__icon">${m.icon}</span>
|
<span class="lp-app-card__icon">${m.icon}</span>
|
||||||
<div class="lp-app-card__body">
|
<div class="lp-app-card__body">
|
||||||
<span class="lp-app-card__name">${escapeHtml(m.name)}</span>
|
<span class="lp-app-card__name">${brandedAppName(m.name)}</span>
|
||||||
${m.standaloneDomain ? `<span class="lp-app-card__domain">${escapeHtml(m.standaloneDomain)}</span>` : ""}
|
${m.standaloneDomain ? `<span class="lp-app-card__domain">${escapeHtml(m.standaloneDomain)}</span>` : ""}
|
||||||
<span class="lp-app-card__desc">${escapeHtml(m.description)}</span>
|
<span class="lp-app-card__desc">${escapeHtml(m.description)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +131,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
|
||||||
<div class="lp-hero__orb lp-hero__orb--indigo" aria-hidden="true"></div>
|
<div class="lp-hero__orb lp-hero__orb--indigo" aria-hidden="true"></div>
|
||||||
<div class="lp-hero__grid" aria-hidden="true"></div>
|
<div class="lp-hero__grid" aria-hidden="true"></div>
|
||||||
<div class="lp-hero__content">
|
<div class="lp-hero__content">
|
||||||
<span class="rl-tagline">Reclaim (you)<span style="color:#f97316">r</span><span style="color:#14b8a6">Space</span> on the internet</span>
|
<span class="rl-tagline">Reclaim (you)<span style="color:#dc8300">r</span><span style="color:#35b9b9">Space</span> on the internet</span>
|
||||||
<h1 class="lp-wordmark"><span class="lp-wordmark__r">r</span><span class="lp-wordmark__space">Space</span></h1>
|
<h1 class="lp-wordmark"><span class="lp-wordmark__r">r</span><span class="lp-wordmark__space">Space</span></h1>
|
||||||
<p class="lp-hero__tagline">
|
<p class="lp-hero__tagline">
|
||||||
Coordinate around what you care about — without stitching together a dozen corporate apps.
|
Coordinate around what you care about — without stitching together a dozen corporate apps.
|
||||||
|
|
@ -293,7 +293,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
|
||||||
<!-- 7. Final CTA -->
|
<!-- 7. Final CTA -->
|
||||||
<section class="lp-final-cta">
|
<section class="lp-final-cta">
|
||||||
<div class="rl-container" style="max-width:720px;text-align:center">
|
<div class="rl-container" style="max-width:720px;text-align:center">
|
||||||
<h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#f97316;-webkit-text-fill-color:#f97316">r</span><span style="color:#14b8a6;-webkit-text-fill-color:#14b8a6">Space</span>.</h2>
|
<h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#dc8300;-webkit-text-fill-color:#dc8300">r</span><span style="color:#35b9b9;-webkit-text-fill-color:#35b9b9">Space</span>.</h2>
|
||||||
<p class="rl-subtext" style="font-size:1.15rem;line-height:1.7;text-align:center">
|
<p class="rl-subtext" style="font-size:1.15rem;line-height:1.7;text-align:center">
|
||||||
No algorithms deciding what you see. No ads. No data harvesting.
|
No algorithms deciding what you see. No ads. No data harvesting.
|
||||||
Just one place for your group to plan, decide, fund, and build together.
|
Just one place for your group to plan, decide, fund, and build together.
|
||||||
|
|
@ -382,7 +382,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
|
||||||
<a href="/${escapeAttr(m.id)}" class="sd-card" data-module="${escapeAttr(m.id)}">
|
<a href="/${escapeAttr(m.id)}" class="sd-card" data-module="${escapeAttr(m.id)}">
|
||||||
<div class="sd-card__icon">${m.icon}</div>
|
<div class="sd-card__icon">${m.icon}</div>
|
||||||
<div class="sd-card__body">
|
<div class="sd-card__body">
|
||||||
<h3 class="sd-card__name">${escapeHtml(m.name)}</h3>
|
<h3 class="sd-card__name">${brandedAppName(m.name)}</h3>
|
||||||
<p class="sd-card__desc">${escapeHtml(m.description)}</p>
|
<p class="sd-card__desc">${escapeHtml(m.description)}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>`;
|
</a>`;
|
||||||
|
|
@ -609,8 +609,8 @@ body {
|
||||||
}
|
}
|
||||||
.lp-wordmark__r {
|
.lp-wordmark__r {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #f97316;
|
color: #dc8300;
|
||||||
-webkit-text-fill-color: #f97316;
|
-webkit-text-fill-color: #dc8300;
|
||||||
}
|
}
|
||||||
.lp-wordmark__space {
|
.lp-wordmark__space {
|
||||||
background: var(--rs-gradient-brand);
|
background: var(--rs-gradient-brand);
|
||||||
|
|
|
||||||
|
|
@ -2088,7 +2088,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
|
||||||
const minimizeBtn = `<button class="rapp-minimize-btn" id="header-minimize-btn" title="Minimize headers"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>`;
|
const minimizeBtn = `<button class="rapp-minimize-btn" id="header-minimize-btn" title="Minimize headers"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>`;
|
||||||
|
|
||||||
const pills = [
|
const pills = [
|
||||||
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,
|
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${brandedAppName(mod.name)}</a>`,
|
||||||
...items.map(it =>
|
...items.map(it =>
|
||||||
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
|
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
|
||||||
),
|
),
|
||||||
|
|
@ -2232,7 +2232,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
|
||||||
: `<div class="ml-hero">
|
: `<div class="ml-hero">
|
||||||
<div class="ml-container">
|
<div class="ml-container">
|
||||||
<span class="ml-icon">${mod.icon}</span>
|
<span class="ml-icon">${mod.icon}</span>
|
||||||
<h1 class="ml-name">${escapeHtml(mod.name)}</h1>
|
<h1 class="ml-name">${brandedAppName(mod.name)}</h1>
|
||||||
<p class="ml-desc">${escapeHtml(mod.description)}</p>
|
<p class="ml-desc">${escapeHtml(mod.description)}</p>
|
||||||
<div class="ml-ctas">
|
<div class="ml-ctas">
|
||||||
<a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a>
|
<a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a>
|
||||||
|
|
@ -2580,7 +2580,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
|
||||||
</div>
|
</div>
|
||||||
${featuresGrid}
|
${featuresGrid}
|
||||||
<div class="rl-back">
|
<div class="rl-back">
|
||||||
<a href="/${escapeAttr(mod.id)}">← Back to ${escapeHtml(mod.name)}</a>
|
<a href="/${escapeAttr(mod.id)}">← Back to ${brandedAppName(mod.name)}</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
return versionAssetUrls(`<!DOCTYPE html>
|
return versionAssetUrls(`<!DOCTYPE html>
|
||||||
|
|
@ -2736,7 +2736,7 @@ export function renderOnboarding(opts: OnboardingOptions): string {
|
||||||
<div class="onboarding__card">
|
<div class="onboarding__card">
|
||||||
<div class="onboarding__glow"></div>
|
<div class="onboarding__glow"></div>
|
||||||
<span class="onboarding__icon">${moduleIcon}</span>
|
<span class="onboarding__icon">${moduleIcon}</span>
|
||||||
<h1 class="onboarding__title">${escapeHtml(moduleName)}</h1>
|
<h1 class="onboarding__title">${brandedAppName(moduleName)}</h1>
|
||||||
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
|
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
|
||||||
<p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p>
|
<p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p>
|
||||||
<div class="onboarding__ctas">
|
<div class="onboarding__ctas">
|
||||||
|
|
@ -2925,6 +2925,14 @@ export function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render rApp name with orange "r" prefix for visible HTML (not <title> tags). */
|
||||||
|
export function brandedAppName(name: string): string {
|
||||||
|
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
|
||||||
|
return `<span style="color:#dc8300">r</span>${escapeHtml(name.slice(1))}`;
|
||||||
|
}
|
||||||
|
return escapeHtml(name);
|
||||||
|
}
|
||||||
|
|
||||||
export function escapeAttr(s: string): string {
|
export function escapeAttr(s: string): string {
|
||||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,16 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
|
||||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
|
||||||
<div style="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;">
|
<div style="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;">
|
||||||
<h1 style="margin: 0 0 4px; font-size: 22px; color: #f1f5f9;">
|
<h1 style="margin: 0 0 4px; font-size: 22px; color: #f1f5f9;">
|
||||||
Welcome to <span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span>, ${escapeHtml(displayName)}!
|
Welcome to <span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span>, ${escapeHtml(displayName)}!
|
||||||
</h1>
|
</h1>
|
||||||
<p style="margin: 0 0 24px; font-size: 15px; color: #94a3b8;">
|
<p style="margin: 0 0 24px; font-size: 15px; color: #94a3b8;">
|
||||||
Reclaim (you)<span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span> on the internet — one place for your group to coordinate around what you care about.
|
Reclaim (you)<span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span> on the internet — one place for your group to coordinate around what you care about.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="background: #0f172a; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
|
<div style="background: #0f172a; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
|
||||||
<p style="margin: 0 0 12px; font-size: 14px; color: #e2e8f0; line-height: 1.6;">
|
<p style="margin: 0 0 12px; font-size: 14px; color: #e2e8f0; line-height: 1.6;">
|
||||||
Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps —
|
Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps —
|
||||||
<strong style="color: #14b8a6;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns.
|
<strong style="color: #35b9b9;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;">
|
<p style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;">
|
||||||
Plan together. Decide together. Fund together. Build together. No corporate middlemen.
|
Plan together. Decide together. Fund together. Build together. No corporate middlemen.
|
||||||
|
|
@ -72,7 +72,7 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
|
||||||
|
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<a href="${demoUrl}" style="display: inline-block; padding: 10px 22px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; margin-right: 8px;">Explore the Demo Space</a>
|
<a href="${demoUrl}" style="display: inline-block; padding: 10px 22px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; margin-right: 8px;">Explore the Demo Space</a>
|
||||||
<a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #14b8a6; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a>
|
<a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #35b9b9; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;">
|
<p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;">
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,14 @@ const MODULE_CATEGORIES: Record<string, string> = {
|
||||||
rstack: "Platform",
|
rstack: "Platform",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Color the "r" prefix orange in rApp names. */
|
||||||
|
function brandR(name: string): string {
|
||||||
|
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
|
||||||
|
return `<span style="color:#dc8300">r</span>${name.slice(1)}`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"Create",
|
"Create",
|
||||||
"Communicate",
|
"Communicate",
|
||||||
|
|
@ -392,7 +400,7 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
<div class="item-text">
|
<div class="item-text">
|
||||||
<span class="item-name-row">
|
<span class="item-name-row">
|
||||||
<span class="item-name">${m.name}</span>
|
<span class="item-name">${brandR(m.name)}</span>
|
||||||
${scopeBadge}
|
${scopeBadge}
|
||||||
</span>
|
</span>
|
||||||
<span class="item-desc">${m.description}</span>
|
<span class="item-desc">${m.description}</span>
|
||||||
|
|
@ -411,10 +419,10 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null;
|
const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null;
|
||||||
|
|
||||||
const triggerContent = badgeInfo
|
const triggerContent = badgeInfo
|
||||||
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${currentMod!.name}`
|
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${brandR(currentMod!.name)}`
|
||||||
: currentMod
|
: currentMod
|
||||||
? `${currentMod.icon} ${currentMod.name}`
|
? `${currentMod.icon} ${brandR(currentMod.name)}`
|
||||||
: `<span class="trigger-badge rstack-gradient">r✨</span> rSpace`;
|
: `<span class="trigger-badge rstack-gradient">r✨</span> <span style="color:#dc8300">r</span>Space`;
|
||||||
|
|
||||||
this.#shadow.innerHTML = `
|
this.#shadow.innerHTML = `
|
||||||
<style>${STYLES}</style>
|
<style>${STYLES}</style>
|
||||||
|
|
|
||||||
|
|
@ -512,8 +512,13 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session?.accessToken) return;
|
if (!session?.accessToken) return;
|
||||||
|
|
||||||
// Don't nag if dismissed within the last 7 days
|
|
||||||
const NUDGE_KEY = "eid_device_nudge_dismissed";
|
const NUDGE_KEY = "eid_device_nudge_dismissed";
|
||||||
|
const DONE_KEY = "eid_device_nudge_done";
|
||||||
|
|
||||||
|
// Permanently suppress if multi-device already confirmed
|
||||||
|
if (localStorage.getItem(DONE_KEY) === "1") return;
|
||||||
|
|
||||||
|
// Don't nag if dismissed within the last 7 days
|
||||||
const dismissed = localStorage.getItem(NUDGE_KEY);
|
const dismissed = localStorage.getItem(NUDGE_KEY);
|
||||||
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
|
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
|
||||||
|
|
||||||
|
|
@ -527,7 +532,11 @@ export class RStackIdentity extends HTMLElement {
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const status = await res.json();
|
const status = await res.json();
|
||||||
if (status.multiDevice) return; // already has 2+ devices
|
if (status.multiDevice) {
|
||||||
|
// Permanently mark as done — never nudge again
|
||||||
|
localStorage.setItem(DONE_KEY, "1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show a toast nudge with QR code
|
// Show a toast nudge with QR code
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue