/** * 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. */ import { TourEngine } from '../../../shared/tour-engine'; import type { TourStep } from '../../../shared/tour-engine'; 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: "📺", }; const CONTENT_TREE_TOUR_STEPS: TourStep[] = [ { target: '.ct-search', title: 'Search Content', message: 'Find items across all modules by name or tag.' }, { target: '.ct-tags', title: 'Filter', message: 'Narrow results by module or tag.' }, { target: '.ct-tree', title: 'Content Tree', message: 'Browse all space data hierarchically — expand modules and collections.' }, ]; class FolkContentTree extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private data: TreeData | null = null; private _tour!: TourEngine; 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" }); this._tour = new TourEngine( this.shadow, CONTENT_TREE_TOUR_STEPS, 'rdata_tour_done', () => this.shadow.querySelector('.ct') as HTMLElement, ); } 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(); if (!localStorage.getItem('rdata_tour_done')) { setTimeout(() => this._tour.start(), 800); } } startTour() { this._tour.start(); } 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._tour.renderOverlay(); 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!); }); } // Tour button this.shadow.getElementById("btn-tour")?.addEventListener("click", () => this.startTour()); // 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);