feat(rdata): add content tree view with search, tags, and sort
Adds a new Content Tree tab (default) to rData that indexes all Automerge docs in a space. Includes /api/content-tree endpoint, folk-content-tree web component with search, tag filtering, sort modes, expand/collapse, and demo data fallback. Analytics moves to second tab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1827b34f6b
commit
befd70c72b
|
|
@ -1,5 +1,6 @@
|
|||
/* Data module — layout wrapper */
|
||||
folk-analytics-view {
|
||||
folk-analytics-view,
|
||||
folk-content-tree {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
|
@ -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<string, { name: string; icon: string }> {
|
||||
const map = new Map<string, { name: string; icon: string }>();
|
||||
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<string, Map<string, Array<{
|
||||
docId: string;
|
||||
title: string | null;
|
||||
itemCount: number | null;
|
||||
createdAt: number | null;
|
||||
tags: string[];
|
||||
itemId: string | null;
|
||||
}>>>();
|
||||
|
||||
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
|
||||
? `<folk-content-tree space="${space}"></folk-content-tree>`
|
||||
: `<folk-analytics-view space="${space}"></folk-analytics-view>`;
|
||||
const scripts = isTree
|
||||
? `<script type="module" src="/modules/rdata/folk-content-tree.js"></script>`
|
||||
: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`;
|
||||
|
||||
return renderShell({
|
||||
title: `${space} — Data | rSpace`,
|
||||
moduleId: "rdata",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
|
||||
scripts: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`,
|
||||
body,
|
||||
scripts,
|
||||
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
|
||||
}));
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue