586 lines
18 KiB
TypeScript
586 lines
18 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
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<string>();
|
|
private activeModules = new Set<string>();
|
|
private sortMode: SortMode = "name";
|
|
private expanded = new Set<string>();
|
|
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<string>();
|
|
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 = `
|
|
<style>${this.styles()}</style>
|
|
<div class="ct">
|
|
${this.loading ? `<div class="ct-loading">Loading content tree...</div>` : `
|
|
<div class="ct-toolbar">
|
|
<div class="ct-search-row">
|
|
<input type="text" class="ct-search" placeholder="Search content..." value="${this.escAttr(this.search)}" />
|
|
<select class="ct-sort">
|
|
<option value="name"${this.sortMode === "name" ? " selected" : ""}>Sort: Name</option>
|
|
<option value="date"${this.sortMode === "date" ? " selected" : ""}>Sort: Newest</option>
|
|
<option value="count"${this.sortMode === "count" ? " selected" : ""}>Sort: Most items</option>
|
|
</select>
|
|
</div>
|
|
${this.allTags.length > 0 ? `
|
|
<div class="ct-tags">
|
|
${this.allTags.map((tag) => `
|
|
<button class="ct-tag${this.activeTags.has(tag) ? " ct-tag--active" : ""}" data-tag="${this.escAttr(tag)}">${this.esc(tag)}</button>
|
|
`).join("")}
|
|
${this.activeTags.size > 0 ? `<button class="ct-tag ct-tag--clear">Clear filters</button>` : ""}
|
|
</div>` : ""}
|
|
<div class="ct-summary">${totalModules} module${totalModules !== 1 ? "s" : ""}, ${totalItems} item${totalItems !== 1 ? "s" : ""}</div>
|
|
</div>
|
|
<div class="ct-tree">
|
|
${totalItems === 0 ? `<div class="ct-empty">No content found${this.search || this.activeTags.size ? " matching your filters" : " in this space"}.</div>` : ""}
|
|
${modules.map((mod) => this.renderModule(mod)).join("")}
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
|
|
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 `
|
|
<div class="ct-node ct-node--module">
|
|
<div class="ct-node__row" data-toggle="${key}">
|
|
<span class="ct-node__toggle">${isExp ? "▾" : "▸"}</span>
|
|
<span class="ct-node__icon">${mod.icon}</span>
|
|
<span class="ct-node__label">${this.esc(mod.name)}</span>
|
|
<span class="ct-node__badge">${totalItems}</span>
|
|
</div>
|
|
${isExp ? `<div class="ct-node__children">
|
|
${mod.collections.map((col) => this.renderCollection(col, mod.id)).join("")}
|
|
</div>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="ct-node ct-node--collection">
|
|
<div class="ct-node__row" data-toggle="${key}">
|
|
<span class="ct-node__toggle">${isExp ? "▾" : "▸"}</span>
|
|
<span class="ct-node__icon">${icon}</span>
|
|
<span class="ct-node__label">${this.esc(col.collection)}</span>
|
|
<span class="ct-node__badge">${visibleItems.length}</span>
|
|
</div>
|
|
${isExp ? `<div class="ct-node__children">
|
|
${visibleItems.map((item) => this.renderItem(item, modId)).join("")}
|
|
</div>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderItem(item: TreeItem, modId: string): string {
|
|
const dateStr = item.createdAt ? new Date(item.createdAt).toLocaleDateString() : "";
|
|
return `
|
|
<div class="ct-node ct-node--item">
|
|
<div class="ct-node__row ct-node__row--leaf" data-nav="${modId}">
|
|
<span class="ct-node__toggle"></span>
|
|
<span class="ct-node__icon">📄</span>
|
|
<span class="ct-node__label">${this.esc(item.title)}</span>
|
|
${item.itemCount !== null ? `<span class="ct-node__badge">${item.itemCount}</span>` : ""}
|
|
${item.tags.length > 0 ? `<span class="ct-node__tags">${item.tags.map((t) =>
|
|
`<span class="ct-node__tag">${this.esc(t)}</span>`
|
|
).join("")}</span>` : ""}
|
|
${dateStr ? `<span class="ct-node__date">${dateStr}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private attachEvents() {
|
|
// Search
|
|
const searchInput = this.shadow.querySelector<HTMLInputElement>(".ct-search");
|
|
if (searchInput) {
|
|
searchInput.addEventListener("input", () => {
|
|
this.search = searchInput.value;
|
|
this.render();
|
|
// Re-focus and restore cursor
|
|
const newInput = this.shadow.querySelector<HTMLInputElement>(".ct-search");
|
|
if (newInput) {
|
|
newInput.focus();
|
|
newInput.setSelectionRange(newInput.value.length, newInput.value.length);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sort
|
|
const sortSelect = this.shadow.querySelector<HTMLSelectElement>(".ct-sort");
|
|
if (sortSelect) {
|
|
sortSelect.addEventListener("change", () => {
|
|
this.sortMode = sortSelect.value as SortMode;
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
// Tag chips
|
|
for (const btn of this.shadow.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>(".ct-tag--clear");
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener("click", () => {
|
|
this.activeTags.clear();
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
// Toggle expand/collapse
|
|
for (const row of this.shadow.querySelectorAll<HTMLElement>("[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<HTMLElement>(".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, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
private escAttr(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
}
|
|
|
|
private styles(): string {
|
|
return `
|
|
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); }
|
|
|
|
.ct { max-width: 900px; margin: 0 auto; }
|
|
|
|
.ct-loading {
|
|
text-align: center; padding: 3rem 1rem;
|
|
color: var(--rs-text-muted); font-size: 0.9rem;
|
|
}
|
|
|
|
/* Toolbar */
|
|
.ct-toolbar {
|
|
position: sticky; top: 0; z-index: 10;
|
|
background: var(--rs-bg-primary, #0f172a);
|
|
padding: 0.75rem 0; margin-bottom: 0.5rem;
|
|
border-bottom: 1px solid var(--rs-border);
|
|
}
|
|
.ct-search-row {
|
|
display: flex; gap: 0.5rem; margin-bottom: 0.5rem;
|
|
}
|
|
.ct-search {
|
|
flex: 1; padding: 0.5rem 0.75rem;
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
|
border-radius: 8px; color: var(--rs-text-primary);
|
|
font-size: 0.85rem; outline: none;
|
|
}
|
|
.ct-search:focus { border-color: #22d3ee; }
|
|
.ct-search::placeholder { color: var(--rs-text-muted); }
|
|
.ct-sort {
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
|
border-radius: 8px; color: var(--rs-text-primary);
|
|
font-size: 0.8rem; cursor: pointer; outline: none;
|
|
}
|
|
|
|
/* Tags */
|
|
.ct-tags {
|
|
display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.5rem;
|
|
overflow-x: auto; scrollbar-width: none;
|
|
}
|
|
.ct-tags::-webkit-scrollbar { display: none; }
|
|
.ct-tag {
|
|
padding: 0.2rem 0.6rem; border-radius: 12px;
|
|
font-size: 0.75rem; cursor: pointer;
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
|
color: var(--rs-text-secondary); transition: all 0.15s;
|
|
}
|
|
.ct-tag:hover { border-color: #22d3ee; color: var(--rs-text-primary); }
|
|
.ct-tag--active {
|
|
background: rgba(34, 211, 238, 0.15); border-color: #22d3ee;
|
|
color: #22d3ee;
|
|
}
|
|
.ct-tag--clear {
|
|
background: transparent; border-color: var(--rs-border);
|
|
color: var(--rs-text-muted); font-style: italic;
|
|
}
|
|
|
|
.ct-summary {
|
|
font-size: 0.75rem; color: var(--rs-text-muted);
|
|
}
|
|
|
|
/* Tree */
|
|
.ct-tree { padding-bottom: 2rem; }
|
|
|
|
.ct-empty {
|
|
text-align: center; padding: 3rem 1rem;
|
|
color: var(--rs-text-muted); font-size: 0.9rem;
|
|
}
|
|
|
|
/* Node */
|
|
.ct-node { margin: 0; }
|
|
.ct-node__row {
|
|
display: flex; align-items: center; gap: 0.4rem;
|
|
padding: 0.4rem 0.5rem; border-radius: 6px;
|
|
cursor: pointer; user-select: none; transition: background 0.1s;
|
|
}
|
|
.ct-node__row:hover { background: var(--rs-bg-surface); }
|
|
.ct-node__toggle {
|
|
width: 1rem; text-align: center; font-size: 0.7rem;
|
|
color: var(--rs-text-muted); flex-shrink: 0;
|
|
}
|
|
.ct-node__icon { font-size: 1rem; flex-shrink: 0; }
|
|
.ct-node__label {
|
|
flex: 1; font-size: 0.85rem; min-width: 0;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.ct-node__badge {
|
|
padding: 0.1rem 0.45rem; border-radius: 10px;
|
|
font-size: 0.7rem; background: var(--rs-bg-surface);
|
|
border: 1px solid var(--rs-border); color: var(--rs-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
.ct-node__tags { display: flex; gap: 0.25rem; flex-shrink: 0; }
|
|
.ct-node__tag {
|
|
padding: 0.1rem 0.4rem; border-radius: 8px;
|
|
font-size: 0.65rem; background: rgba(34, 211, 238, 0.1);
|
|
color: #22d3ee;
|
|
}
|
|
.ct-node__date {
|
|
font-size: 0.7rem; color: var(--rs-text-muted); flex-shrink: 0;
|
|
}
|
|
|
|
/* Nesting */
|
|
.ct-node--module { }
|
|
.ct-node--module > .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);
|