feat: port standalone app features to unified rSpace modules

rCal: keyboard navigation (←→ nav, 1/2/3 views, T today, L lunar) and
clickable source badges for event filtering.
rWork: inline create form with priority/description, inline title editing,
and priority badge cycling (replaces prompt() dialogs).
rNetwork: force-directed layout (80-iteration simulation), node detail
panel with trust scores, and purple trust badges on person nodes.
rMaps: provider search with dimming, enhanced detail with OSM directions
link, and browser geolocation pin display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 00:05:34 +00:00
parent abbfb552cc
commit bb6643cf70
4 changed files with 467 additions and 79 deletions

View File

@ -19,6 +19,8 @@ class FolkCalendarView extends HTMLElement {
private selectedEvent: any = null;
private expandedDay = ""; // mobile day-detail panel
private error = "";
private filteredSources = new Set<string>();
private boundKeyHandler: ((e: KeyboardEvent) => void) | null = null;
constructor() {
super();
@ -27,11 +29,36 @@ class FolkCalendarView extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.boundKeyHandler = (e: KeyboardEvent) => this.handleKeydown(e);
document.addEventListener("keydown", this.boundKeyHandler);
if (this.space === "demo") { this.loadDemoData(); return; }
this.loadMonth();
this.render();
}
disconnectedCallback() {
if (this.boundKeyHandler) {
document.removeEventListener("keydown", this.boundKeyHandler);
this.boundKeyHandler = null;
}
}
private handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
switch (e.key) {
case "ArrowLeft": e.preventDefault(); this.navigate(-1); break;
case "ArrowRight": e.preventDefault(); this.navigate(1); break;
case "1": this.viewMode = "day"; this.render(); break;
case "2": this.viewMode = "week"; this.render(); break;
case "3": this.viewMode = "month"; this.render(); break;
case "t": case "T":
this.currentDate = new Date(); this.expandedDay = "";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
break;
case "l": case "L": this.showLunar = !this.showLunar; this.render(); break;
}
}
private loadDemoData() {
const now = new Date();
const year = now.getFullYear();
@ -218,7 +245,14 @@ class FolkCalendarView extends HTMLElement {
}
private getEventsForDate(dateStr: string): any[] {
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr)
&& !this.filteredSources.has(e.source_name));
}
private toggleSource(name: string) {
if (this.filteredSources.has(name)) { this.filteredSources.delete(name); }
else { this.filteredSources.add(name); }
this.render();
}
private render() {
@ -243,7 +277,9 @@ class FolkCalendarView extends HTMLElement {
.view-switch-btn.active { background: #4f46e5; color: #fff; }
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; cursor: pointer; transition: opacity 0.15s; user-select: none; }
.src-badge:hover { filter: brightness(1.2); }
.src-badge.filtered { opacity: 0.3; text-decoration: line-through; }
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
.wd { text-align: center; font-size: 11px; color: #64748b; padding: 4px; font-weight: 600; }
@ -371,7 +407,7 @@ class FolkCalendarView extends HTMLElement {
</div>
${this.sources.length > 0 ? `<div class="sources">
${this.sources.map(s => `<span class="src-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}" data-source="${this.esc(s.name)}" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>` : ""}
${this.viewMode === "month" ? this.renderMonth() : ""}
@ -711,6 +747,14 @@ class FolkCalendarView extends HTMLElement {
this.render();
});
// Source filter toggles
this.shadow.querySelectorAll("[data-source]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.toggleSource((el as HTMLElement).dataset.source!);
});
});
// View switcher
this.shadow.querySelectorAll("[data-view]").forEach(el => {
el.addEventListener("click", () => {

View File

@ -32,6 +32,8 @@ class FolkMapViewer extends HTMLElement {
private dragVbY = 0;
private zoomLevel = 1;
private selectedProvider = -1;
private searchQuery = "";
private userLocation: { lat: number; lng: number } | null = null;
constructor() {
super();
@ -97,6 +99,18 @@ class FolkMapViewer extends HTMLElement {
this.renderDemo();
}
private getFilteredProviders(): { provider: typeof this.providers[0]; index: number }[] {
if (!this.searchQuery.trim()) return this.providers.map((p, i) => ({ provider: p, index: i }));
const q = this.searchQuery.toLowerCase();
return this.providers.map((p, i) => ({ provider: p, index: i }))
.filter(({ provider: p }) =>
p.name.toLowerCase().includes(q) ||
p.city.toLowerCase().includes(q) ||
p.country.toLowerCase().includes(q) ||
p.specialties.some(s => s.toLowerCase().includes(q))
);
}
private renderDemo() {
const W = 900;
const H = 460;
@ -104,6 +118,8 @@ class FolkMapViewer extends HTMLElement {
const px = (lng: number) => ((lng + 180) / 360) * W;
const py = (lat: number) => ((90 - lat) / 180) * H;
const filteredSet = new Set(this.getFilteredProviders().map(f => f.index));
// Label offsets to avoid overlapping
const labelOffsets: Record<string, [number, number]> = {
"Radiant Hall Press": [10, -8],
@ -130,13 +146,27 @@ class FolkMapViewer extends HTMLElement {
}).join("\n");
// Provider pins
// User location pin
const userPin = this.userLocation ? (() => {
const ux = px(this.userLocation!.lng);
const uy = py(this.userLocation!.lat);
return `
<g>
<circle cx="${ux}" cy="${uy}" r="6" fill="#22c55e" stroke="#fff" stroke-width="2" opacity="0.9">
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite" />
</circle>
<text x="${ux + 10}" y="${uy + 4}" fill="#22c55e" font-size="9" font-weight="600" font-family="system-ui,sans-serif">You</text>
</g>`;
})() : "";
const pins = this.providers.map((p, i) => {
const x = px(p.lng);
const y = py(p.lat);
const [lx, ly] = labelOffsets[p.name] || [10, 4];
const isSelected = this.selectedProvider === i;
const isDimmed = this.searchQuery.trim() && !filteredSet.has(i);
return `
<g class="pin-group" data-idx="${i}" style="cursor:pointer">
<g class="pin-group" data-idx="${i}" style="cursor:pointer;${isDimmed ? "opacity:0.2" : ""}">
<circle cx="${x}" cy="${y}" r="4" fill="none" stroke="${p.color}" stroke-width="1" opacity="0.5">
<animate attributeName="r" values="4;14;4" dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" />
@ -182,6 +212,9 @@ class FolkMapViewer extends HTMLElement {
<div style="font-size:11px;color:#4a5568;margin-top:10px;font-family:monospace;">
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
</div>
<div style="display:flex;gap:8px;margin-top:12px;">
<a href="https://www.openstreetmap.org/?mlat=${sp.lat}&mlon=${sp.lng}#map=15/${sp.lat}/${sp.lng}" target="_blank" rel="noopener" style="flex:1;text-align:center;padding:8px;border-radius:6px;background:#4f46e520;border:1px solid #4f46e540;color:#818cf8;font-size:12px;font-weight:600;text-decoration:none;">Get Directions</a>
</div>
</div>`;
}
@ -212,6 +245,20 @@ class FolkMapViewer extends HTMLElement {
.zoom-btn:hover { border-color: #555; color: #e2e8f0; }
.zoom-label { font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; min-width: 32px; text-align: center; }
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.search-input {
flex: 1; border: 1px solid #1e293b; border-radius: 8px; padding: 8px 12px;
background: #0c1221; color: #e0e0e0; font-size: 13px; outline: none;
}
.search-input:focus { border-color: #6366f1; }
.search-input::placeholder { color: #4a5568; }
.geo-btn {
padding: 8px 14px; border-radius: 8px; border: 1px solid #1e293b;
background: #0c1221; color: #94a3b8; cursor: pointer; font-size: 12px; white-space: nowrap;
}
.geo-btn:hover { border-color: #334155; color: #e2e8f0; }
.geo-btn.active { border-color: #22c55e; color: #22c55e; }
.map-wrap {
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b;
overflow: hidden; position: relative; cursor: grab;
@ -292,6 +339,11 @@ class FolkMapViewer extends HTMLElement {
<span class="demo-nav__badge"><span class="dot"></span> ${this.providers.length} providers online</span>
</div>
<div class="search-bar">
<input class="search-input" type="text" id="map-search" placeholder="Search providers by name, city, or specialty..." value="${this.esc(this.searchQuery)}">
<button class="geo-btn ${this.userLocation ? "active" : ""}" id="share-geo">${this.userLocation ? "\u{1F4CD} Sharing" : "\u{1F4CD} Share Location"}</button>
</div>
<div class="map-wrap" id="map-wrap">
<div class="tooltip" id="tooltip"></div>
<svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg">
@ -316,6 +368,9 @@ class FolkMapViewer extends HTMLElement {
<!-- Provider pins -->
${pins}
<!-- User location -->
${userPin}
</svg>
</div>
@ -508,6 +563,32 @@ class FolkMapViewer extends HTMLElement {
}
private attachDemoListeners() {
// Search input
let searchTimeout: any;
this.shadow.getElementById("map-search")?.addEventListener("input", (e) => {
this.searchQuery = (e.target as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.renderDemo(), 200);
});
// Geolocation button
this.shadow.getElementById("share-geo")?.addEventListener("click", () => {
if (this.userLocation) {
this.userLocation = null;
this.renderDemo();
return;
}
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
this.userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
this.renderDemo();
},
() => { /* denied — do nothing */ }
);
}
});
const tooltip = this.shadow.getElementById("tooltip");
const mapWrap = this.shadow.getElementById("map-wrap");
const mapSvg = this.shadow.getElementById("map-svg");

View File

@ -32,6 +32,7 @@ class FolkGraphViewer extends HTMLElement {
private filter: "all" | "person" | "company" | "opportunity" = "all";
private searchQuery = "";
private error = "";
private selectedNode: GraphNode | null = null;
constructor() {
super();
@ -141,6 +142,130 @@ class FolkGraphViewer extends HTMLElement {
return filtered;
}
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
const pos: Record<string, { x: number; y: number }> = {};
// Initial positions: orgs in triangle, people around their org
const orgCenters: Record<string, { x: number; y: number }> = {
"org-1": { x: W / 2, y: 120 },
"org-2": { x: 160, y: 380 },
"org-3": { x: W - 160, y: 380 },
};
const orgNameToId: Record<string, string> = {
"Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3",
};
for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p };
const peopleByOrg: Record<string, GraphNode[]> = {};
for (const n of nodes) {
if (n.type === "person") {
const oid = orgNameToId[n.workspace];
if (oid) { (peopleByOrg[oid] ??= []).push(n); }
}
}
for (const [oid, people] of Object.entries(peopleByOrg)) {
const c = orgCenters[oid];
if (!c) continue;
const gcx = W / 2, gcy = 250;
const base = Math.atan2(c.y - gcy, c.x - gcx);
const spread = Math.PI * 0.8;
people.forEach((p, i) => {
const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1);
pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) };
});
}
// Run force iterations
const allIds = nodes.map(n => n.id).filter(id => pos[id]);
for (let iter = 0; iter < 80; iter++) {
const force: Record<string, { fx: number; fy: number }> = {};
for (const id of allIds) force[id] = { fx: 0, fy: 0 };
// Repulsion between all nodes
for (let i = 0; i < allIds.length; i++) {
for (let j = i + 1; j < allIds.length; j++) {
const a = pos[allIds[i]], b = pos[allIds[j]];
let dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const repel = 3000 / (dist * dist);
dx /= dist; dy /= dist;
force[allIds[i]].fx -= dx * repel;
force[allIds[i]].fy -= dy * repel;
force[allIds[j]].fx += dx * repel;
force[allIds[j]].fy += dy * repel;
}
}
// Attraction along edges
for (const edge of edges) {
const a = pos[edge.source], b = pos[edge.target];
if (!a || !b) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const idealLen = edge.type === "work_at" ? 100 : 200;
const attract = (dist - idealLen) * 0.01;
const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1);
if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; }
if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; }
}
// Center gravity
for (const id of allIds) {
const p = pos[id];
force[id].fx += (W / 2 - p.x) * 0.002;
force[id].fy += (H / 2 - p.y) * 0.002;
}
// Apply forces with damping
const damping = 0.4 * (1 - iter / 80);
for (const id of allIds) {
pos[id].x += force[id].fx * damping;
pos[id].y += force[id].fy * damping;
pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x));
pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y));
}
}
return pos;
}
private getTrustScore(nodeId: string): number {
return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20);
}
private getConnectedNodes(nodeId: string): GraphNode[] {
const connIds = new Set<string>();
for (const e of this.edges) {
if (e.source === nodeId) connIds.add(e.target);
if (e.target === nodeId) connIds.add(e.source);
}
return this.nodes.filter(n => connIds.has(n.id));
}
private renderDetailPanel(): string {
if (!this.selectedNode) return "";
const n = this.selectedNode;
const connected = this.getConnectedNodes(n.id);
const trust = n.type === "person" ? this.getTrustScore(n.id) : -1;
return `
<div class="detail-panel" id="detail-panel">
<div class="detail-header">
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span>
<div class="detail-info">
<div class="detail-name">${this.esc(n.name)}</div>
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
</div>
<button class="detail-close" id="close-detail">\u2715</button>
</div>
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
${connected.length > 0 ? `
<div class="detail-section">Connected (${connected.length})</div>
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
` : ""}
</div>`;
}
private renderGraphNodes(): string {
const filtered = this.getFilteredNodes();
if (filtered.length === 0 && this.nodes.length > 0) {
@ -161,69 +286,21 @@ class FolkGraphViewer extends HTMLElement {
const H = 500;
const filteredIds = new Set(filtered.map(n => n.id));
// Cluster layout: position org nodes as hubs, people orbit around their org
// Three orgs arranged in a triangle
const orgCenters: Record<string, { x: number; y: number }> = {
"org-1": { x: W / 2, y: 120 }, // Commons DAO — top center
"org-2": { x: 160, y: 380 }, // Mycelial Lab — bottom left
"org-3": { x: W - 160, y: 380 }, // Regenerative Fund — bottom right
};
// Force-directed layout
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
// Build a position map for all nodes
const positions: Record<string, { x: number; y: number }> = {};
// Position org nodes at their centers
for (const [id, pos] of Object.entries(orgCenters)) {
positions[id] = pos;
}
// Group people by their workspace (org)
const orgNameToId: Record<string, string> = {
"Commons DAO": "org-1",
"Mycelial Lab": "org-2",
"Regenerative Fund": "org-3",
};
const peopleByOrg: Record<string, GraphNode[]> = {};
for (const node of this.nodes) {
if (node.type === "person") {
const orgId = orgNameToId[node.workspace];
if (orgId) {
if (!peopleByOrg[orgId]) peopleByOrg[orgId] = [];
peopleByOrg[orgId].push(node);
}
}
}
// Position people in a semicircle around their org
const orbitRadius = 110;
for (const [orgId, people] of Object.entries(peopleByOrg)) {
const center = orgCenters[orgId];
if (!center) continue;
const count = people.length;
// Spread people in an arc facing outward from the graph center
const graphCx = W / 2;
const graphCy = (120 + 380) / 2; // vertical center of the triangle
const baseAngle = Math.atan2(center.y - graphCy, center.x - graphCx);
const spread = Math.PI * 0.8; // 144 degrees arc
for (let i = 0; i < count; i++) {
const angle = baseAngle - spread / 2 + (spread * i) / Math.max(count - 1, 1);
positions[people[i].id] = {
x: center.x + orbitRadius * Math.cos(angle),
y: center.y + orbitRadius * Math.sin(angle),
};
}
}
// Org background cluster circles
// Org colors
const orgColors: Record<string, string> = {
"org-1": "#6366f1", // indigo for Commons DAO
"org-2": "#22c55e", // green for Mycelial Lab
"org-3": "#f59e0b", // amber for Regenerative Fund
"org-1": "#6366f1", "org-2": "#22c55e", "org-3": "#f59e0b",
};
const clustersSvg = Object.entries(orgCenters).map(([orgId, pos]) => {
// Cluster backgrounds based on computed positions
const orgIds = ["org-1", "org-2", "org-3"];
const clustersSvg = orgIds.map(orgId => {
const pos = positions[orgId];
if (!pos) return "";
const color = orgColors[orgId] || "#333";
return `<circle cx="${pos.x}" cy="${pos.y}" r="${orbitRadius + 30}" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
return `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
}).join("");
// Render edges
@ -237,7 +314,6 @@ class FolkGraphViewer extends HTMLElement {
if (edge.type === "work_at") {
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#555" stroke-width="1" opacity="0.35"/>`);
} else if (edge.type === "point_of_contact") {
// Cross-org edges: dashed, brighter
const mx = (sp.x + tp.x) / 2;
const my = (sp.y + tp.y) / 2;
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#c084fc" stroke-width="1.5" stroke-dasharray="6 3" opacity="0.6"/>`);
@ -254,6 +330,7 @@ class FolkGraphViewer extends HTMLElement {
const isOrg = node.type === "company";
const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6";
const radius = isOrg ? 22 : 12;
const isSelected = this.selectedNode?.id === node.id;
let label = this.esc(node.name);
let sublabel = "";
@ -263,11 +340,22 @@ class FolkGraphViewer extends HTMLElement {
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" fill="#666" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
}
// Trust score badge for people
const trust = !isOrg ? this.getTrustScore(node.id) : -1;
const trustBadge = trust >= 0 ? `
<circle cx="${pos.x + radius - 2}" cy="${pos.y - radius + 2}" r="8" fill="#7c3aed" stroke="#0d0d14" stroke-width="1.5"/>
<text x="${pos.x + radius - 2}" y="${pos.y - radius + 5.5}" fill="#fff" font-size="7" font-weight="700" text-anchor="middle">${trust}</text>
` : "";
return `
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
${sublabel}
<g class="graph-node" data-node-id="${node.id}" style="cursor:pointer">
${isSelected ? `<circle cx="${pos.x}" cy="${pos.y}" r="${radius + 6}" fill="none" stroke="${color}" stroke-width="2" opacity="0.6"/>` : ""}
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
${sublabel}
${trustBadge}
</g>
`;
}).join("");
@ -329,6 +417,30 @@ class FolkGraphViewer extends HTMLElement {
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
.graph-node:hover circle:first-child { filter: brightness(1.2); }
.detail-panel {
background: #1a1a2e; border: 1px solid #334155; border-radius: 10px;
padding: 16px; margin-top: 12px;
}
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.detail-icon { font-size: 24px; }
.detail-info { flex: 1; }
.detail-name { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.detail-type { font-size: 12px; color: #94a3b8; }
.detail-close { background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; padding: 4px; }
.detail-close:hover { color: #e2e8f0; }
.detail-desc { font-size: 13px; color: #94a3b8; line-height: 1.5; margin: 8px 0; }
.detail-trust { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
.trust-label { font-size: 11px; color: #64748b; min-width: 70px; }
.trust-bar { flex: 1; height: 6px; background: #1e1e2e; border-radius: 3px; overflow: hidden; }
.trust-fill { display: block; height: 100%; background: #7c3aed; border-radius: 3px; transition: width 0.3s; }
.trust-val { font-size: 12px; font-weight: 700; color: #a78bfa; min-width: 24px; text-align: right; }
.detail-section { font-size: 11px; font-weight: 600; color: #64748b; margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.detail-conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #e2e8f0; padding: 4px 0; }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.conn-role { font-size: 11px; color: #64748b; margin-left: auto; }
@media (max-width: 768px) {
.graph-canvas { height: 350px; }
.workspace-list { grid-template-columns: 1fr; }
@ -371,6 +483,8 @@ class FolkGraphViewer extends HTMLElement {
`}
</div>
${this.renderDetailPanel()}
<div class="legend">
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
@ -406,6 +520,22 @@ class FolkGraphViewer extends HTMLElement {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => this.render(), 200);
});
// Node click → detail panel
this.shadow.querySelectorAll("[data-node-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.nodeId!;
if (this.selectedNode?.id === id) { this.selectedNode = null; }
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
this.render();
});
});
// Close detail panel
this.shadow.getElementById("close-detail")?.addEventListener("click", () => {
this.selectedNode = null;
this.render();
});
}
private esc(s: string): string {

View File

@ -17,6 +17,9 @@ class FolkWorkBoard extends HTMLElement {
private error = "";
private isDemo = false;
private dragTaskId: string | null = null;
private editingTaskId: string | null = null;
private showCreateForm = false;
private priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
constructor() {
super();
@ -102,11 +105,11 @@ class FolkWorkBoard extends HTMLElement {
} catch { this.error = "Failed to create workspace"; this.render(); }
}
private async createTask() {
const title = prompt("Task title:");
if (!title?.trim()) return;
private async submitCreateTask(title: string, priority: string, description: string) {
if (!title.trim()) return;
if (this.isDemo) {
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority: "MEDIUM", labels: [] });
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: "TODO", priority, labels: [], description: description.trim() || undefined });
this.showCreateForm = false;
this.render();
return;
}
@ -115,12 +118,41 @@ class FolkWorkBoard extends HTMLElement {
await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim() }),
body: JSON.stringify({ title: title.trim(), priority, description: description.trim() || undefined }),
});
this.showCreateForm = false;
this.loadTasks();
} catch { this.error = "Failed to create task"; this.render(); }
}
private async updateTask(taskId: string, fields: Record<string, string>) {
if (this.isDemo) {
const task = this.tasks.find(t => t.id === taskId);
if (task) Object.assign(task, fields);
this.editingTaskId = null;
this.render();
return;
}
try {
const base = this.getApiBase();
await fetch(`${base}/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fields),
});
this.editingTaskId = null;
this.loadTasks();
} catch { this.error = "Failed to update task"; this.render(); }
}
private cyclePriority(taskId: string) {
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
const idx = this.priorities.indexOf(task.priority || "MEDIUM");
const next = this.priorities[(idx + 1) % this.priorities.length];
this.updateTask(taskId, { priority: next });
}
private async moveTask(taskId: string, newStatus: string) {
if (this.isDemo) {
const task = this.tasks.find(t => t.id === taskId);
@ -195,6 +227,25 @@ class FolkWorkBoard extends HTMLElement {
.move-btn { font-size: 10px; padding: 2px 6px; border-radius: 4px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; }
.move-btn:hover { border-color: #555; color: #ccc; }
.create-form { background: #1a1a2e; border: 1px solid #4f46e5; border-radius: 8px; padding: 10px; margin-bottom: 10px; }
.create-form input, .create-form select, .create-form textarea {
width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid #333;
background: #16161e; color: #e0e0e0; font-size: 13px; margin-bottom: 6px; outline: none; font-family: inherit;
}
.create-form input:focus, .create-form select:focus, .create-form textarea:focus { border-color: #6366f1; }
.create-form textarea { resize: vertical; min-height: 40px; }
.create-form-actions { display: flex; gap: 6px; }
.create-form-actions button { padding: 4px 12px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; font-weight: 600; }
.cf-submit { background: #4f46e5; color: #fff; }
.cf-cancel { background: transparent; color: #888; border: 1px solid #333 !important; }
.task-title-input {
width: 100%; padding: 4px 6px; border-radius: 4px; border: 1px solid #6366f1;
background: #16161e; color: #e0e0e0; font-size: 13px; font-weight: 500; outline: none; font-family: inherit;
}
.badge.clickable { cursor: pointer; transition: all 0.15s; }
.badge.clickable:hover { filter: brightness(1.3); transform: scale(1.1); }
.empty { text-align: center; color: #666; padding: 40px; }
@media (max-width: 768px) {
@ -229,10 +280,29 @@ class FolkWorkBoard extends HTMLElement {
`;
}
private renderCreateForm(): string {
if (!this.showCreateForm) return "";
return `
<div class="create-form" id="create-form">
<input type="text" id="cf-title" placeholder="Task title..." autofocus>
<select id="cf-priority">
<option value="LOW">Low</option>
<option value="MEDIUM" selected>Medium</option>
<option value="HIGH">High</option>
<option value="URGENT">Urgent</option>
</select>
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
<div class="create-form-actions">
<button class="cf-submit" id="cf-submit">Add Task</button>
<button class="cf-cancel" id="cf-cancel">Cancel</button>
</div>
</div>`;
}
private renderBoard(): string {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="list"> Workspaces</button>
<button class="rapp-nav__back" data-back="list">\u2190 Workspaces</button>
<span class="rapp-nav__title">${this.esc(this.workspaceSlug)}</span>
<button class="rapp-nav__btn" id="create-task">+ New Task</button>
</div>
@ -245,6 +315,7 @@ class FolkWorkBoard extends HTMLElement {
<span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span>
</div>
${status === "TODO" ? this.renderCreateForm() : ""}
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
</div>
`;
@ -255,20 +326,23 @@ class FolkWorkBoard extends HTMLElement {
private renderTaskCard(task: any, currentStatus: string): string {
const otherStatuses = this.statuses.filter(s => s !== currentStatus);
const isEditing = this.editingTaskId === task.id;
const priorityBadge = (p: string) => {
const map: Record<string, string> = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" };
return map[p] ? `<span class="badge ${map[p]}">${this.esc(p.toLowerCase())}</span>` : "";
return map[p] ? `<span class="badge clickable ${map[p]}" data-cycle-priority="${task.id}">${this.esc(p.toLowerCase())}</span>` : "";
};
return `
<div class="task-card" draggable="true" data-task-id="${task.id}">
<div class="task-title">${this.esc(task.title)}</div>
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}">
${isEditing
? `<input class="task-title-input" data-edit-title="${task.id}" value="${this.esc(task.title)}">`
: `<div class="task-title" data-start-edit="${task.id}" style="cursor:text">${this.esc(task.title)}</div>`}
<div class="task-meta">
${priorityBadge(task.priority || "")}
${(task.labels || []).map((l: string) => `<span class="badge">${this.esc(l)}</span>`).join("")}
</div>
${task.assignee ? `<div style="font-size:11px;color:#888;margin-top:4px">${this.esc(task.assignee)}</div>` : ""}
<div class="move-btns">
${otherStatuses.map(s => `<button class="move-btn" data-move="${task.id}" data-to="${s}"> ${this.esc(s.replace(/_/g, " ").substring(0, 8))}</button>`).join("")}
${otherStatuses.map(s => `<button class="move-btn" data-move="${task.id}" data-to="${s}">\u2192 ${this.esc(s.replace(/_/g, " ").substring(0, 8))}</button>`).join("")}
</div>
</div>
`;
@ -276,7 +350,66 @@ class FolkWorkBoard extends HTMLElement {
private attachListeners() {
this.shadow.getElementById("create-ws")?.addEventListener("click", () => this.createWorkspace());
this.shadow.getElementById("create-task")?.addEventListener("click", () => this.createTask());
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm;
this.render();
if (this.showCreateForm) {
setTimeout(() => this.shadow.getElementById("cf-title")?.focus(), 0);
}
});
// Create form handlers
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
});
this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => {
this.showCreateForm = false; this.render();
});
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
}
});
// Inline title editing
this.shadow.querySelectorAll("[data-start-edit]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.editingTaskId = (el as HTMLElement).dataset.startEdit!;
this.render();
setTimeout(() => {
const input = this.shadow.querySelector(`[data-edit-title="${this.editingTaskId}"]`) as HTMLInputElement;
if (input) { input.focus(); input.select(); }
}, 0);
});
});
this.shadow.querySelectorAll("[data-edit-title]").forEach(el => {
const handler = () => {
const taskId = (el as HTMLElement).dataset.editTitle!;
const val = (el as HTMLInputElement).value;
if (val.trim()) this.updateTask(taskId, { title: val.trim() });
else { this.editingTaskId = null; this.render(); }
};
el.addEventListener("blur", handler);
el.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handler();
if ((e as KeyboardEvent).key === "Escape") { this.editingTaskId = null; this.render(); }
});
});
// Priority cycling
this.shadow.querySelectorAll("[data-cycle-priority]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
this.cyclePriority((el as HTMLElement).dataset.cyclePriority!);
});
});
this.shadow.querySelectorAll("[data-ws]").forEach(el => {
el.addEventListener("click", () => this.openBoard((el as HTMLElement).dataset.ws!));