diff --git a/modules/rdata/components/data.css b/modules/rdata/components/data.css index 2d1a91c..c21c738 100644 --- a/modules/rdata/components/data.css +++ b/modules/rdata/components/data.css @@ -1,5 +1,6 @@ /* Data module — layout wrapper */ -folk-analytics-view { +folk-analytics-view, +folk-content-tree { display: block; padding: 1.5rem; } diff --git a/modules/rdata/components/folk-content-tree.ts b/modules/rdata/components/folk-content-tree.ts new file mode 100644 index 0000000..5bf00d1 --- /dev/null +++ b/modules/rdata/components/folk-content-tree.ts @@ -0,0 +1,585 @@ +/** + * folk-content-tree — Interactive content tree that indexes all + * Automerge documents in a space, grouped by module and collection. + * + * Features: search, tag filter, module filter, sort, expand/collapse, + * click-to-navigate, demo mode fallback. + */ + +interface TreeItem { + docId: string; + title: string; + itemCount: number | null; + createdAt: number | null; + tags: string[]; +} + +interface TreeCollection { + collection: string; + items: TreeItem[]; +} + +interface TreeModule { + id: string; + name: string; + icon: string; + collections: TreeCollection[]; +} + +interface TreeData { + space: string; + modules: TreeModule[]; +} + +type SortMode = "name" | "date" | "count"; + +const DEMO_DATA: TreeData = { + space: "demo", + modules: [ + { + id: "notes", name: "rNotes", icon: "📝", + collections: [ + { + collection: "notebooks", items: [ + { docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", itemCount: 5, createdAt: 1710000000000, tags: ["dev", "planning"] }, + { docId: "demo:notes:notebooks:nb2", title: "Personal Notes", itemCount: 7, createdAt: 1709500000000, tags: ["personal"] }, + { docId: "demo:notes:notebooks:nb3", title: "Meeting Minutes", itemCount: 3, createdAt: 1709900000000, tags: ["meetings"] }, + ], + }, + ], + }, + { + id: "vote", name: "rVote", icon: "🗳", + collections: [ + { + collection: "proposals", items: [ + { docId: "demo:vote:proposals:p1", title: "Add dark mode", itemCount: null, createdAt: 1710100000000, tags: ["governance", "ux"] }, + { docId: "demo:vote:proposals:p2", title: "Budget approval Q2", itemCount: null, createdAt: 1710200000000, tags: ["governance", "finance"] }, + ], + }, + { + collection: "config", items: [ + { docId: "demo:vote:config", title: "Space Config", itemCount: null, createdAt: 1709000000000, tags: [] }, + ], + }, + ], + }, + { + id: "tasks", name: "rTasks", icon: "📋", + collections: [ + { + collection: "boards", items: [ + { docId: "demo:tasks:boards:b1", title: "Development Board", itemCount: 8, createdAt: 1710050000000, tags: ["dev"] }, + ], + }, + ], + }, + { + id: "cal", name: "rCal", icon: "📅", + collections: [ + { + collection: "calendars", items: [ + { docId: "demo:cal:calendars:c1", title: "Team Calendar", itemCount: 12, createdAt: 1709800000000, tags: ["team"] }, + { docId: "demo:cal:calendars:c2", title: "Personal", itemCount: 4, createdAt: 1709700000000, tags: ["personal"] }, + ], + }, + ], + }, + { + id: "wallet", name: "rWallet", icon: "💰", + collections: [ + { + collection: "ledgers", items: [ + { docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", itemCount: 3, createdAt: 1710300000000, tags: ["tokens", "finance"] }, + ], + }, + ], + }, + { + id: "flows", name: "rFlows", icon: "🌊", + collections: [ + { + collection: "streams", items: [ + { docId: "demo:flows:streams:s1", title: "Contributor Payments", itemCount: null, createdAt: 1710150000000, tags: ["finance", "governance"] }, + { docId: "demo:flows:streams:s2", title: "Community Fund", itemCount: null, createdAt: 1710250000000, tags: ["finance"] }, + ], + }, + ], + }, + ], +}; + +/** Icons for well-known collection names */ +const COLLECTION_ICONS: Record = { + notebooks: "📓", notes: "📄", proposals: "📜", config: "⚙️", + boards: "📋", tasks: "✅", calendars: "📅", events: "🗓", + ledgers: "💳", streams: "🌊", files: "📁", threads: "💬", + contacts: "📇", companies: "🏢", trips: "✈️", photos: "📸", + pages: "📃", books: "📚", items: "🏷", channels: "📺", +}; + +class FolkContentTree extends HTMLElement { + private shadow: ShadowRoot; + private space = "demo"; + private data: TreeData | null = null; + private search = ""; + private activeTags = new Set(); + private activeModules = new Set(); + private sortMode: SortMode = "name"; + private expanded = new Set(); + private allTags: string[] = []; + private loading = true; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.loadData(); + } + + private async loadData() { + this.loading = true; + this.render(); + + if (this.space === "demo") { + this.data = DEMO_DATA; + this.initFromData(); + return; + } + + try { + const base = window.location.pathname.replace(/\/(tree|analytics)?\/?$/, ""); + const resp = await fetch(`${base}/api/content-tree?space=${encodeURIComponent(this.space)}`); + if (resp.ok) { + this.data = await resp.json(); + } else { + this.data = { space: this.space, modules: [] }; + } + } catch { + this.data = { space: this.space, modules: [] }; + } + this.initFromData(); + } + + private initFromData() { + this.loading = false; + // Collect all unique tags + const tagSet = new Set(); + for (const mod of this.data!.modules) { + for (const col of mod.collections) { + for (const item of col.items) { + for (const tag of item.tags) tagSet.add(tag); + } + } + } + this.allTags = Array.from(tagSet).sort(); + // Expand all modules by default + for (const mod of this.data!.modules) { + this.expanded.add(`mod:${mod.id}`); + } + this.render(); + } + + private matchesSearch(text: string): boolean { + if (!this.search) return true; + return text.toLowerCase().includes(this.search.toLowerCase()); + } + + private matchesTags(tags: string[]): boolean { + if (this.activeTags.size === 0) return true; + return tags.some((t) => this.activeTags.has(t)); + } + + private matchesModuleFilter(modId: string): boolean { + if (this.activeModules.size === 0) return true; + return this.activeModules.has(modId); + } + + /** Check if an item passes all filters */ + private itemVisible(item: TreeItem, modId: string): boolean { + if (!this.matchesModuleFilter(modId)) return false; + if (!this.matchesTags(item.tags)) return false; + if (!this.matchesSearch(item.title) && !item.tags.some((t) => this.matchesSearch(t))) return false; + return true; + } + + /** Get sorted modules */ + private getSortedModules(): TreeModule[] { + if (!this.data) return []; + const mods = [...this.data.modules]; + switch (this.sortMode) { + case "name": + mods.sort((a, b) => a.name.localeCompare(b.name)); + break; + case "date": { + const newest = (m: TreeModule) => { + let max = 0; + for (const c of m.collections) for (const i of c.items) if (i.createdAt && i.createdAt > max) max = i.createdAt; + return max; + }; + mods.sort((a, b) => newest(b) - newest(a)); + break; + } + case "count": { + const total = (m: TreeModule) => m.collections.reduce((s, c) => s + c.items.length, 0); + mods.sort((a, b) => total(b) - total(a)); + break; + } + } + return mods; + } + + /** Sort items within a collection */ + private getSortedItems(items: TreeItem[]): TreeItem[] { + const sorted = [...items]; + switch (this.sortMode) { + case "name": + sorted.sort((a, b) => a.title.localeCompare(b.title)); + break; + case "date": + sorted.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + break; + case "count": + sorted.sort((a, b) => (b.itemCount || 0) - (a.itemCount || 0)); + break; + } + return sorted; + } + + private toggle(key: string) { + if (this.expanded.has(key)) this.expanded.delete(key); + else this.expanded.add(key); + this.render(); + } + + 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}`; + window.location.href = `${base}/${modPath}`; + } + + private render() { + const modules = this.getSortedModules(); + + // Count total visible items + let totalItems = 0; + let totalModules = 0; + for (const mod of modules) { + let modHasVisible = false; + for (const col of mod.collections) { + for (const item of col.items) { + if (this.itemVisible(item, mod.id)) { + totalItems++; + modHasVisible = true; + } + } + } + if (modHasVisible) totalModules++; + } + + this.shadow.innerHTML = ` + +
+ ${this.loading ? `
Loading content tree...
` : ` +
+
+ + +
+ ${this.allTags.length > 0 ? ` +
+ ${this.allTags.map((tag) => ` + + `).join("")} + ${this.activeTags.size > 0 ? `` : ""} +
` : ""} +
${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}
+
+
+ ${totalItems === 0 ? `
No content found${this.search || this.activeTags.size ? " matching your filters" : " in this space"}.
` : ""} + ${modules.map((mod) => this.renderModule(mod)).join("")} +
+ `} +
+ `; + + this.attachEvents(); + } + + private renderModule(mod: TreeModule): string { + // Check if any item in this module is visible + let visibleCount = 0; + for (const col of mod.collections) { + for (const item of col.items) { + if (this.itemVisible(item, mod.id)) visibleCount++; + } + } + if (visibleCount === 0) return ""; + + const key = `mod:${mod.id}`; + const isExp = this.expanded.has(key); + const totalItems = mod.collections.reduce((s, c) => s + c.items.length, 0); + + return ` +
+
+ ${isExp ? "▾" : "▸"} + ${mod.icon} + ${this.esc(mod.name)} + ${totalItems} +
+ ${isExp ? `
+ ${mod.collections.map((col) => this.renderCollection(col, mod.id)).join("")} +
` : ""} +
+ `; + } + + private renderCollection(col: TreeCollection, modId: string): string { + const visibleItems = this.getSortedItems(col.items).filter((it) => this.itemVisible(it, modId)); + if (visibleItems.length === 0) return ""; + + const key = `col:${modId}:${col.collection}`; + const isExp = this.expanded.has(key); + const icon = COLLECTION_ICONS[col.collection] || "📂"; + + // If only one item and collection name matches, flatten + if (visibleItems.length === 1 && !isExp) { + // Show collection as expandable still + } + + return ` +
+
+ ${isExp ? "▾" : "▸"} + ${icon} + ${this.esc(col.collection)} + ${visibleItems.length} +
+ ${isExp ? `
+ ${visibleItems.map((item) => this.renderItem(item, modId)).join("")} +
` : ""} +
+ `; + } + + private renderItem(item: TreeItem, modId: string): string { + const dateStr = item.createdAt ? new Date(item.createdAt).toLocaleDateString() : ""; + return ` +
+
+ + 📄 + ${this.esc(item.title)} + ${item.itemCount !== null ? `${item.itemCount}` : ""} + ${item.tags.length > 0 ? `${item.tags.map((t) => + `${this.esc(t)}` + ).join("")}` : ""} + ${dateStr ? `${dateStr}` : ""} +
+
+ `; + } + + private attachEvents() { + // Search + const searchInput = this.shadow.querySelector(".ct-search"); + if (searchInput) { + searchInput.addEventListener("input", () => { + this.search = searchInput.value; + this.render(); + // Re-focus and restore cursor + const newInput = this.shadow.querySelector(".ct-search"); + if (newInput) { + newInput.focus(); + newInput.setSelectionRange(newInput.value.length, newInput.value.length); + } + }); + } + + // Sort + const sortSelect = this.shadow.querySelector(".ct-sort"); + if (sortSelect) { + sortSelect.addEventListener("change", () => { + this.sortMode = sortSelect.value as SortMode; + this.render(); + }); + } + + // Tag chips + for (const btn of this.shadow.querySelectorAll(".ct-tag[data-tag]")) { + btn.addEventListener("click", () => { + const tag = btn.dataset.tag!; + if (this.activeTags.has(tag)) this.activeTags.delete(tag); + else this.activeTags.add(tag); + this.render(); + }); + } + const clearBtn = this.shadow.querySelector(".ct-tag--clear"); + if (clearBtn) { + clearBtn.addEventListener("click", () => { + this.activeTags.clear(); + this.render(); + }); + } + + // Toggle expand/collapse + for (const row of this.shadow.querySelectorAll("[data-toggle]")) { + row.addEventListener("click", (e) => { + // Don't toggle if clicking a nav link + if ((e.target as HTMLElement).closest("[data-nav]") && !((e.target as HTMLElement).closest("[data-toggle]") as HTMLElement)?.dataset?.toggle) return; + this.toggle(row.dataset.toggle!); + }); + } + + // Navigate on leaf click + for (const row of this.shadow.querySelectorAll(".ct-node__row--leaf[data-nav]")) { + row.addEventListener("click", () => { + this.navigate(row.dataset.nav!); + }); + } + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + private escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(/ .ct-node__row .ct-node__label { font-weight: 600; font-size: 0.9rem; } + .ct-node--collection { padding-left: 1.25rem; } + .ct-node--collection > .ct-node__row .ct-node__label { color: var(--rs-text-secondary); } + .ct-node--item { padding-left: 2.5rem; } + .ct-node__row--leaf { cursor: pointer; } + .ct-node__row--leaf:hover { background: rgba(34, 211, 238, 0.08); } + + .ct-node__children { } + + /* Responsive */ + @media (max-width: 600px) { + .ct-search-row { flex-direction: column; } + .ct-node--collection { padding-left: 0.75rem; } + .ct-node--item { padding-left: 1.5rem; } + .ct-node__date { display: none; } + .ct-node__tags { display: none; } + } + `; + } +} + +customElements.define("folk-content-tree", FolkContentTree); diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index da6abcb..d4836cd 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -7,8 +7,9 @@ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; -import { getModuleInfoList } from "../../shared/module"; +import { getAllModules, getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; +import { syncServer } from "../../server/sync-instance"; import { renderLanding } from "./landing"; const routes = new Hono(); @@ -118,20 +119,172 @@ routes.post("/api/collect", async (c) => { return c.json({ ok: true }); }); -// ── Page route ── -routes.get("/", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - return c.html(renderShell({ +// ── Content Tree API ── + +/** Build a module icon/name lookup from registered modules */ +function getModuleMeta(): Map { + const map = new Map(); + for (const m of getAllModules()) { + // Module IDs in doc keys omit the 'r' prefix and are lowercase, e.g. "notes" for rNotes + map.set(m.id.replace(/^r/, ""), { name: m.name, icon: m.icon }); + // Also map the full ID for direct matches + map.set(m.id, { name: m.name, icon: m.icon }); + } + return map; +} + +/** Extract a human-readable title from an Automerge doc */ +function extractTitle(doc: any): string | null { + if (!doc) return null; + if (doc.meta?.title) return doc.meta.title; + if (doc.title) return doc.title; + if (doc.name) return doc.name; + if (doc.spaceConfig?.title) return doc.spaceConfig.title; + return null; +} + +/** Extract tags from an Automerge doc */ +function extractTags(doc: any): string[] { + if (!doc) return []; + if (Array.isArray(doc.tags)) return doc.tags.map(String); + if (Array.isArray(doc.meta?.tags)) return doc.meta.tags.map(String); + return []; +} + +/** Count items in a doc (for collection-level docs with Record-like data) */ +function countItems(doc: any): number | null { + if (!doc) return null; + // Check common collection patterns + if (doc.items && typeof doc.items === "object") return Object.keys(doc.items).length; + if (doc.entries && typeof doc.entries === "object") return Object.keys(doc.entries).length; + if (doc.notes && typeof doc.notes === "object") return Object.keys(doc.notes).length; + if (doc.proposals && typeof doc.proposals === "object") return Object.keys(doc.proposals).length; + if (doc.tasks && typeof doc.tasks === "object") return Object.keys(doc.tasks).length; + if (doc.events && typeof doc.events === "object") return Object.keys(doc.events).length; + if (doc.threads && typeof doc.threads === "object") return Object.keys(doc.threads).length; + if (doc.files && typeof doc.files === "object") return Object.keys(doc.files).length; + return null; +} + +routes.get("/api/content-tree", (c) => { + const space = c.req.query("space") || c.req.param("space") || "demo"; + const moduleMeta = getModuleMeta(); + + const allDocIds = syncServer.listDocs(); + const prefix = `${space}:`; + const spaceDocIds = allDocIds.filter((id) => id.startsWith(prefix)); + + // Group by module → collection → items + const moduleMap = new Map>>(); + + for (const docId of spaceDocIds) { + const parts = docId.split(":"); + // Format: {space}:{module}:{collection}[:{itemId}] + if (parts.length < 3) continue; + const [, modKey, collection, ...rest] = parts; + const itemId = rest.length ? rest.join(":") : null; + + if (!moduleMap.has(modKey)) moduleMap.set(modKey, new Map()); + const colMap = moduleMap.get(modKey)!; + if (!colMap.has(collection)) colMap.set(collection, []); + + const doc = syncServer.getDoc(docId); + colMap.get(collection)!.push({ + docId, + title: extractTitle(doc), + itemCount: countItems(doc), + createdAt: (doc as any)?.meta?.createdAt ?? null, + tags: extractTags(doc), + itemId, + }); + } + + // Build response + const modules: any[] = []; + for (const [modKey, colMap] of moduleMap) { + const meta = moduleMeta.get(modKey) || { name: modKey, icon: "📄" }; + const collections: any[] = []; + for (const [colName, items] of colMap) { + collections.push({ + collection: colName, + items: items.map((it) => ({ + docId: it.docId, + title: it.title || it.itemId || colName, + itemCount: it.itemCount, + createdAt: it.createdAt, + tags: it.tags, + })), + }); + } + modules.push({ + id: modKey, + name: meta.name, + icon: meta.icon, + collections, + }); + } + + // Sort modules alphabetically by name + modules.sort((a, b) => a.name.localeCompare(b.name)); + + return c.json({ space, modules }); +}); + +// ── Tab routing ── + +const DATA_TABS = [ + { id: "tree", label: "Content Tree", icon: "🌳" }, + { id: "analytics", label: "Analytics", icon: "📊" }, +] as const; + +const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id)); + +function renderDataPage(space: string, activeTab: string) { + const isTree = activeTab === "tree"; + const body = isTree + ? `` + : ``; + const scripts = isTree + ? `` + : ``; + + return renderShell({ title: `${space} — Data | rSpace`, moduleId: "rdata", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ``, - scripts: ``, + body, + scripts, styles: ``, - })); + tabs: [...DATA_TABS], + activeTab, + tabBasePath: process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, + }); +} + +// ── Page routes ── +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderDataPage(space, "tree")); +}); + +routes.get("/:tabId", (c, next) => { + const space = c.req.param("space") || "demo"; + const tabId = c.req.param("tabId"); + // Skip API and asset routes — let Hono fall through + if (tabId.startsWith("api") || tabId.includes(".")) return next(); + if (!DATA_TAB_IDS.has(tabId as any)) { + return c.redirect(process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, 302); + } + return c.html(renderDataPage(space, tabId)); }); export const dataModule: RSpaceModule = { diff --git a/vite.config.ts b/vite.config.ts index 0c0bfb2..cc35b96 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1049,6 +1049,26 @@ export default defineConfig({ }, }); + // Build content tree component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rdata/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rdata"), + lib: { + entry: resolve(__dirname, "modules/rdata/components/folk-content-tree.ts"), + formats: ["es"], + fileName: () => "folk-content-tree.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-content-tree.js", + }, + }, + }, + }); + // Copy data CSS mkdirSync(resolve(__dirname, "dist/modules/rdata"), { recursive: true }); copyFileSync(