Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m15s
Details
CI/CD / deploy (push) Successful in 2m15s
Details
This commit is contained in:
commit
8f9aa98995
|
|
@ -408,6 +408,54 @@ registry.push(
|
|||
},
|
||||
);
|
||||
|
||||
// ── rTime Commitment/Task Tools ──
|
||||
registry.push(
|
||||
{
|
||||
declaration: {
|
||||
name: "create_commitment_pool",
|
||||
description: "Create a commitment pool basket on the canvas. Shows floating orbs representing community time pledges that can be dragged onto task cards.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
spaceSlug: { type: "string", description: "The space slug to load commitments from" },
|
||||
},
|
||||
required: ["spaceSlug"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-commitment-pool",
|
||||
buildProps: (args) => ({
|
||||
spaceSlug: args.spaceSlug || "demo",
|
||||
}),
|
||||
actionLabel: (args) => `Created commitment pool for ${args.spaceSlug || "demo"}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_task_request",
|
||||
description: "Create a task request card on the canvas with skill slots. Commitments can be dragged from the pool onto matching skill slots.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskName: { type: "string", description: "Name of the task" },
|
||||
spaceSlug: { type: "string", description: "The space slug this task belongs to" },
|
||||
needsJson: { type: "string", description: 'JSON object of skill needs, e.g. {"facilitation":3,"design":2}' },
|
||||
},
|
||||
required: ["taskName"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-task-request",
|
||||
buildProps: (args) => {
|
||||
let needs: Record<string, number> = {};
|
||||
try { needs = JSON.parse(args.needsJson || "{}"); } catch { needs = {}; }
|
||||
return {
|
||||
taskName: args.taskName,
|
||||
spaceSlug: args.spaceSlug || "demo",
|
||||
needs,
|
||||
};
|
||||
},
|
||||
actionLabel: (args) => `Created task request: ${args.taskName}`,
|
||||
},
|
||||
);
|
||||
|
||||
// ── Design Agent Tool ──
|
||||
registry.push({
|
||||
declaration: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,488 @@
|
|||
/**
|
||||
* folk-commitment-pool — Canvas shape rendering a physics-based basket of
|
||||
* commitment orbs. Orbs can be dragged out onto folk-task-request shapes.
|
||||
*
|
||||
* Extracted from modules/rtime/components/folk-timebank-app.ts (pool view).
|
||||
*/
|
||||
|
||||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface PoolCommitment {
|
||||
id: string;
|
||||
memberName: string;
|
||||
hours: number;
|
||||
skill: string;
|
||||
desc: string;
|
||||
ownerDid?: string;
|
||||
}
|
||||
|
||||
// ── Orb class ──
|
||||
|
||||
class Orb {
|
||||
c: PoolCommitment;
|
||||
baseRadius: number;
|
||||
radius: number;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
hoverT = 0;
|
||||
phase: number;
|
||||
opacity = 0;
|
||||
color: string;
|
||||
|
||||
constructor(c: PoolCommitment, cx: number, cy: number, r: number) {
|
||||
this.c = c;
|
||||
this.baseRadius = 18 + c.hours * 9;
|
||||
this.radius = this.baseRadius;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const d = Math.random() * (r - this.baseRadius - 10);
|
||||
this.x = cx + Math.cos(a) * d;
|
||||
this.y = cy + Math.sin(a) * d;
|
||||
this.vx = (Math.random() - 0.5) * 0.4;
|
||||
this.vy = (Math.random() - 0.5) * 0.4;
|
||||
this.phase = Math.random() * Math.PI * 2;
|
||||
this.color = SKILL_COLORS[c.skill] || '#8b5cf6';
|
||||
}
|
||||
|
||||
update(hovered: Orb | null, cx: number, cy: number, r: number) {
|
||||
this.phase += 0.008;
|
||||
this.vx += Math.sin(this.phase) * 0.003;
|
||||
this.vy += Math.cos(this.phase * 0.73 + 1) * 0.003;
|
||||
this.vx *= 0.996;
|
||||
this.vy *= 0.996;
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
|
||||
const dx = this.x - cx, dy = this.y - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const maxDist = r - this.radius - 3;
|
||||
if (dist > maxDist && dist > 0.1) {
|
||||
const nx = dx / dist, ny = dy / dist;
|
||||
this.x = cx + nx * maxDist;
|
||||
this.y = cy + ny * maxDist;
|
||||
const dot = this.vx * nx + this.vy * ny;
|
||||
this.vx -= 1.3 * dot * nx;
|
||||
this.vy -= 1.3 * dot * ny;
|
||||
this.vx *= 0.6; this.vy *= 0.6;
|
||||
}
|
||||
|
||||
const isH = hovered === this;
|
||||
this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12;
|
||||
this.radius = this.baseRadius + this.hoverT * 5;
|
||||
if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025);
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D) {
|
||||
if (this.opacity < 0.01) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.opacity;
|
||||
if (this.hoverT > 0.05) { ctx.shadowColor = this.color; ctx.shadowBlur = 24 * this.hoverT; }
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = this.color + '15';
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius * 0.82, 0, Math.PI * 2);
|
||||
const g = ctx.createRadialGradient(
|
||||
this.x - this.radius * 0.15, this.y - this.radius * 0.15, 0,
|
||||
this.x, this.y, this.radius * 0.82
|
||||
);
|
||||
g.addColorStop(0, this.color + 'dd');
|
||||
g.addColorStop(1, this.color);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
if (this.hoverT > 0.05) {
|
||||
ctx.globalAlpha = this.opacity * 0.08 * this.hoverT;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(this.x, this.y + this.radius + 4, this.radius * 0.6, 4, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = this.opacity;
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const fs = Math.max(10, this.radius * 0.3);
|
||||
ctx.font = '600 ' + fs + 'px -apple-system, sans-serif';
|
||||
ctx.fillText(this.c.memberName.split(' ')[0], this.x, this.y - fs * 0.35);
|
||||
ctx.font = '500 ' + (fs * 0.78) + 'px -apple-system, sans-serif';
|
||||
ctx.fillStyle = '#ffffffcc';
|
||||
ctx.fillText(this.c.hours + 'hr \u00b7 ' + (SKILL_LABELS[this.c.skill] || this.c.skill), this.x, this.y + fs * 0.55);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
contains(px: number, py: number) {
|
||||
const dx = px - this.x, dy = py - this.y;
|
||||
return dx * dx + dy * dy < this.radius * this.radius;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ripple class ──
|
||||
|
||||
class Ripple {
|
||||
x: number; y: number; r = 8; o = 0.5; color: string;
|
||||
constructor(x: number, y: number, color: string) { this.x = x; this.y = y; this.color = color; }
|
||||
update() { this.r += 1.5; this.o -= 0.008; return this.o > 0; }
|
||||
draw(ctx: CanvasRenderingContext2D) {
|
||||
ctx.save(); ctx.globalAlpha = this.o;
|
||||
ctx.strokeStyle = this.color; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.empty-msg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-commitment-pool": FolkCommitmentPool;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkCommitmentPool extends FolkShape {
|
||||
static override tagName = "folk-commitment-pool";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#spaceSlug = "demo";
|
||||
#canvas!: HTMLCanvasElement;
|
||||
#ctx!: CanvasRenderingContext2D;
|
||||
#orbs: Orb[] = [];
|
||||
#ripples: Ripple[] = [];
|
||||
#animFrame = 0;
|
||||
#hoveredOrb: Orb | null = null;
|
||||
#draggingOrb: Orb | null = null;
|
||||
#ghost: HTMLDivElement | null = null;
|
||||
#dpr = 1;
|
||||
#wrapper!: HTMLElement;
|
||||
|
||||
get spaceSlug() { return this.#spaceSlug; }
|
||||
set spaceSlug(v: string) {
|
||||
this.#spaceSlug = v;
|
||||
this.#fetchCommitments();
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
this.#wrapper = document.createElement("div");
|
||||
this.#wrapper.style.cssText = "width:100%;height:100%;position:relative;";
|
||||
this.#wrapper.innerHTML = html`<canvas></canvas><div class="empty-msg" style="display:none">No commitments yet</div>`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const container = slot?.parentElement as HTMLElement;
|
||||
if (container) container.replaceWith(this.#wrapper);
|
||||
|
||||
this.#canvas = this.#wrapper.querySelector("canvas")!;
|
||||
this.#ctx = this.#canvas.getContext("2d")!;
|
||||
|
||||
this.#canvas.addEventListener("pointermove", this.#onPointerMove);
|
||||
this.#canvas.addEventListener("pointerdown", this.#onPointerDown);
|
||||
this.#canvas.addEventListener("pointerleave", () => { this.#hoveredOrb = null; });
|
||||
|
||||
this.#fetchCommitments();
|
||||
this.#startAnimation();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.#animFrame) cancelAnimationFrame(this.#animFrame);
|
||||
this.#animFrame = 0;
|
||||
this.#removeGhost();
|
||||
}
|
||||
|
||||
// ── Data fetching ──
|
||||
|
||||
async #fetchCommitments() {
|
||||
try {
|
||||
const resp = await fetch(`/${this.#spaceSlug}/rtime/api/commitments`);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const commitments: PoolCommitment[] = data.commitments || [];
|
||||
this.#rebuildOrbs(commitments);
|
||||
} catch { /* network error, keep existing orbs */ }
|
||||
}
|
||||
|
||||
#rebuildOrbs(commitments: PoolCommitment[]) {
|
||||
const w = this.width || 500;
|
||||
const h = this.height || 500;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(cx, cy) * 0.85;
|
||||
|
||||
const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement;
|
||||
if (emptyMsg) emptyMsg.style.display = commitments.length === 0 ? "flex" : "none";
|
||||
|
||||
// Preserve existing orbs by commitment ID
|
||||
const existing = new Map(this.#orbs.map(o => [o.c.id, o]));
|
||||
this.#orbs = commitments.map(c => existing.get(c.id) || new Orb(c, cx, cy, r));
|
||||
}
|
||||
|
||||
// ── Canvas coord helpers ──
|
||||
|
||||
#canvasCoords(e: PointerEvent): { x: number; y: number } {
|
||||
const rect = this.#canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) * (this.#canvas.width / this.#dpr / rect.width),
|
||||
y: (e.clientY - rect.top) * (this.#canvas.height / this.#dpr / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
#findOrbAt(x: number, y: number): Orb | null {
|
||||
for (let i = this.#orbs.length - 1; i >= 0; i--) {
|
||||
if (this.#orbs[i].contains(x, y)) return this.#orbs[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Pointer handlers ──
|
||||
|
||||
#onPointerMove = (e: PointerEvent) => {
|
||||
if (this.#draggingOrb) return; // drag is handled on document
|
||||
const { x, y } = this.#canvasCoords(e);
|
||||
const orb = this.#findOrbAt(x, y);
|
||||
this.#hoveredOrb = orb;
|
||||
this.#canvas.style.cursor = orb ? "grab" : "default";
|
||||
};
|
||||
|
||||
#onPointerDown = (e: PointerEvent) => {
|
||||
const { x, y } = this.#canvasCoords(e);
|
||||
const orb = this.#findOrbAt(x, y);
|
||||
if (!orb) return;
|
||||
|
||||
// Prevent FolkShape from starting a shape-move
|
||||
e.stopPropagation();
|
||||
|
||||
this.#draggingOrb = orb;
|
||||
this.#ripples.push(new Ripple(orb.x, orb.y, orb.color));
|
||||
|
||||
// Create ghost element on document.body
|
||||
this.#ghost = document.createElement("div");
|
||||
this.#ghost.style.cssText = `
|
||||
position: fixed; z-index: 99999; pointer-events: none;
|
||||
width: ${orb.radius * 2}px; height: ${orb.radius * 2}px;
|
||||
border-radius: 50%;
|
||||
background: ${orb.color};
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 20px ${orb.color}80;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: 11px; font-weight: 600;
|
||||
font-family: -apple-system, sans-serif;
|
||||
transform: translate(-50%, -50%);
|
||||
left: ${e.clientX}px; top: ${e.clientY}px;
|
||||
`;
|
||||
this.#ghost.textContent = `${orb.c.hours}hr ${SKILL_LABELS[orb.c.skill] || orb.c.skill}`;
|
||||
document.body.appendChild(this.#ghost);
|
||||
|
||||
// Dispatch drag start
|
||||
document.dispatchEvent(new CustomEvent("commitment-drag-start", {
|
||||
detail: {
|
||||
commitmentId: orb.c.id,
|
||||
skill: orb.c.skill,
|
||||
hours: orb.c.hours,
|
||||
memberName: orb.c.memberName,
|
||||
ownerDid: orb.c.ownerDid,
|
||||
},
|
||||
}));
|
||||
|
||||
document.addEventListener("pointermove", this.#onDocPointerMove);
|
||||
document.addEventListener("pointerup", this.#onDocPointerUp);
|
||||
};
|
||||
|
||||
#onDocPointerMove = (e: PointerEvent) => {
|
||||
if (this.#ghost) {
|
||||
this.#ghost.style.left = `${e.clientX}px`;
|
||||
this.#ghost.style.top = `${e.clientY}px`;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent("commitment-drag-move", {
|
||||
detail: { clientX: e.clientX, clientY: e.clientY },
|
||||
}));
|
||||
};
|
||||
|
||||
#onDocPointerUp = (e: PointerEvent) => {
|
||||
document.removeEventListener("pointermove", this.#onDocPointerMove);
|
||||
document.removeEventListener("pointerup", this.#onDocPointerUp);
|
||||
|
||||
document.dispatchEvent(new CustomEvent("commitment-drag-end", {
|
||||
detail: {
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
commitmentId: this.#draggingOrb?.c.id,
|
||||
skill: this.#draggingOrb?.c.skill,
|
||||
hours: this.#draggingOrb?.c.hours,
|
||||
memberName: this.#draggingOrb?.c.memberName,
|
||||
ownerDid: this.#draggingOrb?.c.ownerDid,
|
||||
},
|
||||
}));
|
||||
|
||||
this.#draggingOrb = null;
|
||||
this.#removeGhost();
|
||||
};
|
||||
|
||||
#removeGhost() {
|
||||
if (this.#ghost) {
|
||||
this.#ghost.remove();
|
||||
this.#ghost = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animation loop ──
|
||||
|
||||
#startAnimation() {
|
||||
const frame = () => {
|
||||
this.#animFrame = requestAnimationFrame(frame);
|
||||
this.#poolFrame();
|
||||
};
|
||||
this.#animFrame = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
#poolFrame() {
|
||||
const w = this.width || 500;
|
||||
const h = this.height || 500;
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(cx, cy) * 0.85;
|
||||
|
||||
// Resize canvas if needed
|
||||
this.#dpr = window.devicePixelRatio || 1;
|
||||
const cw = Math.round(w * this.#dpr);
|
||||
const ch = Math.round(h * this.#dpr);
|
||||
if (this.#canvas.width !== cw || this.#canvas.height !== ch) {
|
||||
this.#canvas.width = cw;
|
||||
this.#canvas.height = ch;
|
||||
}
|
||||
|
||||
const ctx = this.#ctx;
|
||||
ctx.setTransform(this.#dpr, 0, 0, this.#dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Draw basket
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#334155';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([8, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Basket label
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.font = '500 12px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Commitment Pool', cx, cy - r + 20);
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '400 11px -apple-system, sans-serif';
|
||||
ctx.fillText(`${this.#orbs.length} pledges`, cx, cy - r + 36);
|
||||
ctx.restore();
|
||||
|
||||
// Update & draw ripples
|
||||
this.#ripples = this.#ripples.filter(rp => { rp.update(); rp.draw(ctx); return rp.o > 0; });
|
||||
|
||||
// Orb-to-orb collision
|
||||
for (let i = 0; i < this.#orbs.length; i++) {
|
||||
for (let j = i + 1; j < this.#orbs.length; j++) {
|
||||
const a = this.#orbs[i], b = this.#orbs[j];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const minD = a.radius + b.radius + 2;
|
||||
if (dist < minD && dist > 0.01) {
|
||||
const nx = dx / dist, ny = dy / dist;
|
||||
const overlap = (minD - dist) / 2;
|
||||
a.x -= nx * overlap; a.y -= ny * overlap;
|
||||
b.x += nx * overlap; b.y += ny * overlap;
|
||||
const relV = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
|
||||
if (relV > 0) {
|
||||
a.vx -= relV * nx * 0.5; a.vy -= relV * ny * 0.5;
|
||||
b.vx += relV * nx * 0.5; b.vy += relV * ny * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update & draw orbs
|
||||
for (const orb of this.#orbs) {
|
||||
if (orb === this.#draggingOrb) {
|
||||
orb.opacity = 0.3;
|
||||
orb.draw(ctx);
|
||||
continue;
|
||||
}
|
||||
orb.update(this.#hoveredOrb, cx, cy, r);
|
||||
orb.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serialization ──
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-commitment-pool",
|
||||
spaceSlug: this.#spaceSlug,
|
||||
};
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkCommitmentPool {
|
||||
const shape = FolkShape.fromData(data) as FolkCommitmentPool;
|
||||
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
|
||||
return shape;
|
||||
}
|
||||
|
||||
override applyData(data: Record<string, any>): void {
|
||||
super.applyData(data);
|
||||
if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ export * from "./folk-booking";
|
|||
export * from "./folk-token-mint";
|
||||
export * from "./folk-token-ledger";
|
||||
|
||||
// rTime Canvas Shapes
|
||||
export * from "./folk-commitment-pool";
|
||||
export * from "./folk-task-request";
|
||||
|
||||
// Transaction Builder
|
||||
export * from "./folk-transaction-builder";
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { getModuleInfoList } from "../../shared/module";
|
|||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyToken, extractToken } from "../../server/auth";
|
||||
import { renderLanding } from "./landing";
|
||||
import { notify } from '../../server/notification-service';
|
||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
import {
|
||||
commitmentsSchema, tasksSchema,
|
||||
|
|
@ -157,7 +158,7 @@ routes.post("/api/commitments", async (c) => {
|
|||
ensureCommitmentsDoc(space);
|
||||
|
||||
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'add commitment', (d) => {
|
||||
d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now } as any;
|
||||
d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now, ownerDid: (claims.did as string) || '' } as any;
|
||||
});
|
||||
|
||||
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
|
||||
|
|
@ -248,7 +249,8 @@ routes.put("/api/tasks/:id", async (c) => {
|
|||
routes.post("/api/connections", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
let claims;
|
||||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const space = c.req.param("space") || "demo";
|
||||
const body = await c.req.json();
|
||||
|
|
@ -257,13 +259,32 @@ routes.post("/api/connections", async (c) => {
|
|||
|
||||
const id = newId();
|
||||
ensureTasksDoc(space);
|
||||
ensureCommitmentsDoc(space);
|
||||
|
||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
|
||||
d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any;
|
||||
});
|
||||
|
||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
return c.json(doc.connections[id], 201);
|
||||
// Notify commitment owner that their time was requested
|
||||
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
||||
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
const commitment = cDoc?.items?.[fromCommitmentId];
|
||||
const task = tDoc.tasks?.[toTaskId];
|
||||
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
|
||||
notify({
|
||||
userDid: commitment.ownerDid,
|
||||
category: 'module',
|
||||
eventType: 'commitment_requested',
|
||||
title: `Your ${commitment.hours}hr ${skill} commitment was requested`,
|
||||
body: task ? `Task: ${task.name}` : undefined,
|
||||
spaceSlug: space,
|
||||
moduleId: 'rtime',
|
||||
actionUrl: `/${space}/rtime`,
|
||||
actorDid: claims.did as string | undefined,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return c.json(tDoc.connections[id], 201);
|
||||
});
|
||||
|
||||
routes.delete("/api/connections/:id", async (c) => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface Commitment {
|
|||
createdAt: number;
|
||||
intentId?: string; // links commitment to its intent
|
||||
status?: 'active' | 'matched' | 'settled' | 'withdrawn';
|
||||
ownerDid?: string; // DID of the commitment creator (for notifications)
|
||||
}
|
||||
|
||||
// ── Task / Connection / ExecState ──
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ export type NotificationEventType =
|
|||
// Social
|
||||
| 'mention' | 'ping_user'
|
||||
// Delegation
|
||||
| 'delegation_received' | 'delegation_revoked' | 'delegation_expired';
|
||||
| 'delegation_received' | 'delegation_revoked' | 'delegation_expired'
|
||||
// Commitment (rTime)
|
||||
| 'commitment_requested' | 'commitment_accepted' | 'commitment_declined';
|
||||
|
||||
export interface NotifyOptions {
|
||||
userDid: string;
|
||||
|
|
|
|||
|
|
@ -1599,7 +1599,8 @@
|
|||
folk-booking, folk-token-mint, folk-token-ledger,
|
||||
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email) {
|
||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email,
|
||||
folk-commitment-pool, folk-task-request) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
|
|
@ -1611,7 +1612,8 @@
|
|||
folk-booking, folk-token-mint, folk-token-ledger,
|
||||
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email):hover {
|
||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email,
|
||||
folk-commitment-pool, folk-task-request):hover {
|
||||
outline: 2px dashed #3b82f6;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
|
@ -2095,6 +2097,8 @@
|
|||
<button id="new-spider-3d" title="3D Spider">📊 3D Spider</button>
|
||||
<button id="new-conviction" title="Conviction">⏳ Conviction</button>
|
||||
<button id="new-token" title="Token">🪙 Token</button>
|
||||
<button id="new-commitment-pool" title="Commitment Pool" data-requires-module="rtime">🧺 Commitments</button>
|
||||
<button id="new-task-request" title="Task Request" data-requires-module="rtime">📋 Task Request</button>
|
||||
<button id="new-multisig-email" title="Multi-Sig Email">✉️ Multi-Sig Email</button>
|
||||
<button id="embed-vote" title="Embed rVote" data-requires-module="rvote">🗳️ rVote</button>
|
||||
</div>
|
||||
|
|
@ -2453,6 +2457,8 @@
|
|||
FolkBooking,
|
||||
FolkTokenMint,
|
||||
FolkTokenLedger,
|
||||
FolkCommitmentPool,
|
||||
FolkTaskRequest,
|
||||
FolkTransactionBuilder,
|
||||
FolkChoiceVote,
|
||||
FolkChoiceRank,
|
||||
|
|
@ -2705,6 +2711,8 @@
|
|||
FolkBooking.define();
|
||||
FolkTokenMint.define();
|
||||
FolkTokenLedger.define();
|
||||
FolkCommitmentPool.define();
|
||||
FolkTaskRequest.define();
|
||||
FolkTransactionBuilder.define();
|
||||
FolkChoiceVote.define();
|
||||
FolkChoiceRank.define();
|
||||
|
|
@ -2758,6 +2766,8 @@
|
|||
shapeRegistry.register("folk-booking", FolkBooking);
|
||||
shapeRegistry.register("folk-token-mint", FolkTokenMint);
|
||||
shapeRegistry.register("folk-token-ledger", FolkTokenLedger);
|
||||
shapeRegistry.register("folk-commitment-pool", FolkCommitmentPool);
|
||||
shapeRegistry.register("folk-task-request", FolkTaskRequest);
|
||||
shapeRegistry.register("folk-transaction-builder", FolkTransactionBuilder);
|
||||
shapeRegistry.register("folk-choice-vote", FolkChoiceVote);
|
||||
shapeRegistry.register("folk-choice-rank", FolkChoiceRank);
|
||||
|
|
@ -3137,6 +3147,7 @@
|
|||
"folk-workflow-block", "folk-itinerary", "folk-destination",
|
||||
"folk-budget", "folk-packing-list", "folk-booking",
|
||||
"folk-token-mint", "folk-token-ledger",
|
||||
"folk-commitment-pool", "folk-task-request",
|
||||
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
|
||||
"folk-spider-3d", "folk-choice-conviction", "folk-social-post",
|
||||
"folk-splat", "folk-blender", "folk-drawfast",
|
||||
|
|
@ -3946,6 +3957,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
"folk-booking": { width: 300, height: 240 },
|
||||
"folk-token-mint": { width: 320, height: 280 },
|
||||
"folk-token-ledger": { width: 380, height: 400 },
|
||||
"folk-commitment-pool": { width: 500, height: 500 },
|
||||
"folk-task-request": { width: 300, height: 280 },
|
||||
"folk-choice-vote": { width: 360, height: 400 },
|
||||
"folk-choice-rank": { width: 380, height: 480 },
|
||||
"folk-choice-spider": { width: 440, height: 540 },
|
||||
|
|
@ -4630,6 +4643,22 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
});
|
||||
});
|
||||
|
||||
// Commitment Pool creation
|
||||
document.getElementById("new-commitment-pool").addEventListener("click", () => {
|
||||
setPendingTool("folk-commitment-pool", {
|
||||
spaceSlug: communitySlug,
|
||||
});
|
||||
});
|
||||
|
||||
// Task Request creation
|
||||
document.getElementById("new-task-request").addEventListener("click", () => {
|
||||
setPendingTool("folk-task-request", {
|
||||
spaceSlug: communitySlug,
|
||||
taskName: "New Task",
|
||||
needs: { facilitation: 2, design: 2, tech: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
// Decision/choice components
|
||||
document.getElementById("new-choice-vote").addEventListener("click", () => {
|
||||
setPendingTool("folk-choice-vote", {
|
||||
|
|
@ -6096,6 +6125,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
"folk-workflow-block": "⚙️", "folk-itinerary": "🗓️", "folk-destination": "📍",
|
||||
"folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️",
|
||||
"folk-token-mint": "🪙", "folk-token-ledger": "📒",
|
||||
"folk-commitment-pool": "🧺", "folk-task-request": "📋",
|
||||
"folk-choice-vote": "☑", "folk-choice-rank": "📊",
|
||||
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
|
||||
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
|
||||
|
|
|
|||
Loading…
Reference in New Issue