440 lines
13 KiB
TypeScript
440 lines
13 KiB
TypeScript
/**
|
|
* 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";
|
|
|
|
// Skill constants (mirrored from rtime/schemas)
|
|
const SKILL_COLORS: Record<string, string> = {
|
|
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
|
|
outreach: '#10b981', logistics: '#f59e0b',
|
|
};
|
|
const SKILL_LABELS: Record<string, string> = {
|
|
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;
|
|
}
|
|
|
|
#taskId = "";
|
|
#spaceSlug = "demo";
|
|
#taskName = "New Task";
|
|
#taskDescription = "";
|
|
#needs: Record<string, number> = {};
|
|
#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<string, number>) {
|
|
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`
|
|
<div class="header">
|
|
<span class="icon">📋</span>
|
|
<input class="task-name" type="text" placeholder="Task name..." />
|
|
</div>
|
|
<div class="desc">
|
|
<textarea class="desc-input" rows="2" placeholder="Description..."></textarea>
|
|
</div>
|
|
<div class="skill-slots"></div>
|
|
`;
|
|
|
|
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);
|
|
|
|
return root;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
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 = `
|
|
<span class="slot-dot" style="background:${SKILL_COLORS[slot.skill] || '#64748b'}"></span>
|
|
<span class="slot-label">${SKILL_LABELS[slot.skill] || slot.skill}</span>
|
|
<span class="slot-hours">${slot.hoursNeeded}hr</span>
|
|
${slot.assignedMember ? `<span class="slot-assigned">${slot.assignedMember}</span>` : ""}
|
|
`;
|
|
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(`/${this.#spaceSlug}/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<string, { member?: string; commitmentId?: string }> = {};
|
|
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<string, any>): 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<string, any>)) {
|
|
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<string, any>): 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<string, any>)) {
|
|
const slot = this.#slots.find(s => s.skill === skill);
|
|
if (slot && info.member) {
|
|
slot.assignedMember = info.member;
|
|
slot.commitmentId = info.commitmentId;
|
|
}
|
|
}
|
|
this.#renderSlots();
|
|
}
|
|
}
|
|
}
|