diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/cal/components/folk-calendar-view.ts index 69f1f0d..370010f 100644 --- a/modules/cal/components/folk-calendar-view.ts +++ b/modules/cal/components/folk-calendar-view.ts @@ -19,6 +19,8 @@ class FolkCalendarView extends HTMLElement { private selectedEvent: any = null; private expandedDay = ""; // mobile day-detail panel private error = ""; + private filteredSources = new Set(); + 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 { ${this.sources.length > 0 ? `
- ${this.sources.map(s => `${this.esc(s.name)}`).join("")} + ${this.sources.map(s => `${this.esc(s.name)}`).join("")}
` : ""} ${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", () => { diff --git a/modules/maps/components/folk-map-viewer.ts b/modules/maps/components/folk-map-viewer.ts index 55db9bd..8df7fc9 100644 --- a/modules/maps/components/folk-map-viewer.ts +++ b/modules/maps/components/folk-map-viewer.ts @@ -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 = { "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 ` + + + + + You + `; + })() : ""; + 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 ` - + @@ -182,6 +212,9 @@ class FolkMapViewer extends HTMLElement {
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
+ `; } @@ -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 { ${this.providers.length} providers online + +
@@ -316,6 +368,9 @@ class FolkMapViewer extends HTMLElement { ${pins} + + + ${userPin}
@@ -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"); diff --git a/modules/network/components/folk-graph-viewer.ts b/modules/network/components/folk-graph-viewer.ts index 2889487..2931a6c 100644 --- a/modules/network/components/folk-graph-viewer.ts +++ b/modules/network/components/folk-graph-viewer.ts @@ -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 { + const pos: Record = {}; + + // Initial positions: orgs in triangle, people around their org + const orgCenters: Record = { + "org-1": { x: W / 2, y: 120 }, + "org-2": { x: 160, y: 380 }, + "org-3": { x: W - 160, y: 380 }, + }; + const orgNameToId: Record = { + "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 = {}; + 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 = {}; + 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(); + 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 ` +
+
+ ${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"} +
+
${this.esc(n.name)}
+
${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
+
+ +
+ ${n.description ? `

${this.esc(n.description)}

` : ""} + ${trust >= 0 ? `
Trust Score${trust}
` : ""} + ${connected.length > 0 ? ` +
Connected (${connected.length})
+ ${connected.map(c => `
${this.esc(c.name)}${this.esc(c.role || c.type)}
`).join("")} + ` : ""} +
`; + } + 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 = { - "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 = {}; - - // 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 = { - "Commons DAO": "org-1", - "Mycelial Lab": "org-2", - "Regenerative Fund": "org-3", - }; - - const peopleByOrg: Record = {}; - 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 = { - "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 ``; + return ``; }).join(""); // Render edges @@ -237,7 +314,6 @@ class FolkGraphViewer extends HTMLElement { if (edge.type === "work_at") { edgesSvg.push(``); } 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(``); @@ -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 = `${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}`; } + // Trust score badge for people + const trust = !isOrg ? this.getTrustScore(node.id) : -1; + const trustBadge = trust >= 0 ? ` + + ${trust} + ` : ""; + return ` - - ${isOrg ? `${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}` : ""} - ${label} - ${sublabel} + + ${isSelected ? `` : ""} + + ${isOrg ? `${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}` : ""} + ${label} + ${sublabel} + ${trustBadge} + `; }).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 { `} + ${this.renderDetailPanel()} +
People
Organizations
@@ -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 { diff --git a/modules/work/components/folk-work-board.ts b/modules/work/components/folk-work-board.ts index 2114b40..477cdf1 100644 --- a/modules/work/components/folk-work-board.ts +++ b/modules/work/components/folk-work-board.ts @@ -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) { + 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 ` +
+ + + +
+ + +
+
`; + } + private renderBoard(): string { return `
- + ${this.esc(this.workspaceSlug)}
@@ -245,6 +315,7 @@ class FolkWorkBoard extends HTMLElement { ${this.esc(status.replace(/_/g, " "))} ${columnTasks.length}
+ ${status === "TODO" ? this.renderCreateForm() : ""} ${columnTasks.map(t => this.renderTaskCard(t, status)).join("")} `; @@ -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 = { URGENT: "badge-urgent", HIGH: "badge-high", MEDIUM: "badge-medium", LOW: "badge-low" }; - return map[p] ? `${this.esc(p.toLowerCase())}` : ""; + return map[p] ? `${this.esc(p.toLowerCase())}` : ""; }; return ` -
-
${this.esc(task.title)}
+
+ ${isEditing + ? `` + : `
${this.esc(task.title)}
`}
${priorityBadge(task.priority || "")} ${(task.labels || []).map((l: string) => `${this.esc(l)}`).join("")}
${task.assignee ? `
${this.esc(task.assignee)}
` : ""}
- ${otherStatuses.map(s => ``).join("")} + ${otherStatuses.map(s => ``).join("")}
`; @@ -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!));