/** * folk-task-request — Canvas shape representing a task card with skill slots. * Acts as a drop target for orbs dragged from folk-commitment-pool. * * On drop: POST connection to rTime API + dispatch notification event. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import { getModuleApiBase } from "../shared/url-helpers"; // Skill constants (mirrored from rtime/schemas) const SKILL_COLORS: Record = { facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', outreach: '#10b981', logistics: '#f59e0b', }; const SKILL_LABELS: Record = { facilitation: 'Facilitation', design: 'Design', tech: 'Tech', outreach: 'Outreach', logistics: 'Logistics', }; const styles = css` :host { background: #1e293b; border-radius: 10px; border: 1.5px solid #334155; overflow: hidden; } :host(.drag-highlight) { border-color: #14b8a6; box-shadow: 0 0 16px rgba(20, 184, 166, 0.3); } .header { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px; cursor: move; } .header .icon { font-size: 16px; } .task-name { flex: 1; font-size: 13px; font-weight: 600; color: #e2e8f0; background: none; border: none; outline: none; font-family: inherit; padding: 0; } .task-name:focus { border-bottom: 1px solid #14b8a6; } .task-name::placeholder { color: #64748b; } .desc { padding: 0 12px 8px; font-size: 11px; color: #94a3b8; line-height: 1.4; } .desc-input { width: 100%; background: none; border: none; outline: none; color: #94a3b8; font-size: 11px; font-family: inherit; padding: 0; resize: none; } .desc-input::placeholder { color: #475569; } .skill-slots { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 4px; } .slot { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 6px; background: #0f172a; border: 1.5px dashed #334155; font-size: 11px; color: #cbd5e1; transition: border-color 0.2s, background 0.2s; } .slot.highlight { border-color: #14b8a6; background: rgba(20, 184, 166, 0.08); } .slot.filled { border-style: solid; opacity: 0.7; } .slot-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .slot-label { flex: 1; } .slot-hours { font-weight: 600; font-size: 10px; color: #64748b; } .slot-assigned { font-size: 10px; color: #14b8a6; font-weight: 500; } `; interface SlotState { skill: string; hoursNeeded: number; assignedMember?: string; commitmentId?: string; } declare global { interface HTMLElementTagNameMap { "folk-task-request": FolkTaskRequest; } } export class FolkTaskRequest extends FolkShape { static override tagName = "folk-task-request"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } // ── Module-disabled gating ── static #enabledModules: Set | null = null; static #instances = new Set(); static setEnabledModules(ids: string[] | null) { FolkTaskRequest.#enabledModules = ids ? new Set(ids) : null; for (const inst of FolkTaskRequest.#instances) inst.#syncDisabledState(); } #isModuleDisabled(): boolean { const enabled = FolkTaskRequest.#enabledModules; if (!enabled) return false; return !enabled.has("rtime"); } #syncDisabledState() { if (!this.#wrapper) return; const disabled = this.#isModuleDisabled(); const wasDisabled = this.hasAttribute("data-module-disabled"); if (disabled && !wasDisabled) { this.#showDisabledOverlay(); } else if (!disabled && wasDisabled) { this.removeAttribute("data-module-disabled"); this.#wrapper.querySelector(".disabled-overlay")?.remove(); } } #showDisabledOverlay() { this.setAttribute("data-module-disabled", ""); let overlay = this.#wrapper?.querySelector(".disabled-overlay") as HTMLElement; if (!overlay && this.#wrapper) { overlay = document.createElement("div"); overlay.className = "disabled-overlay"; overlay.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;background:rgba(15,23,42,0.85);border-radius:inherit;z-index:10;color:#94a3b8;font-size:13px;"; overlay.innerHTML = '🔒rTime is disabled'; this.#wrapper.appendChild(overlay); } } #taskId = ""; #spaceSlug = "demo"; #taskName = "New Task"; #taskDescription = ""; #needs: Record = {}; #slots: SlotState[] = []; #wrapper!: HTMLElement; #slotsContainer!: HTMLElement; #nameInput!: HTMLInputElement; #descInput!: HTMLTextAreaElement; get taskId() { return this.#taskId; } set taskId(v: string) { this.#taskId = v; } get spaceSlug() { return this.#spaceSlug; } set spaceSlug(v: string) { this.#spaceSlug = v; } get taskName() { return this.#taskName; } set taskName(v: string) { this.#taskName = v; if (this.#nameInput) this.#nameInput.value = v; this.dispatchEvent(new CustomEvent("content-change")); } get taskDescription() { return this.#taskDescription; } set taskDescription(v: string) { this.#taskDescription = v; if (this.#descInput) this.#descInput.value = v; this.dispatchEvent(new CustomEvent("content-change")); } get needs() { return this.#needs; } set needs(v: Record) { this.#needs = v; this.#buildSlots(); this.dispatchEvent(new CustomEvent("content-change")); } override createRenderRoot() { const root = super.createRenderRoot(); this.#wrapper = document.createElement("div"); this.#wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; this.#wrapper.innerHTML = html`
📋
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(this.#wrapper); this.#nameInput = this.#wrapper.querySelector(".task-name") as HTMLInputElement; this.#descInput = this.#wrapper.querySelector(".desc-input") as HTMLTextAreaElement; this.#slotsContainer = this.#wrapper.querySelector(".skill-slots") as HTMLElement; this.#nameInput.value = this.#taskName; this.#descInput.value = this.#taskDescription; // Stop events from triggering shape move const stopProp = (e: Event) => e.stopPropagation(); this.#nameInput.addEventListener("click", stopProp); this.#descInput.addEventListener("click", stopProp); this.#nameInput.addEventListener("pointerdown", stopProp); this.#descInput.addEventListener("pointerdown", stopProp); this.#nameInput.addEventListener("change", () => { this.#taskName = this.#nameInput.value.trim() || "New Task"; this.dispatchEvent(new CustomEvent("content-change")); }); this.#nameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") this.#nameInput.blur(); }); this.#descInput.addEventListener("change", () => { this.#taskDescription = this.#descInput.value.trim(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#descInput.addEventListener("keydown", (e) => { e.stopPropagation(); }); this.#buildSlots(); // Listen for commitment drag events document.addEventListener("commitment-drag-start", this.#onDragStart as EventListener); document.addEventListener("commitment-drag-move", this.#onDragMove as EventListener); document.addEventListener("commitment-drag-end", this.#onDragEnd as EventListener); FolkTaskRequest.#instances.add(this); if (this.#isModuleDisabled()) this.#showDisabledOverlay(); return root; } disconnectedCallback() { FolkTaskRequest.#instances.delete(this); document.removeEventListener("commitment-drag-start", this.#onDragStart as EventListener); document.removeEventListener("commitment-drag-move", this.#onDragMove as EventListener); document.removeEventListener("commitment-drag-end", this.#onDragEnd as EventListener); } #buildSlots() { if (!this.#slotsContainer) return; // Build slots from needs this.#slots = Object.entries(this.#needs).map(([skill, hours]) => { // Preserve existing assignment if any const existing = this.#slots.find(s => s.skill === skill); return { skill, hoursNeeded: hours, assignedMember: existing?.assignedMember, commitmentId: existing?.commitmentId, }; }); this.#renderSlots(); } #renderSlots() { if (!this.#slotsContainer) return; this.#slotsContainer.innerHTML = ""; for (const slot of this.#slots) { const el = document.createElement("div"); el.className = "slot" + (slot.assignedMember ? " filled" : ""); el.dataset.skill = slot.skill; el.innerHTML = ` ${SKILL_LABELS[slot.skill] || slot.skill} ${slot.hoursNeeded}hr ${slot.assignedMember ? `${slot.assignedMember}` : ""} `; this.#slotsContainer.appendChild(el); } } // ── Drag handling ── #activeDragSkill: string | null = null; #onDragStart = (e: CustomEvent) => { this.#activeDragSkill = e.detail.skill; // Highlight matching unfilled slots const matchingSlots = this.#slots.filter(s => s.skill === this.#activeDragSkill && !s.assignedMember); if (matchingSlots.length > 0) { this.classList.add("drag-highlight"); this.#highlightSlots(this.#activeDragSkill!); } }; #onDragMove = (e: CustomEvent) => { // Check if pointer is over this shape const rect = this.getBoundingClientRect(); const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right && e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom; if (over && this.#activeDragSkill) { this.classList.add("drag-highlight"); this.#highlightSlots(this.#activeDragSkill); } else { this.classList.remove("drag-highlight"); this.#clearSlotHighlights(); } }; #onDragEnd = (e: CustomEvent) => { this.classList.remove("drag-highlight"); this.#clearSlotHighlights(); if (!this.#activeDragSkill) return; const skill = this.#activeDragSkill; this.#activeDragSkill = null; // Check if drop landed on this shape const rect = this.getBoundingClientRect(); const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right && e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom; if (!over) return; // Find matching unfilled slot const slot = this.#slots.find(s => s.skill === skill && !s.assignedMember); if (!slot) return; // Assign slot.assignedMember = e.detail.memberName; slot.commitmentId = e.detail.commitmentId; this.#renderSlots(); // POST connection this.#postConnection(e.detail.commitmentId, skill); this.dispatchEvent(new CustomEvent("commitment-assigned", { bubbles: true, detail: { taskId: this.#taskId, commitmentId: e.detail.commitmentId, skill, }, })); }; #highlightSlots(skill: string) { const slotEls = this.#slotsContainer.querySelectorAll(".slot"); slotEls.forEach(el => { const s = (el as HTMLElement).dataset.skill; if (s === skill && !el.classList.contains("filled")) { el.classList.add("highlight"); } }); } #clearSlotHighlights() { this.#slotsContainer.querySelectorAll(".slot.highlight").forEach(el => el.classList.remove("highlight")); } async #postConnection(commitmentId: string, skill: string) { if (!this.#taskId) return; try { // Get auth token from cookie or localStorage const token = document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("auth_token="))?.split("=")[1] || localStorage.getItem("auth_token") || ""; await fetch(`${getModuleApiBase("rtime")}/api/connections`, { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { "Authorization": `Bearer ${token}` } : {}), }, body: JSON.stringify({ fromCommitmentId: commitmentId, toTaskId: this.#taskId, skill, }), }); } catch { /* best-effort */ } } // ── Serialization ── override toJSON() { const slotsData: Record = {}; for (const s of this.#slots) { if (s.assignedMember) { slotsData[s.skill] = { member: s.assignedMember, commitmentId: s.commitmentId }; } } return { ...super.toJSON(), type: "folk-task-request", taskId: this.#taskId, spaceSlug: this.#spaceSlug, taskName: this.#taskName, taskDescription: this.#taskDescription, needs: this.#needs, assignments: slotsData, }; } static override fromData(data: Record): FolkTaskRequest { const shape = FolkShape.fromData(data) as FolkTaskRequest; if (data.taskId) shape.taskId = data.taskId; if (data.spaceSlug) shape.spaceSlug = data.spaceSlug; if (data.taskName) shape.taskName = data.taskName; if (data.taskDescription) shape.taskDescription = data.taskDescription; if (data.needs) shape.needs = data.needs; // Restore assignments if (data.assignments) { for (const [skill, info] of Object.entries(data.assignments as Record)) { const slot = shape.#slots.find(s => s.skill === skill); if (slot && info.member) { slot.assignedMember = info.member; slot.commitmentId = info.commitmentId; } } shape.#renderSlots(); } return shape; } override applyData(data: Record): void { super.applyData(data); if (data.taskId && data.taskId !== this.#taskId) this.taskId = data.taskId; if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug; if (data.taskName !== undefined && data.taskName !== this.#taskName) this.taskName = data.taskName; if (data.taskDescription !== undefined && data.taskDescription !== this.#taskDescription) this.taskDescription = data.taskDescription; if (data.needs && JSON.stringify(data.needs) !== JSON.stringify(this.#needs)) this.needs = data.needs; if (data.assignments) { for (const [skill, info] of Object.entries(data.assignments as Record)) { const slot = this.#slots.find(s => s.skill === skill); if (slot && info.member) { slot.assignedMember = info.member; slot.commitmentId = info.commitmentId; } } this.#renderSlots(); } } }