Merge branch 'dev': board view + rsocials planner fixes
CI/CD / deploy (push) Successful in 3m1s
Details
CI/CD / deploy (push) Successful in 3m1s
Details
This commit is contained in:
commit
969c363fd2
|
|
@ -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/<rapp>/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/<rapp>/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.*
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// ── 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'
|
||||
? `<span class="cp-bv-err" title="${esc(d.postizError || '')}">⚠ error</span>` : '';
|
||||
const queuedBadge = d.postizStatus === 'queued'
|
||||
? '<span class="cp-bv-queued">⏳ Postiz</span>' : '';
|
||||
const releaseLink = d.postizReleaseURL
|
||||
? `<a class="cp-bv-link" href="${esc(d.postizReleaseURL)}" target="_blank" rel="noopener">Open ↗</a>` : '';
|
||||
|
||||
return `<article class="cp-bv-card" draggable="true" data-node-id="${node.id}"
|
||||
style="--plat:${color}">
|
||||
<header class="cp-bv-card__head">
|
||||
<span class="cp-bv-card__icon" style="background:${color}22;color:${color}">${icon}</span>
|
||||
<span class="cp-bv-card__plat">${esc(label)}</span>
|
||||
${errorBadge}${queuedBadge}
|
||||
</header>
|
||||
<div class="cp-bv-card__body">${esc(preview) || '<em style="color:var(--rs-text-muted)">(empty)</em>'}</div>
|
||||
<footer class="cp-bv-card__foot">
|
||||
<div class="cp-bv-card__bar">
|
||||
<div style="width:${charPct}%;background:${overLimit ? '#ef4444' : color}"></div>
|
||||
</div>
|
||||
<span class="cp-bv-card__chars ${overLimit ? 'over' : ''}">${charCount}/${charMax}</span>
|
||||
${scheduledStr ? `<span class="cp-bv-card__when">📅 ${scheduledStr}</span>` : ''}
|
||||
${publishedStr ? `<span class="cp-bv-card__when">✓ ${publishedStr}</span>` : ''}
|
||||
${releaseLink}
|
||||
<button class="cp-bv-card__edit" data-edit-node="${node.id}" title="Open in canvas editor">Edit</button>
|
||||
</footer>
|
||||
</article>`;
|
||||
};
|
||||
|
||||
const emptyCol = (label: string, hint: string) =>
|
||||
`<div class="cp-bv-empty"><strong>${label}</strong><p>${hint}</p></div>`;
|
||||
|
||||
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 `<section class="cp-bv-col ${visible ? '' : 'cp-bv-col--collapsed'}" data-col="${key}">
|
||||
<header class="cp-bv-col__head">
|
||||
<button class="cp-bv-col__toggle" data-toggle-col="${key}" title="${visible ? 'Hide' : 'Show'} column">
|
||||
${visible ? '−' : '+'}
|
||||
</button>
|
||||
<span class="cp-bv-col__title">${icon} ${title}</span>
|
||||
<span class="cp-bv-col__count">${count}</span>
|
||||
</header>
|
||||
<div class="cp-bv-col__drop" data-drop-col="${key}">
|
||||
${visible ? html : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
};
|
||||
|
||||
return `<div class="cp-board-view">
|
||||
<div class="cp-bv-switcher">
|
||||
<span class="cp-bv-switcher__label">Columns:</span>
|
||||
${(['drafts', 'scheduled', 'published'] as const).map(k => `
|
||||
<label class="cp-bv-switcher__item">
|
||||
<input type="checkbox" data-show-col="${k}" ${this.boardVisible[k] ? 'checked' : ''}/>
|
||||
${k.charAt(0).toUpperCase() + k.slice(1)}
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="cp-bv-cols">
|
||||
${col('drafts', 'Drafts', '📝', drafts.length, draftsHTML)}
|
||||
${col('scheduled', 'Scheduled', '📅', scheduled.length, scheduledHTML)}
|
||||
${col('published', 'Published', '🚀', published.length, publishedHTML)}
|
||||
</div>
|
||||
<div class="cp-bv-sched-modal" id="cp-bv-sched" hidden>
|
||||
<div class="cp-bv-sched-modal__backdrop" data-sched-cancel></div>
|
||||
<div class="cp-bv-sched-modal__panel">
|
||||
<h3>Schedule post</h3>
|
||||
<p class="cp-bv-sched-modal__hint">Pick a publish date & time. Postiz will pick it up on the next sweep.</p>
|
||||
<label>Scheduled at</label>
|
||||
<input type="datetime-local" id="cp-bv-sched-input"/>
|
||||
<div class="cp-bv-sched-modal__actions">
|
||||
<button class="cp-btn" data-sched-cancel>Cancel</button>
|
||||
<button class="cp-btn cp-btn--postiz" id="cp-bv-sched-confirm">Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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<HTMLInputElement>('[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<HTMLElement>('.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<HTMLElement>('[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 `
|
||||
<div class="cp-pp-chip" draggable="true" data-platform="${id}" title="${esc(s.label)} — ${esc(s.note)}">
|
||||
<span class="cp-pp-chip__icon" style="background:${s.color}22;color:${s.color}">${s.icon}</span>
|
||||
<div class="cp-pp-chip__meta">
|
||||
<div class="cp-pp-chip" draggable="true" data-platform="${id}" title="${esc(s.label)} — ${esc(s.note)}"
|
||||
style="--plat:${s.color};--plat-bg:${s.color}18;--plat-ring:${s.color}55">
|
||||
<div class="cp-pp-chip__card">
|
||||
<div class="cp-pp-chip__icon">${s.icon}</div>
|
||||
<div class="cp-pp-chip__label">${esc(s.label)}</div>
|
||||
<div class="cp-pp-chip__limit">${limit} chars${s.mediaRequired ? ' · media' : ''}${s.titleRequired ? ' · title' : ''}${s.supportsThreads ? ' · thread' : ''}</div>
|
||||
<div class="cp-pp-chip__limit">${limit} chars${tags.length ? ' · ' + tags.join('') : ''}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<aside class="cp-pp ${this.paletteCollapsed ? 'cp-pp--collapsed' : ''}" id="cp-platform-palette">
|
||||
<div class="cp-pp-header">
|
||||
<span class="cp-pp-title">Drag onto canvas</span>
|
||||
<button class="cp-pp-toggle" id="cp-pp-toggle" title="Toggle palette">${this.paletteCollapsed ? '\u00bb' : '\u00ab'}</button>
|
||||
<span class="cp-pp-title">📢 Channels</span>
|
||||
<button class="cp-pp-toggle" id="cp-pp-toggle" title="Toggle palette">${this.paletteCollapsed ? '\u203a' : '\u2039'}</button>
|
||||
</div>
|
||||
<div class="cp-pp-hint">Each drop creates a post pre-tagged with the platform's limits & fields.</div>
|
||||
<div class="cp-pp-hint">Drag a channel onto the canvas to create a pre-tagged post.</div>
|
||||
<div class="cp-pp-list">${chips}</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
|
@ -1809,6 +2094,16 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
<button class="cp-view-btn ${this.currentView === 'table' ? 'active' : ''}" data-view="table" title="Table">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="3" rx="0.5" fill="currentColor" opacity="0.5"/><rect x="1" y="5.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="9" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="12.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/></svg>
|
||||
</button>
|
||||
<button class="cp-view-btn ${this.currentView === 'board' ? 'active' : ''}" data-view="board" title="Board — Drafts / Scheduled / Published">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="1" y="2" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/>
|
||||
<rect x="6" y="2" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/>
|
||||
<rect x="11" y="2" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/>
|
||||
<rect x="2" y="4" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="7" y="4" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
<rect x="12" y="4" width="2" height="2" fill="currentColor" opacity="0.6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cp-toolbar__actions">
|
||||
<button class="cp-btn cp-btn--brief" id="add-brief">\u2728 + Brief</button>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -2885,8 +2885,8 @@ routes.get("/campaign-flow", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css?v=4">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=5"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css?v=5">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=6"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue