From 82663be934cd774c8dc7135e9e41b0246884c092 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 15:17:41 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20rApp=20=E2=86=92=20applet=20mapping?= =?UTF-8?q?=20for=20canvas=20shape=20parity=20(Phase=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog of all rApp mini-canvas flow-node types proposed for promotion to AppletDefinition so the main rSpace canvas can render them. 53 proposed applets across rsocials (planner + workflow), rminders, rflows, rgov, rtime, rnetwork, rchoices. Review doc β€” no code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/RAPP-APPLET-MAPPING.md | 210 ++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/RAPP-APPLET-MAPPING.md diff --git a/docs/RAPP-APPLET-MAPPING.md b/docs/RAPP-APPLET-MAPPING.md new file mode 100644 index 00000000..481deb75 --- /dev/null +++ b/docs/RAPP-APPLET-MAPPING.md @@ -0,0 +1,210 @@ +# rApp β†’ Applet Mapping (Phase B) + +> **Purpose.** Every flow-node type inside an rApp mini-canvas should exist as an `AppletDefinition` so the main rSpace canvas can render it as a `folk-applet` with typed I/O ports. This is the asymmetric B-model: *rSpace understands every rApp shape; rApps don't need to understand every rSpace shape.* +> +> **Status legend** (for each proposed applet): +> - βœ… = already registered in `modules//applets.ts` +> - πŸ†• = proposed new applet β€” user approves, I implement +> - 🟑 = covered by existing custom element (`folk-gov-*`) β€” should wrap into AppletDefinition for registry discoverability + template support +> +> **Review process.** Walk each section, mark βœ“/βœ—/edit on proposed rows (id, label, icon, color, ports). I'll implement the βœ“ set per-rApp in follow-up PRs. + +--- + +## Currently-registered applets (27 total) + +For reference only β€” these already work on the main canvas. + +| rApp | Applet ID | Label | Icon | +|---|---|---|---| +| rbooks | `book-card` | Book Card | πŸ“š | +| rcal | `next-event` | Next Event | πŸ“… | +| rcal | `timeline-view` | Timeline | πŸ•’ | +| rchats | `unread-count` | Unread Count | πŸ’¬ | +| rchoices | `vote-tally` | Vote Tally | πŸ—³οΈ | +| rdata | `analytics-card` | Analytics Card | πŸ“Š | +| rdocs | `doc-summary` | Doc Summary | πŸ“„ | +| rexchange | `rate-card` | Rate Card | πŸ’± | +| rexchange | `trade-status` | Trade Status | πŸ”„ | +| rflows | `flow-summary` | Flow Summary | πŸ’§ | +| rgov | `signoff-gate` | Signoff Gate | βš–οΈ | +| rgov | `governance-circuit` | Governance Circuit | πŸ”€ | +| rinbox | `thread-feed` | Thread Feed | πŸ“¬ | +| rmaps | `location-pin` | Location Pin | πŸ“ | +| rmaps | `route-summary` | Route Summary | πŸ—ΊοΈ | +| rnetwork | `contact-card` | Contact Card | πŸ‘€ | +| rnotes | `vault-note` | Vault Note | πŸ—’οΈ | +| rphotos | `album-card` | Album Card | πŸ–ΌοΈ | +| rsocials | `post-draft` | Post Draft | ✏️ | +| rtasks | `task-counter` | Task Counter | πŸ“‹ | +| rtasks | `due-today` | Due Today | ⏰ | +| rtasks | `resource-coverage` | Resource Coverage | 🎯 | +| rtime | `commitment-meter` | Commitment Meter | ⏳ | +| rtime | `weaving-coverage` | Weaving Coverage | 🧢 | +| rwallet | `balance-card` | Balance Card | πŸ’° | +| rwallet | `token-balance` | Token Balance | πŸͺ™ | + +--- + +## Proposed new applets (51 total across 7 rApps) + +Port type legend: `trigger` (flow control pulse), `data` (payload), `text`, `number`, `boolean`, `json`, `date`. Colors follow existing rApp accents (`modules//applets.ts`). + +--- + +### rsocials β€” Campaign Planner (source: `modules/rsocials/schemas.ts`, `CampaignNodeType`) + +Planner nodes represent the building blocks of a campaign *structure* (not runtime flow). + +| # | id | label | icon | color | input ports | output ports | compact preview | +|---|---|---|---|---|---|---|---| +| 1 | πŸ†• `planner-post` | Campaign Post | πŸ“ | #db2777 | `content:text` | `post:json` | platform + char-count + preview | +| 2 | πŸ†• `planner-thread` | Thread | 🧡 | #db2777 | `tweets:json` | `thread:json` | tweet count + platform | +| 3 | πŸ†• `planner-platform` | Platform Channel | πŸ“‘ | #db2777 | `post:json` | `scheduled:json` | platform logo + cadence | +| 4 | πŸ†• `planner-audience` | Audience | πŸ‘₯ | #db2777 | `segments:json` | `target:json` | segment count + size | +| 5 | πŸ†• `planner-phase` | Campaign Phase | πŸ—“οΈ | #db2777 | `trigger:trigger` | `next:trigger` | phase name + date range | +| 6 | πŸ†• `planner-goal` | Campaign Goal | 🎯 | #db2777 | β€” | `metric:data` | KPI name + target | +| 7 | πŸ†• `planner-message` | Key Message | πŸ’¬ | #db2777 | β€” | `copy:text` | message + tone | +| 8 | πŸ†• `planner-tone` | Brand Tone | 🎨 | #db2777 | β€” | `style:data` | tone name + examples | +| 9 | πŸ†• `planner-brief` | Creative Brief | πŸ“‹ | #db2777 | β€” | `brief:json` | objective + constraints | + +--- + +### rsocials β€” Campaign Workflow (source: `modules/rsocials/schemas.ts`, `CAMPAIGN_NODE_CATALOG`) + +Workflow nodes are n8n-style runtime steps. These already have full typed I/O in the schema. + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 10 | πŸ†• `wf-campaign-start` | Campaign Start | β–Ά | #3b82f6 | β€” | `trigger:trigger` | +| 11 | πŸ†• `wf-schedule-trigger` | Schedule Trigger | ⏰ | #3b82f6 | β€” | `trigger:trigger`, `timestamp:data` | +| 12 | πŸ†• `wf-webhook-trigger` | Webhook Trigger | πŸ”— | #3b82f6 | β€” | `trigger:trigger`, `payload:data` | +| 13 | πŸ†• `wf-wait-duration` | Wait Duration | ⏳ | #f59e0b | `trigger:trigger` | `done:trigger` | +| 14 | πŸ†• `wf-wait-approval` | Wait for Approval | βœ‹ | #f59e0b | `trigger:trigger` | `approved:trigger`, `rejected:trigger` | +| 15 | πŸ†• `wf-engagement-check` | Engagement Check | πŸ“ˆ | #a855f7 | `trigger:trigger`, `metrics:data` | `above:trigger`, `below:trigger` | +| 16 | πŸ†• `wf-time-window` | Time Window | πŸ•“ | #a855f7 | `trigger:trigger` | `in-window:trigger`, `outside:trigger` | +| 17 | πŸ†• `wf-post-to-platform` | Post to Platform | πŸ“€ | #10b981 | `trigger:trigger`, `content:data` | `done:trigger`, `postId:data` | +| 18 | πŸ†• `wf-cross-post` | Cross-Post | πŸ”€ | #10b981 | `trigger:trigger`, `content:data` | `done:trigger`, `results:data` | +| 19 | πŸ†• `wf-publish-thread` | Publish Thread | 🧡 | #10b981 | `trigger:trigger` | `done:trigger`, `threadId:data` | +| 20 | πŸ†• `wf-send-newsletter` | Send Newsletter | βœ‰οΈ | #10b981 | `trigger:trigger`, `content:data` | `done:trigger`, `campaignId:data` | +| 21 | πŸ†• `wf-post-webhook` | POST Webhook | πŸ“‘ | #10b981 | `trigger:trigger`, `data:data` | `done:trigger`, `response:data` | + +> **Color groups:** trigger=blue `#3b82f6`, delay=amber `#f59e0b`, condition=purple `#a855f7`, action=emerald `#10b981`. Matches existing workflow palette. + +--- + +### rminders β€” Automation Canvas (source: `modules/rminders/schemas.ts`, `NODE_CATALOG`) + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 22 | πŸ†• `auto-trigger-cron` | Cron Schedule | ⏰ | #3b82f6 | β€” | `fire:trigger` | +| 23 | πŸ†• `auto-trigger-data-change` | Data Change | πŸ”„ | #3b82f6 | β€” | `fire:trigger`, `delta:data` | +| 24 | πŸ†• `auto-trigger-webhook` | Webhook Incoming | πŸ”— | #3b82f6 | β€” | `fire:trigger`, `payload:data` | +| 25 | πŸ†• `auto-trigger-manual` | Manual Trigger | πŸ‘† | #3b82f6 | β€” | `fire:trigger` | +| 26 | πŸ†• `auto-trigger-proximity` | Location Proximity | πŸ“ | #3b82f6 | β€” | `enter:trigger`, `exit:trigger` | +| 27 | πŸ†• `auto-condition-compare` | Compare Values | βš–οΈ | #a855f7 | `trigger:trigger`, `a:data`, `b:data` | `true:trigger`, `false:trigger` | +| 28 | πŸ†• `auto-condition-geofence` | Geofence Check | 🌐 | #a855f7 | `trigger:trigger`, `location:data` | `inside:trigger`, `outside:trigger` | +| 29 | πŸ†• `auto-condition-time-window` | Time Window | πŸ•“ | #a855f7 | `trigger:trigger` | `in-window:trigger`, `outside:trigger` | +| 30 | πŸ†• `auto-condition-data-filter` | Data Filter | πŸ” | #a855f7 | `trigger:trigger`, `data:data` | `pass:trigger`, `block:trigger` | +| 31 | πŸ†• `auto-action-send-email` | Send Email | βœ‰οΈ | #10b981 | `trigger:trigger` | `done:trigger` | +| 32 | πŸ†• `auto-action-post-webhook` | POST Webhook | πŸ“‘ | #10b981 | `trigger:trigger`, `body:data` | `done:trigger`, `response:data` | +| 33 | πŸ†• `auto-action-create-event` | Create Calendar Event | πŸ“… | #10b981 | `trigger:trigger` | `done:trigger`, `eventId:data` | +| 34 | πŸ†• `auto-action-create-task` | Create Task | βœ… | #10b981 | `trigger:trigger` | `done:trigger`, `taskId:data` | +| 35 | πŸ†• `auto-action-send-notification` | Send Notification | πŸ”” | #10b981 | `trigger:trigger` | `done:trigger` | +| 36 | πŸ†• `auto-action-update-data` | Update Data | πŸ’Ύ | #10b981 | `trigger:trigger`, `patch:data` | `done:trigger` | + +--- + +### rflows β€” Funnel Canvas (source: `modules/rflows/lib/types.ts`, `FlowNode`) + +Three node types with typed flow ports: `outflow / inflow / overflow / spending`. Already has a coarse `flow-summary` applet β€” propose splitting into per-node types. + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 37 | πŸ†• `flow-source` | Income Source | πŸ’§ | #10b981 | β€” | `outflow:number` | +| 38 | πŸ†• `flow-funnel` | Funnel | πŸ”½ | #60a5fa | `inflow:number` | `overflow:number`, `spending:number` | +| 39 | πŸ†• `flow-outcome` | Outcome | 🎯 | #8b5cf6 | `inflow:number` | `overflow:number` | + +> **Note:** port colors already defined in `PORT_DEFS` β€” reuse exactly so canvas-level wire colors match. Port type is `number` (monthly flow rate) not `data`. + +--- + +### rgov β€” Governance Circuit (source: `modules/rgov/components/folk-gov-circuit.ts`, `GOV_NODE_CATALOG`) + +These already exist as `folk-gov-*` custom elements rendered by the main canvas. But they are **not registered** as AppletDefinitions β€” which means users can only add them from within the circuit mini-canvas, not from the main canvas palette. Low-lift win: register the 8 that aren't already wrapped. + +| # | id | label | icon | color | input ports | output ports | notes | +|---|---|---|---|---|---|---|---| +| 40 | 🟑 `gov-binary` | Signoff | βœ“ | #7c3aed | `in:trigger` | `out:trigger` | duplicates existing `signoff-gate` applet β€” deprecate one? | +| 41 | πŸ†• `gov-threshold` | Threshold | πŸ“Š | #0891b2 | `in:number` | `out:trigger` | trips when input β‰₯ N | +| 42 | πŸ†• `gov-knob` | Knob | 🎚️ | #b45309 | `in:trigger` | `out:number` | manual dial value | +| 43 | πŸ†• `gov-project` | Project | πŸ“ | #10b981 | `in:trigger` | `out:data` | project card as flow node | +| 44 | πŸ†• `gov-quadratic` | Quadratic Vote | ⊿ | #14b8a6 | `in:data` | `out:number` | quadratic tally | +| 45 | πŸ†• `gov-conviction` | Conviction Vote | ⏱️ | #d97706 | `in:data` | `out:number` | conviction curve | +| 46 | πŸ†• `gov-multisig` | Multisig Approval | πŸ” | #6366f1 | `in:trigger` | `out:trigger` | M-of-N signers | +| 47 | πŸ†• `gov-sankey` | Sankey Flow | 〰️ | #f43f5e | `in:data` | `out:data` | fund-routing viz | + +> **Alternative:** register as thin AppletDefinition wrappers that delegate `renderCompact` to the existing `folk-gov-*` component. Keeps rendering logic in one place. Recommend this approach. + +--- + +### rtime β€” Weave Canvas (source: `modules/rtime/components/folk-timebank-app.ts`) + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 48 | πŸ†• `weave-commitment` | Commitment | 🀝 | #7c3aed | `trigger:trigger` | `fulfilled:trigger`, `progress:number` | +| 49 | πŸ†• `weave-task` | Weave Task | 🧡 | #7c3aed | `assignee:data`, `trigger:trigger` | `done:trigger` | + +> Existing `commitment-meter` + `weaving-coverage` applets aggregate all commitments. These new ones represent *individual* commitment/task nodes droppable on canvas. + +--- + +### rnetwork β€” CRM Graph (source: `modules/rnetwork/components/folk-crm-view.ts`, `CrmGraphNode`) + +Existing `contact-card` covers the person case well. Propose one additional for organizations. + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 50 | πŸ†• `org-card` | Organization | 🏒 | #4f46e5 | β€” | `members:json` | company card w/ member count | + +--- + +### rchoices β€” other vote types + +Currently only `vote-tally`. rChoices supports multiple vote types β€” each deserves its own applet. + +| # | id | label | icon | color | input ports | output ports | +|---|---|---|---|---|---|---| +| 51 | πŸ†• `choice-conviction` | Conviction Vote | ⏱️ | #7c3aed | `submit:data` | `result:data` | +| 52 | πŸ†• `choice-rank` | Ranked Choice | πŸ† | #7c3aed | `submit:data` | `winner:data`, `round:data` | +| 53 | πŸ†• `choice-spider` | Spider Vote | πŸ•ΈοΈ | #7c3aed | `submit:data` | `profile:data` | + +> *These overlap with existing `folk-choice-*` custom elements the main canvas already renders β€” same pattern as rgov: wrap into AppletDefinitions so they appear in the palette.* + +--- + +## Implementation order (proposed) + +1. **rgov** (Β§40-47) β€” easiest, already custom elements. 🟑 ones delegate; πŸ†• ones wrap existing components. ~1h. +2. **rflows** (Β§37-39) β€” only 3 types, clean port model already in `PORT_DEFS`. ~1h. +3. **rsocials planner** (Β§1-9) β€” 9 types, shared palette color. ~2h. +4. **rminders automation** (Β§22-36) β€” 15 types with clean catalog. ~2-3h (lots of glyph decisions). +5. **rsocials workflow** (Β§10-21) β€” 12 types, similar to rminders. ~2h. +6. **rtime, rnetwork, rchoices extras** (Β§48-53) β€” cleanup round. ~1h. + +Total: ~10 hours of focused work, spread across 4-6 PRs. One rApp per PR for clean rollback. + +--- + +## Open questions for review + +1. **Naming prefix.** Should I use `planner-*` / `wf-*` / `auto-*` / `gov-*` / `flow-*` prefixes, or namespace only by `moduleId` (registry already does `moduleId:appletId`)? Prefixes improve readability in the palette; namespacing is already handled. **Default: use prefixes for readability.** +2. **Duplicate deprecation.** rgov `signoff-gate` and proposed `gov-binary` are the same concept. Keep one? +3. **Port name `trigger`.** Borrowed from the campaign workflow schema. Alternative: `pulse` or `step`. Consistent with automation catalog. +4. **Icons.** Most proposed icons are emoji for compactness. Some rApps prefer Lucide SVG icons β€” confirm preference. +5. **Compact preview.** For every πŸ†•, I've described the preview in prose. Want me to mock the actual `renderCompact()` HTML for one rApp as a sample before I scale? +6. **Live data.** Several proposed applets would benefit from `fetchLiveData()` pulling real metrics (engagement counts, commitment progress). Add at first implementation or in a second pass? + +--- + +*Generated: 2026-04-17. Review and modify above; I'll implement per Β§Implementation order once approved.* From cd8878d92587dee19154f04921332ea227783f00 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 17:53:28 -0400 Subject: [PATCH 2/2] feat(rsocials): board view + planner UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix inline post editor: pointerdown + wheel on SVG now bail when target is inside .cp-inline-config so textarea/select focus and native scrolling work. Grow post panel to 300Γ—460 with 380px body so the Done/Delete toolbar isn't clipped. - Restyle channel palette chips as vertical tldraw-style cards (big tinted icon, centered label, constraint tags). 2-column grid. - Add Board view (Drafts / Scheduled / Published) as a 4th switcher option. Each column toggleable (min one must stay on). Drag a post card between columns: Draftsβ†’Scheduled opens a date/time picker modal, Scheduledβ†’Drafts reverts status (blocked if already queued on Postiz). Published is terminal (no drops). Card shows platform badge, 3-line preview, char bar + limit, scheduled/published timestamp, Postiz error badge, release link, Edit button that jumps back into the canvas editor. Bump planner assets: css?v=5, js?v=6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rsocials/components/campaign-planner.css | 372 ++++++++++++++++-- .../components/folk-campaign-planner.ts | 337 +++++++++++++++- modules/rsocials/mod.ts | 4 +- 3 files changed, 659 insertions(+), 54 deletions(-) diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index 75245b07..720b77ed 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -160,69 +160,80 @@ folk-campaign-planner { .cp-pp-list { flex: 1; overflow-y: auto; - padding: 8px; - display: flex; - flex-direction: column; - gap: 5px; + padding: 10px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-content: start; } .cp-pp-chip { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - background: var(--rs-bg-surface, #1a1a2e); - border: 1px solid var(--rs-border, #2d2d44); + position: relative; cursor: grab; user-select: none; - transition: border-color 0.1s, background 0.1s, transform 0.05s; -} - -.cp-pp-chip:hover { - border-color: var(--rs-border-strong, #3d3d5c); - background: var(--rs-bg-surface-raised, #252540); + padding: 0; + background: transparent; + border: none; } .cp-pp-chip:active { cursor: grabbing; } -.cp-pp-chip.dragging { - opacity: 0.5; - transform: scale(0.96); +.cp-pp-chip.dragging { opacity: 0.4; } + +.cp-pp-chip__card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 6px 10px; + border-radius: 10px; + background: linear-gradient(180deg, var(--plat-bg, rgba(59,130,246,0.12)), transparent 60%), var(--rs-bg-surface, #1a1a2e); + border: 1.5px solid var(--plat-ring, rgba(255,255,255,0.08)); + box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 3px 8px rgba(0,0,0,0.25); + transition: transform 0.1s ease, box-shadow 0.1s ease, border-color 0.1s ease; +} + +.cp-pp-chip:hover .cp-pp-chip__card { + transform: translateY(-1px); + border-color: var(--plat, #3b82f6); + box-shadow: 0 1px 0 rgba(255,255,255,0.06), 0 6px 14px rgba(0,0,0,0.35); } .cp-pp-chip__icon { - width: 28px; - height: 28px; - border-radius: 6px; + width: 38px; + height: 38px; + border-radius: 10px; display: flex; align-items: center; justify-content: center; - font-weight: 700; - font-size: 14px; - flex-shrink: 0; -} - -.cp-pp-chip__meta { - min-width: 0; - flex: 1; + font-weight: 800; + font-size: 20px; + color: var(--plat, #3b82f6); + background: var(--plat-bg, rgba(59,130,246,0.15)); + border: 1px solid var(--plat-ring, rgba(59,130,246,0.35)); } .cp-pp-chip__label { - font-size: 12px; + font-size: 11.5px; font-weight: 600; color: var(--rs-text-primary, #e1e1e1); + letter-spacing: 0.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 100%; + text-align: center; } .cp-pp-chip__limit { - font-size: 10px; + font-size: 9.5px; color: var(--rs-text-muted, #888); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 100%; + text-align: center; + line-height: 1.25; } .cp-canvas--drop-target { @@ -336,10 +347,293 @@ folk-campaign-planner { } @media (max-width: 720px) { - .cp-pp { width: 56px; } - .cp-pp-title, .cp-pp-hint { display: none; } - .cp-pp-chip__meta { display: none; } - .cp-pp-chip { padding: 6px; justify-content: center; } + .cp-pp { width: 84px; } + .cp-pp-hint { display: none; } + .cp-pp-list { grid-template-columns: 1fr; } + .cp-pp-chip__label { font-size: 10.5px; } + .cp-pp-chip__limit { display: none; } +} + +/* ── Board view (Drafts / Scheduled / Published) ── */ +.cp-board-view { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + background: var(--rs-bg-surface-sunken, #14141e); + color: var(--rs-text-primary, #e1e1e1); +} + +.cp-bv-switcher { + display: flex; + align-items: center; + gap: 14px; + padding: 10px 16px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + font-size: 12px; + flex-shrink: 0; +} + +.cp-bv-switcher__label { color: var(--rs-text-muted, #888); font-weight: 600; } +.cp-bv-switcher__item { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--rs-text-secondary, #a0a0b8); +} +.cp-bv-switcher__item input { accent-color: #3b82f6; } + +.cp-bv-cols { + flex: 1; + display: grid; + grid-auto-columns: minmax(240px, 1fr); + grid-auto-flow: column; + gap: 12px; + padding: 14px; + overflow: auto; +} + +.cp-bv-col { + display: flex; + flex-direction: column; + min-width: 240px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border, #2d2d44); + border-radius: 10px; + overflow: hidden; +} + +.cp-bv-col--collapsed { + min-width: 42px; + max-width: 42px; +} +.cp-bv-col--collapsed .cp-bv-col__title, +.cp-bv-col--collapsed .cp-bv-col__count, +.cp-bv-col--collapsed .cp-bv-col__drop { display: none; } +.cp-bv-col--collapsed .cp-bv-col__head { + flex-direction: column; + gap: 8px; + padding: 10px 6px; +} + +.cp-bv-col__head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface-raised, #252540); +} + +.cp-bv-col__toggle { + width: 22px; height: 22px; + border-radius: 6px; + border: 1px solid var(--rs-border, #3d3d5c); + background: transparent; + color: var(--rs-text-secondary, #a0a0b8); + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.cp-bv-col__toggle:hover { background: var(--rs-bg-surface, #1a1a2e); } + +.cp-bv-col__title { + font-size: 12.5px; + font-weight: 700; + color: var(--rs-text-primary, #e1e1e1); + flex: 1; + letter-spacing: 0.2px; +} + +.cp-bv-col__count { + font-size: 11px; + padding: 1px 7px; + border-radius: 999px; + background: rgba(255,255,255,0.05); + color: var(--rs-text-muted, #888); + font-weight: 600; +} + +.cp-bv-col__drop { + flex: 1; + padding: 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 80px; + transition: background 0.1s ease; +} + +.cp-bv-drop-hover { + background: rgba(59, 130, 246, 0.08); + outline: 2px dashed #3b82f6; + outline-offset: -6px; +} + +.cp-bv-empty { + margin: auto; + text-align: center; + color: var(--rs-text-muted, #888); + padding: 30px 12px; + font-size: 12px; +} +.cp-bv-empty strong { display: block; font-size: 13px; color: var(--rs-text-secondary, #a0a0b8); margin-bottom: 4px; } +.cp-bv-empty p { margin: 0; line-height: 1.4; } + +.cp-bv-card { + background: var(--rs-bg-surface-raised, #252540); + border: 1px solid var(--rs-border, #2d2d44); + border-left: 3px solid var(--plat, #3b82f6); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; + cursor: grab; + user-select: none; + transition: border-color 0.1s ease, transform 0.05s ease; +} +.cp-bv-card:hover { border-color: var(--plat, #3b82f6); } +.cp-bv-card.dragging { opacity: 0.4; } +.cp-bv-card:active { cursor: grabbing; } + +.cp-bv-card__head { + display: flex; + align-items: center; + gap: 8px; +} + +.cp-bv-card__icon { + width: 24px; height: 24px; + border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 13px; + flex-shrink: 0; +} + +.cp-bv-card__plat { + flex: 1; + font-size: 12px; + font-weight: 600; + color: var(--rs-text-primary, #e1e1e1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cp-bv-err, .cp-bv-queued { + font-size: 10px; + padding: 1px 6px; + border-radius: 999px; + font-weight: 600; +} +.cp-bv-err { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.35); } +.cp-bv-queued { background: rgba(168,85,247,0.15); color: #c084fc; border: 1px solid rgba(168,85,247,0.35); } + +.cp-bv-card__body { + font-size: 12px; + color: var(--rs-text-secondary, #cbd5e1); + line-height: 1.4; + max-height: 3.9em; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.cp-bv-card__foot { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 10px; + color: var(--rs-text-muted, #888); +} + +.cp-bv-card__bar { + flex: 1; + min-width: 40px; + height: 3px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + overflow: hidden; +} +.cp-bv-card__bar > div { height: 100%; border-radius: 2px; } + +.cp-bv-card__chars.over { color: #ef4444; font-weight: 700; } + +.cp-bv-card__when { + background: rgba(255,255,255,0.04); + padding: 1px 6px; + border-radius: 999px; + white-space: nowrap; +} + +.cp-bv-card__edit { + margin-left: auto; + padding: 2px 8px; + border: 1px solid var(--rs-border, #3d3d5c); + background: transparent; + color: var(--rs-text-secondary, #a0a0b8); + border-radius: 6px; + font-size: 10px; + cursor: pointer; +} +.cp-bv-card__edit:hover { background: var(--rs-bg-surface, #1a1a2e); color: var(--rs-text-primary, #e1e1e1); } + +.cp-bv-link { + color: #3b82f6; + text-decoration: none; +} +.cp-bv-link:hover { text-decoration: underline; } + +/* Scheduled-at picker modal */ +.cp-bv-sched-modal[hidden] { display: none; } +.cp-bv-sched-modal { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; +} +.cp-bv-sched-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.45); +} +.cp-bv-sched-modal__panel { + position: relative; + width: min(380px, calc(100% - 32px)); + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border-strong, #3d3d5c); + border-radius: 12px; + padding: 18px 18px 14px; + box-shadow: 0 16px 40px rgba(0,0,0,0.5); +} +.cp-bv-sched-modal h3 { margin: 0 0 4px; font-size: 15px; } +.cp-bv-sched-modal__hint { margin: 0 0 14px; font-size: 11px; color: var(--rs-text-muted, #888); } +.cp-bv-sched-modal label { display: block; font-size: 11px; color: var(--rs-text-muted, #888); margin-bottom: 6px; } +.cp-bv-sched-modal input { + width: 100%; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--rs-border-strong, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e1e1e1); + font-size: 13px; +} +.cp-bv-sched-modal__actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 14px; } .cp-canvas { @@ -526,8 +820,10 @@ folk-campaign-planner { display: flex; flex-direction: column; gap: 8px; - max-height: 300px; + max-height: 380px; overflow-y: auto; + overscroll-behavior: contain; + touch-action: pan-y; } .cp-icp-body label { diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index 43f1657a..77bd2f6a 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -154,7 +154,7 @@ function getUsername(): string | null { class FolkCampaignPlanner extends HTMLElement { private shadow: ShadowRoot; private space = ''; - private currentView: 'canvas' | 'timeline' | 'platform' | 'table' = 'canvas'; + private currentView: 'canvas' | 'timeline' | 'platform' | 'table' | 'board' = 'canvas'; private get basePath() { const host = window.location.hostname; @@ -825,8 +825,8 @@ class FolkCampaignPlanner extends HTMLElement { const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'g'); overlay.classList.add('inline-edit-overlay'); - const panelW = 260; - const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : node.type === 'brief' ? 400 : 220; + const panelW = node.type === 'post' ? 300 : 260; + const panelH = node.type === 'post' ? 460 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : node.type === 'brief' ? 400 : 220; const panelX = s.w + 12; const panelY = 0; @@ -1437,7 +1437,7 @@ class FolkCampaignPlanner extends HTMLElement { // ── View switching ── - private switchView(view: 'canvas' | 'timeline' | 'platform' | 'table') { + private switchView(view: 'canvas' | 'timeline' | 'platform' | 'table' | 'board') { this.currentView = view; const canvasEl = this.shadow.getElementById('cp-canvas'); const altEl = this.shadow.getElementById('cp-alt-view'); @@ -1465,8 +1465,10 @@ class FolkCampaignPlanner extends HTMLElement { case 'timeline': altEl.innerHTML = this.renderTimeline(); break; case 'platform': altEl.innerHTML = this.renderPlatformView(); break; case 'table': altEl.innerHTML = this.renderTableView(); break; + case 'board': altEl.innerHTML = this.renderBoardView(); break; } this.attachAltViewListeners(); + if (view === 'board') this.attachBoardListeners(); } // ── Timeline view ── @@ -1720,6 +1722,284 @@ class FolkCampaignPlanner extends HTMLElement { `; } + // ── Board view (Drafts / Scheduled / Published) ── + + /** Which columns are currently visible. At least one must stay on. */ + private boardVisible: { drafts: boolean; scheduled: boolean; published: boolean } = { + drafts: true, scheduled: true, published: true, + }; + + private renderBoardView(): string { + const posts = this.nodes.filter(n => n.type === 'post'); + const drafts = posts.filter(n => { + const d = n.data as PostNodeData; + return d.status === 'draft' && !d.postizPostId; + }); + const scheduled = posts.filter(n => { + const d = n.data as PostNodeData; + return (d.status === 'scheduled' || d.postizStatus === 'queued') && d.postizStatus !== 'published'; + }); + const published = posts.filter(n => { + const d = n.data as PostNodeData; + return d.status === 'published' || d.postizStatus === 'published'; + }); + + const renderCard = (node: CampaignPlannerNode) => { + const d = node.data as PostNodeData; + const platform = d.platform || 'x'; + const spec = getPlatformSpec(platform); + const color = spec?.color || PLATFORM_COLORS[platform] || '#888'; + const icon = spec?.icon || PLATFORM_ICONS[platform] || platform.charAt(0); + const label = spec?.label || platform; + const charCount = (d.content || '').length; + const charMax = charLimitFor(platform); + const charPct = Math.min(1, charCount / charMax) * 100; + const overLimit = charCount > charMax; + const preview = (d.content || '').split('\n')[0].substring(0, 80); + const scheduledStr = d.scheduledAt + ? new Date(d.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) + : ''; + const publishedStr = d.publishedAt + ? new Date(d.publishedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) + : ''; + const errorBadge = d.postizStatus === 'failed' + ? `⚠ error` : ''; + const queuedBadge = d.postizStatus === 'queued' + ? '⏳ Postiz' : ''; + const releaseLink = d.postizReleaseURL + ? `Open β†—` : ''; + + return `
+
+ ${icon} + ${esc(label)} + ${errorBadge}${queuedBadge} +
+
${esc(preview) || '(empty)'}
+
+
+
+
+ ${charCount}/${charMax} + ${scheduledStr ? `πŸ“… ${scheduledStr}` : ''} + ${publishedStr ? `βœ“ ${publishedStr}` : ''} + ${releaseLink} + +
+
`; + }; + + const emptyCol = (label: string, hint: string) => + `
${label}

${hint}

`; + + const draftsHTML = drafts.length + ? drafts.map(renderCard).join('') + : emptyCol('No drafts', 'Drag a channel onto the canvas or drop a scheduled post back here.'); + const scheduledHTML = scheduled.length + ? scheduled.map(renderCard).join('') + : emptyCol('Nothing scheduled', 'Drop a draft here to set a date & time.'); + const publishedHTML = published.length + ? published.map(renderCard).join('') + : emptyCol('No published posts yet', 'Published posts appear here after Postiz confirms release.'); + + const col = (key: 'drafts' | 'scheduled' | 'published', title: string, icon: string, count: number, html: string) => { + const visible = this.boardVisible[key]; + return `
+
+ + ${icon} ${title} + ${count} +
+
+ ${visible ? html : ''} +
+
`; + }; + + return `
+
+ Columns: + ${(['drafts', 'scheduled', 'published'] as const).map(k => ` + + `).join('')} +
+
+ ${col('drafts', 'Drafts', 'πŸ“', drafts.length, draftsHTML)} + ${col('scheduled', 'Scheduled', 'πŸ“…', scheduled.length, scheduledHTML)} + ${col('published', 'Published', 'πŸš€', published.length, publishedHTML)} +
+ +
`; + } + + private attachBoardListeners() { + const root = this.shadow.querySelector('.cp-board-view'); + if (!root) return; + + // Column visibility toggles (checkboxes + mini toggle buttons) + const rerender = () => { + const alt = this.shadow.getElementById('cp-alt-view'); + if (alt) alt.innerHTML = this.renderBoardView(); + this.attachBoardListeners(); + }; + root.querySelectorAll('[data-show-col]').forEach(cb => { + cb.addEventListener('change', () => { + const key = cb.getAttribute('data-show-col') as 'drafts' | 'scheduled' | 'published'; + // Don't allow all three to be off at once. + const next = { ...this.boardVisible, [key]: cb.checked }; + if (!next.drafts && !next.scheduled && !next.published) { + cb.checked = true; + return; + } + this.boardVisible = next; + rerender(); + }); + }); + root.querySelectorAll('[data-toggle-col]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-toggle-col') as 'drafts' | 'scheduled' | 'published'; + const next = { ...this.boardVisible, [key]: !this.boardVisible[key] }; + if (!next.drafts && !next.scheduled && !next.published) return; + this.boardVisible = next; + rerender(); + }); + }); + + // Edit β†’ jump to canvas and open inline editor for this node + root.querySelectorAll('[data-edit-node]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + const nodeId = el.getAttribute('data-edit-node'); + if (nodeId) this.navigateToCanvasNode(nodeId); + }); + }); + + // Drag source β€” posts + root.querySelectorAll('.cp-bv-card[draggable="true"]').forEach(card => { + card.addEventListener('dragstart', (e: DragEvent) => { + const nodeId = card.getAttribute('data-node-id') || ''; + if (!e.dataTransfer || !nodeId) return; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/x-rsocials-post', nodeId); + e.dataTransfer.setData('text/plain', nodeId); + card.classList.add('dragging'); + }); + card.addEventListener('dragend', () => card.classList.remove('dragging')); + }); + + // Drop targets β€” columns + root.querySelectorAll('[data-drop-col]').forEach(zone => { + const targetCol = zone.getAttribute('data-drop-col') as 'drafts' | 'scheduled' | 'published'; + zone.addEventListener('dragover', (e: DragEvent) => { + const types = e.dataTransfer?.types; + if (!types) return; + const hasPost = Array.from(types).some(t => t === 'application/x-rsocials-post' || t === 'text/plain'); + if (!hasPost) return; + // Published is terminal β€” don't allow drops into it. + if (targetCol === 'published') return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + zone.classList.add('cp-bv-drop-hover'); + }); + zone.addEventListener('dragleave', () => zone.classList.remove('cp-bv-drop-hover')); + zone.addEventListener('drop', (e: DragEvent) => { + zone.classList.remove('cp-bv-drop-hover'); + const nodeId = e.dataTransfer?.getData('application/x-rsocials-post') + || e.dataTransfer?.getData('text/plain') || ''; + if (!nodeId) return; + e.preventDefault(); + this.handleBoardDrop(nodeId, targetCol); + }); + }); + + // Schedule modal + const modal = this.shadow.getElementById('cp-bv-sched'); + modal?.querySelectorAll('[data-sched-cancel]').forEach(el => { + el.addEventListener('click', () => { modal.hidden = true; this.pendingScheduleNodeId = null; }); + }); + this.shadow.getElementById('cp-bv-sched-confirm')?.addEventListener('click', () => { + const input = this.shadow.getElementById('cp-bv-sched-input') as HTMLInputElement | null; + if (!input || !this.pendingScheduleNodeId) return; + const iso = input.value ? new Date(input.value).toISOString() : ''; + if (!iso) { input.focus(); return; } + this.applySchedule(this.pendingScheduleNodeId, iso); + this.pendingScheduleNodeId = null; + if (modal) modal.hidden = true; + }); + } + + private pendingScheduleNodeId: string | null = null; + + private handleBoardDrop(nodeId: string, targetCol: 'drafts' | 'scheduled' | 'published') { + const node = this.nodes.find(n => n.id === nodeId); + if (!node || node.type !== 'post') return; + const d = node.data as PostNodeData; + + if (targetCol === 'published') return; // disallowed + + if (targetCol === 'drafts') { + // Revert scheduled/queued post back to a draft. + // If it was queued on Postiz, warn the user β€” don't silently mutate remote. + if (d.postizStatus === 'queued' && d.postizPostId) { + alert('This post is queued on Postiz. Cancel or publish it in Postiz before moving back to Drafts.'); + return; + } + d.status = 'draft'; + d.scheduledAt = ''; + this.scheduleSave(); + this.drawCanvasContent(); + const alt = this.shadow.getElementById('cp-alt-view'); + if (alt) { alt.innerHTML = this.renderBoardView(); this.attachBoardListeners(); } + return; + } + + // targetCol === 'scheduled' β†’ open date picker + this.pendingScheduleNodeId = nodeId; + const modal = this.shadow.getElementById('cp-bv-sched'); + const input = this.shadow.getElementById('cp-bv-sched-input') as HTMLInputElement | null; + if (input) { + // Seed with existing scheduledAt or round-up to next hour. + const seed = d.scheduledAt + ? new Date(d.scheduledAt) + : new Date(Date.now() + 60 * 60 * 1000); + // datetime-local wants `YYYY-MM-DDTHH:mm` in local time. + const pad = (n: number) => n.toString().padStart(2, '0'); + input.value = `${seed.getFullYear()}-${pad(seed.getMonth() + 1)}-${pad(seed.getDate())}T${pad(seed.getHours())}:${pad(seed.getMinutes())}`; + } + if (modal) modal.hidden = false; + setTimeout(() => input?.focus(), 50); + } + + private applySchedule(nodeId: string, iso: string) { + const node = this.nodes.find(n => n.id === nodeId); + if (!node || node.type !== 'post') return; + const d = node.data as PostNodeData; + d.scheduledAt = iso; + d.status = 'scheduled'; + this.scheduleSave(); + this.drawCanvasContent(); + const alt = this.shadow.getElementById('cp-alt-view'); + if (alt) { alt.innerHTML = this.renderBoardView(); this.attachBoardListeners(); } + } + // ── Alt view click-to-navigate ── private attachAltViewListeners() { @@ -1765,22 +2045,27 @@ class FolkCampaignPlanner extends HTMLElement { const chips = ALL_PLATFORM_IDS.map(id => { const s = PLATFORM_SPECS[id]; const limit = s.charLimit >= 10000 ? `${Math.round(s.charLimit / 1000)}k` : `${s.charLimit}`; + const tags: string[] = []; + if (s.supportsThreads) tags.push('🧡'); + if (s.mediaRequired) tags.push('πŸ“Ž'); + if (s.titleRequired) tags.push('🏷'); return ` -
- ${s.icon} -
+
+
+
${s.icon}
${esc(s.label)}
-
${limit} chars${s.mediaRequired ? ' Β· media' : ''}${s.titleRequired ? ' Β· title' : ''}${s.supportsThreads ? ' Β· thread' : ''}
+
${limit} chars${tags.length ? ' Β· ' + tags.join('') : ''}
`; }).join(''); return ` `; } @@ -1809,6 +2094,16 @@ class FolkCampaignPlanner extends HTMLElement { +
@@ -2565,7 +2860,7 @@ class FolkCampaignPlanner extends HTMLElement { // View switcher buttons this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => { btn.addEventListener('click', () => { - const view = btn.getAttribute('data-view') as 'canvas' | 'timeline' | 'platform' | 'table'; + const view = btn.getAttribute('data-view') as 'canvas' | 'timeline' | 'platform' | 'table' | 'board'; if (view) this.switchView(view); }); }); @@ -2651,13 +2946,20 @@ class FolkCampaignPlanner extends HTMLElement { }); this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - // Shared interaction controller β€” wheel=pan, Ctrl+wheel/pinch=zoom, two-finger touch=pan+pinch + // Shared interaction controller β€” wheel=pan, Ctrl+wheel/pinch=zoom, two-finger touch=pan+pinch. + // Disable when the event originates inside the inline config panel so textareas/selects + // inside foreignObject can scroll and focus normally. this.interactionController?.destroy(); this.interactionController = new CanvasInteractionController({ target: svg, getViewport: () => ({ x: this.canvasPanX, y: this.canvasPanY, zoom: this.canvasZoom }), setViewport: (v) => { this.canvasPanX = v.x; this.canvasPanY = v.y; this.canvasZoom = v.zoom; }, onChange: () => this.updateCanvasTransform(), + isEnabled: (e) => { + const t = e.target as Element | null; + if (!t) return true; + return !t.closest('.cp-inline-config, .inline-edit-overlay'); + }, }); // Context menu @@ -2675,10 +2977,17 @@ class FolkCampaignPlanner extends HTMLElement { svg.addEventListener('pointerdown', (e: PointerEvent) => { if (e.button === 2) return; // right-click handled by contextmenu if (e.button !== 0) return; - this.closeContextMenu(); const target = e.target as Element; + // Inline config panel owns its own interactions β€” don't steal clicks + // from inputs/selects/textareas/buttons living in its foreignObject. + if (target.closest('.cp-inline-config, .inline-edit-overlay')) { + return; + } + + this.closeContextMenu(); + // Edge drag handle? const edgeDragEl = target.closest('[data-edge-drag]'); if (edgeDragEl) { diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 6e5a6209..d9ccd0ed 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -2885,8 +2885,8 @@ routes.get("/campaign-flow", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - styles: ``, - scripts: ``, + styles: ``, + scripts: ``, })); });