Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m35s
Details
CI/CD / deploy (push) Successful in 2m35s
Details
This commit is contained in:
commit
a2acf963f7
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
id: TASK-144
|
||||
title: Power Indices for DAO Governance Analysis
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:50'
|
||||
labels:
|
||||
- rnetwork
|
||||
- governance
|
||||
- encryptid
|
||||
- trust-engine
|
||||
dependencies: []
|
||||
references:
|
||||
- src/encryptid/power-indices.ts
|
||||
- src/encryptid/trust-engine.ts
|
||||
- src/encryptid/schema.sql
|
||||
- modules/rnetwork/components/folk-graph-viewer.ts
|
||||
- modules/rnetwork/mod.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Banzhaf & Shapley-Shubik power index computation for rSpace delegation system. Reveals who actually controls outcomes vs raw delegation weights.
|
||||
|
||||
## Implemented
|
||||
- **Compute engine** (`src/encryptid/power-indices.ts`): Banzhaf DP O(n·Q), Shapley-Shubik DP O(n²·Q), Gini coefficient, HHI concentration
|
||||
- **DB schema**: `power_indices` table (PK: did, space_slug, authority) with materialized results
|
||||
- **Background job**: Hooks into trust engine 5-min recompute cycle
|
||||
- **API**: GET `/api/power-indices?space=X&authority=Y`, GET `/api/power-indices/:did`, POST `/api/power-indices/simulate` (coalition what-if)
|
||||
- **Visualization**: Power tab in rNetwork 3D graph viewer — animated Banzhaf bars, Gini/HHI gauges, node sizing by coalitional power
|
||||
- **On-demand compute**: First API hit computes + caches if DB empty
|
||||
|
||||
## Future Integration Opportunities
|
||||
- **Delegation Dashboard** (`folk-delegation-manager.ts`): Show each user their own Banzhaf power next to their delegation weights. "Your 10% weight gives you 23% voting power" insight.
|
||||
- **rVote conviction voting**: Weight votes by Shapley-Shubik instead of raw tokens — prevents plutocratic capture
|
||||
- **fin-ops blending**: Blend $MYCO token balances with delegation weights (configurable ratio) for fin-ops authority power indices
|
||||
- **Trust Sankey** (`folk-trust-sankey.ts`): Color/thickness flows by marginal power contribution, not just raw weight
|
||||
- **Space admin dashboard**: Alert when Gini > 0.6 or HHI > 0.25 (concentration warning)
|
||||
- **rData analytics**: Time-series of power concentration metrics (Gini trend, effective voters trend)
|
||||
- **Coalition builder UI**: Interactive "what if we form this coalition?" tool using the simulate endpoint
|
||||
- **Quadratic power weighting**: Use sqrt(Banzhaf) as vote weight to reduce inequality
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Banzhaf & Shapley-Shubik computed via DP (not brute force)
|
||||
- [ ] #2 Results materialized in PG, recomputed every 5 min
|
||||
- [ ] #3 3 API endpoints (list, per-user, simulate)
|
||||
- [ ] #4 Power tab in rNetwork graph viewer with animated bars + gauges
|
||||
- [ ] #5 Node sizes reflect Banzhaf power in power mode
|
||||
- [ ] #6 On-demand computation when DB empty
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented Banzhaf and Shapley-Shubik power index computation integrated into the trust engine's 5-min background cycle. Power indices table in PG stores materialized results per (did, space, authority). Three API endpoints on EncryptID server with rNetwork proxy routes. Visualization integrated into 3D graph viewer as Power tab — animated bar chart showing weight/Banzhaf/Shapley-Shubik per player, Gini and HHI concentration gauges, and Banzhaf-scaled node sizing. Also fixed encryptid Dockerfile missing welcome-email.ts and swapped mouse controls to left-drag=rotate.
|
||||
|
||||
Commits: 97c1b02 (feature), 1bc2a0a (Dockerfile fix). Deployed to Netcup, live at demo.rspace.online/rnetwork/power.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: TASK-145
|
||||
title: Power Badge in Delegation Manager
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:56'
|
||||
labels:
|
||||
- rnetwork
|
||||
- governance
|
||||
- power-indices
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- modules/rnetwork/components/folk-delegation-manager.ts
|
||||
- src/encryptid/power-indices.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add Banzhaf power percentage badge to `folk-delegation-manager.ts` inbound delegation section.
|
||||
|
||||
## What
|
||||
Each user's inbound delegation count already shows "3 delegations received". Add a power badge: **"3 delegations → 23% power"** fetched from `/api/power-indices/:did`.
|
||||
|
||||
## Primitive
|
||||
- Fetch user's power index on component load: `GET /rnetwork/api/power-indices/{did}?space={space}`
|
||||
- Display per-authority: weight% vs Banzhaf% with color coding (green if proportional, red if disproportionate)
|
||||
- Tooltip: "You hold 10% of delegation weight but 23% of actual voting power because smaller players can't form winning coalitions without you"
|
||||
|
||||
## Implementation
|
||||
- `folk-delegation-manager.ts`: Add `fetchPowerBadge()` in `connectedCallback`, cache result
|
||||
- New `renderPowerBadge(authority)` method → returns HTML for the badge
|
||||
- Insert into the inbound delegations header row per authority
|
||||
- ~40 lines of code, one fetch call, zero new files
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Banzhaf % shown next to inbound delegation count per authority
|
||||
- [ ] #2 Color coded: green (proportional ±20%), red (overrepresented), blue (underrepresented)
|
||||
- [ ] #3 Tooltip explains power vs weight difference
|
||||
- [ ] #4 Graceful fallback when no power data available
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
id: TASK-146
|
||||
title: Sankey Power Overlay — dual-bar node sizing
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:56'
|
||||
labels:
|
||||
- rnetwork
|
||||
- governance
|
||||
- power-indices
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- modules/rnetwork/components/folk-trust-sankey.ts
|
||||
- src/encryptid/power-indices.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add power index overlay to `folk-trust-sankey.ts` right-column nodes.
|
||||
|
||||
## What
|
||||
Right-column delegate nodes currently show rank badge + received weight %. Add a second bar showing Banzhaf power %, creating a visual comparison: raw weight vs actual coalitional power.
|
||||
|
||||
## Primitive
|
||||
- Fetch power indices once on authority change: `GET /rnetwork/api/power-indices?space={space}&authority={authority}`
|
||||
- Build `Map<did, { banzhaf, shapleyShubik }>` lookup
|
||||
- Right-column nodes get dual horizontal bars:
|
||||
- Top bar (gray): raw received weight %
|
||||
- Bottom bar (authority color): Banzhaf power %
|
||||
- Nodes where power >> weight glow red (disproportionate influence)
|
||||
|
||||
## Implementation
|
||||
- `folk-trust-sankey.ts`: Add `powerMap` field, fetch in `loadData()`
|
||||
- Modify `renderRightNodes()` to draw second bar below weight bar
|
||||
- Add CSS for `.power-bar` with transition animation
|
||||
- ~60 lines, one fetch, zero new files
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Dual bars on right-column nodes: weight % and Banzhaf %
|
||||
- [ ] #2 Red glow on nodes where Banzhaf > 1.5x weight share
|
||||
- [ ] #3 Bars animate on authority tab switch
|
||||
- [ ] #4 Toggle to show/hide power overlay
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
id: TASK-147
|
||||
title: Delegation-weighted voting mode for rVote
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:56'
|
||||
labels:
|
||||
- rvote
|
||||
- governance
|
||||
- power-indices
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- modules/rvote/mod.ts
|
||||
- src/encryptid/power-indices.ts
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Optional voting mode where conviction vote weight is multiplied by the voter's Shapley-Shubik index.
|
||||
|
||||
## What
|
||||
Currently rVote uses credit-based quadratic voting (1 vote = 1 credit, 2 = 4 credits). Add an optional space-level toggle: "delegation-weighted voting" where each vote's effective weight = `creditWeight × shapleyShubikIndex`. This lets delegated authority flow into proposal ranking.
|
||||
|
||||
## Primitive: Power Weight Multiplier
|
||||
- New field in space voting config: `weightMode: 'credits-only' | 'delegation-weighted'`
|
||||
- When `delegation-weighted`: fetch voter's power index at vote time
|
||||
- `effectiveWeight = creditWeight × (1 + shapleyShubik × delegationMultiplier)`
|
||||
- Default `delegationMultiplier = 2.0` (configurable per space)
|
||||
- Fallback: if no power index data, effectiveWeight = creditWeight (graceful degradation)
|
||||
|
||||
## Implementation
|
||||
- `modules/rvote/mod.ts`: In `POST /api/proposals/:id/vote` handler, check space config
|
||||
- If delegation-weighted: fetch from EncryptID `/api/power-indices/:did?space={space}`
|
||||
- Multiply vote weight before storing in Automerge doc
|
||||
- Display in UI: "Your vote: 3 credits × 1.4x delegation = 4.2 effective weight"
|
||||
- ~50 lines server, ~20 lines UI display
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Space config toggle: credits-only vs delegation-weighted
|
||||
- [ ] #2 Vote weight multiplied by Shapley-Shubik when delegation-weighted
|
||||
- [ ] #3 Multiplier configurable per space (default 2.0)
|
||||
- [ ] #4 UI shows breakdown: credits × delegation multiplier = effective
|
||||
- [ ] #5 Graceful fallback to credits-only when no power data
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
id: TASK-148
|
||||
title: Concentration alerts for space admins
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:56'
|
||||
labels:
|
||||
- governance
|
||||
- encryptid
|
||||
- power-indices
|
||||
- notifications
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- src/encryptid/power-indices.ts
|
||||
- src/encryptid/trust-engine.ts
|
||||
- src/encryptid/server.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Alert space admins when power concentration exceeds healthy thresholds.
|
||||
|
||||
## What
|
||||
The power indices engine already computes Gini coefficient and HHI per space+authority every 5 minutes. Surface warnings when:
|
||||
- HHI > 0.25 (highly concentrated — fewer than ~4 effective voters)
|
||||
- Gini > 0.6 (severe inequality — top players hold most power)
|
||||
- Single player Banzhaf > 0.5 (near-dictator — one person controls majority)
|
||||
|
||||
## Primitive: Concentration Monitor
|
||||
- New function `checkConcentrationAlerts(spaceSlug)` in `power-indices.ts`
|
||||
- Called after `computeSpacePowerIndices()` in trust engine cycle
|
||||
- When threshold crossed: create notification via existing `createNotification()` for space admins
|
||||
- Notification: category='system', event_type='power_concentration_warning'
|
||||
- Debounce: only alert once per 24h per space+authority (store `last_alert_at` in power_indices or separate field)
|
||||
|
||||
## Implementation
|
||||
- `src/encryptid/power-indices.ts`: Add `checkConcentrationAlerts()` function
|
||||
- `src/encryptid/trust-engine.ts`: Call after power index computation
|
||||
- Uses existing notification system — zero new infrastructure
|
||||
- ~40 lines, zero new files, zero new tables
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Alert when HHI > 0.25, Gini > 0.6, or single-player Banzhaf > 0.5
|
||||
- [ ] #2 Notification sent to space admins via existing notification system
|
||||
- [ ] #3 24h debounce per space+authority to avoid spam
|
||||
- [ ] #4 Notification includes specific metric + suggestion (e.g. 'encourage more delegation diversity')
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
id: TASK-149
|
||||
title: Power index time-series snapshots
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:56'
|
||||
labels:
|
||||
- governance
|
||||
- analytics
|
||||
- power-indices
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- src/encryptid/schema.sql
|
||||
- src/encryptid/power-indices.ts
|
||||
- modules/rnetwork/components/folk-graph-viewer.ts
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Store daily snapshots of power concentration metrics for trend analysis.
|
||||
|
||||
## What
|
||||
Currently power_indices table overwrites on each 5-min cycle. Add a `power_snapshots` table that stores one row per space+authority per day with aggregate metrics. Enables "is power becoming more or less concentrated over time?" analysis.
|
||||
|
||||
## Primitive: Daily Snapshot Aggregation
|
||||
- New table `power_snapshots`:
|
||||
```sql
|
||||
CREATE TABLE power_snapshots (
|
||||
space_slug TEXT NOT NULL,
|
||||
authority TEXT NOT NULL,
|
||||
snapshot_date DATE NOT NULL,
|
||||
player_count INTEGER,
|
||||
gini_coefficient REAL,
|
||||
herfindahl_index REAL,
|
||||
top3_banzhaf_sum REAL,
|
||||
effective_voters REAL,
|
||||
PRIMARY KEY (space_slug, authority, snapshot_date)
|
||||
);
|
||||
```
|
||||
- In trust engine cycle: after computing power indices, check if today's snapshot exists. If not, insert.
|
||||
- One INSERT per space+authority per day — negligible DB cost.
|
||||
|
||||
## Frontend: Sparkline in power panel
|
||||
- `folk-graph-viewer.ts` power panel: fetch `GET /api/power-snapshots?space=X&authority=Y&days=30`
|
||||
- Render 30-day sparkline of Gini + HHI below the gauge metrics
|
||||
- Red trend line = concentrating, green = dispersing
|
||||
|
||||
## Implementation
|
||||
- `src/encryptid/schema.sql`: New table
|
||||
- `src/encryptid/db.ts`: `upsertPowerSnapshot()`, `getPowerSnapshots(space, authority, days)`
|
||||
- `src/encryptid/power-indices.ts`: `snapshotIfNeeded()` called from trust engine
|
||||
- `src/encryptid/server.ts`: `GET /api/power-snapshots` endpoint
|
||||
- `folk-graph-viewer.ts`: 30-day sparkline SVG in power panel
|
||||
- ~80 lines backend, ~40 lines frontend
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 power_snapshots table with daily aggregates per space+authority
|
||||
- [ ] #2 Auto-insert one snapshot per day during trust engine cycle
|
||||
- [ ] #3 API endpoint returns N days of historical snapshots
|
||||
- [ ] #4 30-day sparkline in power panel showing Gini + HHI trend
|
||||
- [ ] #5 Red/green trend coloring based on direction
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
id: TASK-150
|
||||
title: Coalition simulator UI
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-16 18:57'
|
||||
labels:
|
||||
- rnetwork
|
||||
- governance
|
||||
- power-indices
|
||||
dependencies:
|
||||
- TASK-144
|
||||
references:
|
||||
- src/encryptid/power-indices.ts
|
||||
- modules/rnetwork/components/folk-graph-viewer.ts
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Interactive coalition builder using the existing `/api/power-indices/simulate` endpoint.
|
||||
|
||||
## What
|
||||
Let users select a group of voters and instantly see: "Can this coalition pass a vote? Who is the swing voter?" Uses the simulate endpoint already built in TASK-144.
|
||||
|
||||
## Primitive: Coalition Picker Component
|
||||
- New `<folk-coalition-sim>` element (or inline in power panel)
|
||||
- Checkbox list of top N voters (sorted by Banzhaf)
|
||||
- As checkboxes toggle: POST to simulate endpoint, show result:
|
||||
- ✅ "Winning coalition (67% of weight, needs 50%+1)"
|
||||
- Per-member: "Alice: swing voter ⚡" / "Bob: not swing (coalition wins without them)"
|
||||
- "Add 1 more voter to win" suggestion when losing
|
||||
|
||||
## Implementation
|
||||
- Can be embedded in the power panel of `folk-graph-viewer.ts` as a collapsible section
|
||||
- Or standalone `folk-coalition-sim.ts` for embedding in delegation manager
|
||||
- POST `/rnetwork/api/power-indices/simulate` with `{ space, authority, coalition: [did1, did2...] }`
|
||||
- Response already returns `isWinning`, `marginalContributions[].isSwing`
|
||||
- ~80 lines, zero backend changes (endpoint exists)
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Checkbox selection of voters from top-N list
|
||||
- [ ] #2 Live POST to simulate endpoint on selection change
|
||||
- [ ] #3 Shows winning/losing status with weight vs quota
|
||||
- [ ] #4 Identifies swing voters in the coalition
|
||||
- [ ] #5 Suggests minimum additions to form winning coalition
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* rCal applet definitions — Next Event.
|
||||
* rCal applet definitions — Next Event, Timeline View.
|
||||
*/
|
||||
|
||||
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
|
||||
import { projectSpace } from "../../shared/markwhen";
|
||||
|
||||
const nextEvent: AppletDefinition = {
|
||||
id: "next-event",
|
||||
|
|
@ -35,4 +36,41 @@ const nextEvent: AppletDefinition = {
|
|||
},
|
||||
};
|
||||
|
||||
export const calApplets: AppletDefinition[] = [nextEvent];
|
||||
const timelineView: AppletDefinition = {
|
||||
id: "timeline-view",
|
||||
label: "Timeline",
|
||||
icon: "🕒",
|
||||
accentColor: "#2563eb",
|
||||
ports: [
|
||||
{ name: "range-in", type: "json", direction: "input" },
|
||||
{ name: "mw-out", type: "json", direction: "output" },
|
||||
],
|
||||
renderCompact(data: AppletLiveData): string {
|
||||
const count = Number(data.snapshot.count ?? 0);
|
||||
const preview = (data.snapshot.preview as string) || "";
|
||||
return `
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${count} event(s)</div>
|
||||
<div style="font-size:11px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${preview || "Open to render timeline"}</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
async fetchLiveData(space: string) {
|
||||
const projection = await projectSpace(space, { modules: ["rcal"] });
|
||||
const first = projection.events[0];
|
||||
return {
|
||||
count: projection.count,
|
||||
preview: first ? first.title : "",
|
||||
mw: projection.text,
|
||||
};
|
||||
},
|
||||
onInputReceived(portName, value, ctx) {
|
||||
if (portName === "range-in" && value && typeof value === "object") {
|
||||
const range = value as { from?: number; to?: number };
|
||||
projectSpace(ctx.space, { modules: ["rcal"], from: range.from, to: range.to })
|
||||
.then(p => ctx.emitOutput("mw-out", p.text));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const calApplets: AppletDefinition[] = [nextEvent, timelineView];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* <rpast-viewer> — Chronicle-of-self timeline, rendered as a canvas shape.
|
||||
*
|
||||
* Hits the server-rendered `/:space/rpast/render` endpoint via iframe for
|
||||
* the actual timeline/calendar visualization. The chip bar drives query
|
||||
* params; the iframe reloads on every change.
|
||||
*
|
||||
* Attribute: space — the space slug
|
||||
*/
|
||||
|
||||
import '../../../shared/components/rstack-markwhen-view';
|
||||
|
||||
type ViewMode = 'timeline' | 'calendar';
|
||||
|
||||
interface ModuleInfo { module: string; label: string; icon: string; color?: string; }
|
||||
|
||||
export class RpastViewer extends HTMLElement {
|
||||
static get observedAttributes() { return ['space']; }
|
||||
|
||||
#space = '';
|
||||
#selected: Set<string> | null = null; // null = "all"
|
||||
#mode: ViewMode = 'timeline';
|
||||
#modules: ModuleInfo[] = [];
|
||||
#shadow: ShadowRoot;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||
this.#shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: grid; grid-template-rows: auto 1fr auto; height: 100%; width: 100%; }
|
||||
.bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
padding: 6px 10px; background: #0f172a; border-bottom: 1px solid #1e293b;
|
||||
font: 12px system-ui; color: #cbd5e1; }
|
||||
.chip { padding: 2px 10px; border-radius: 999px; background: #1e293b;
|
||||
cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
.chip[aria-pressed="true"] { background: #2563eb; color: white; }
|
||||
.spacer { flex: 1; }
|
||||
.toggle { display: flex; gap: 4px; }
|
||||
.toggle button { border: 0; background: #1e293b; color: #cbd5e1; padding: 2px 10px;
|
||||
border-radius: 4px; font: 12px system-ui; cursor: pointer; }
|
||||
.toggle button[aria-pressed="true"] { background: #2563eb; color: white; }
|
||||
.foot { display: flex; gap: 16px; padding: 4px 10px; font: 11px system-ui;
|
||||
color: #94a3b8; background: #0f172a; border-top: 1px solid #1e293b; }
|
||||
.foot a { color: #60a5fa; text-decoration: none; }
|
||||
.foot a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
<div class="bar"></div>
|
||||
<rstack-markwhen-view></rstack-markwhen-view>
|
||||
<div class="foot"><span class="count">Loading…</span><span class="spacer"></span><a class="download" href="#">Download .mw</a></div>
|
||||
`;
|
||||
}
|
||||
|
||||
set space(v: string) { this.#space = v; void this.#init(); }
|
||||
get space() { return this.#space; }
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
|
||||
if (name === 'space') this.space = next ?? '';
|
||||
}
|
||||
|
||||
connectedCallback() { void this.#init(); }
|
||||
|
||||
async #init() {
|
||||
if (!this.#space) return;
|
||||
try {
|
||||
// The modules endpoint is space-agnostic; any space path works.
|
||||
const res = await fetch(`/${this.#space}/rpast/api/modules`);
|
||||
this.#modules = await res.json();
|
||||
} catch (err) {
|
||||
console.error('[rpast-viewer] failed to load module list', err);
|
||||
this.#modules = [];
|
||||
}
|
||||
this.#paintBar();
|
||||
await this.#reload();
|
||||
}
|
||||
|
||||
#paintBar() {
|
||||
const bar = this.#shadow.querySelector('.bar')!;
|
||||
bar.innerHTML = '';
|
||||
|
||||
const allChip = document.createElement('span');
|
||||
allChip.className = 'chip';
|
||||
allChip.setAttribute('aria-pressed', String(this.#selected === null));
|
||||
allChip.textContent = '✨ All';
|
||||
allChip.addEventListener('click', () => { this.#selected = null; this.#paintBar(); void this.#reload(); });
|
||||
bar.appendChild(allChip);
|
||||
|
||||
for (const m of this.#modules) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
const active = this.#selected !== null && this.#selected.has(m.module);
|
||||
chip.setAttribute('aria-pressed', String(active));
|
||||
chip.textContent = `${m.icon} ${m.label}`;
|
||||
chip.addEventListener('click', () => this.#toggle(m.module));
|
||||
bar.appendChild(chip);
|
||||
}
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'spacer';
|
||||
bar.appendChild(spacer);
|
||||
|
||||
const toggle = document.createElement('div');
|
||||
toggle.className = 'toggle';
|
||||
for (const mode of ['timeline', 'calendar'] as const) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = mode[0].toUpperCase() + mode.slice(1);
|
||||
btn.setAttribute('aria-pressed', String(this.#mode === mode));
|
||||
btn.addEventListener('click', () => { this.#mode = mode; this.#paintBar(); void this.#reload(); });
|
||||
toggle.appendChild(btn);
|
||||
}
|
||||
bar.appendChild(toggle);
|
||||
}
|
||||
|
||||
#toggle(module: string) {
|
||||
if (this.#selected === null) this.#selected = new Set([module]);
|
||||
else if (this.#selected.has(module)) {
|
||||
this.#selected.delete(module);
|
||||
if (this.#selected.size === 0) this.#selected = null;
|
||||
} else this.#selected.add(module);
|
||||
this.#paintBar();
|
||||
void this.#reload();
|
||||
}
|
||||
|
||||
#buildUrl(base: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (this.#selected) params.set('modules', [...this.#selected].join(','));
|
||||
params.set('view', this.#mode);
|
||||
return `/${this.#space}/rpast/${base}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async #reload() {
|
||||
const view = this.#shadow.querySelector('rstack-markwhen-view') as HTMLElement & {
|
||||
src?: string; view?: ViewMode;
|
||||
};
|
||||
view.view = this.#mode;
|
||||
view.src = this.#buildUrl('render');
|
||||
|
||||
const dl = this.#shadow.querySelector('.download') as HTMLAnchorElement;
|
||||
dl.href = this.#buildUrl('api/chronicle.mw');
|
||||
dl.download = `${this.#space}-rpast.mw`;
|
||||
|
||||
try {
|
||||
const res = await fetch(this.#buildUrl('api/chronicle'));
|
||||
const data = await res.json() as { count: number; sections: { id: string; count: number }[] };
|
||||
const foot = this.#shadow.querySelector('.count') as HTMLElement;
|
||||
foot.textContent = `${data.count.toLocaleString()} creation${data.count === 1 ? '' : 's'} across ${data.sections.length} module${data.sections.length === 1 ? '' : 's'}`;
|
||||
} catch (err) {
|
||||
console.error('[rpast-viewer] count fetch failed', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('rpast-viewer')) {
|
||||
customElements.define('rpast-viewer', RpastViewer);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* rPast landing — chronicle-of-self viewer.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline">rPast</span>
|
||||
<h1 class="rl-heading">Every creation, on one timeline.</h1>
|
||||
<p class="rl-subtitle">
|
||||
Your notes, events, tasks, votes, photos, files, trips, messages — all dated records from every rApp, unified in a single chronicle-of-self you can browse, filter, and share.
|
||||
</p>
|
||||
<p class="rl-subtext">
|
||||
rPast reads the canonical state of every rApp in your space and projects it onto a markwhen timeline. Zero extra storage, zero drift: the chronicle is always a live projection of what you've actually made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<rpast-viewer id="rpast-main" style="height: 70vh; display:block;"></rpast-viewer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="module" src="/modules/rpast/rpast-viewer.js?v=1"></script>
|
||||
<script type="module">
|
||||
const el = document.getElementById('rpast-main');
|
||||
const space = (location.hostname.split('.')[0]) || 'demo';
|
||||
el.setAttribute('space', space);
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* rPast — chronicle-of-self timeline.
|
||||
*
|
||||
* Projects every dated CRDT record across every rApp into a single
|
||||
* timeline/calendar. Zero writes to source modules. The `.mw` text is
|
||||
* regenerated on every request; saved "chronicles" are just filter configs.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { RSpaceModule } from '../../shared/module';
|
||||
import {
|
||||
enumerateCreations, listCreationEnumerators, renderMarkwhen,
|
||||
} from '../../shared/markwhen';
|
||||
import { renderMarkwhenHtml } from '../../shared/markwhen/html-render';
|
||||
import { pastSchema } from './schemas';
|
||||
import { renderLanding } from './landing';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get('/api/modules', c => {
|
||||
return c.json(listCreationEnumerators().map(e => ({
|
||||
module: e.module, label: e.label, icon: e.icon, color: e.color,
|
||||
})));
|
||||
});
|
||||
|
||||
async function buildProjection(c: any) {
|
||||
const space = c.req.param('space');
|
||||
const modules = c.req.query('modules')?.split(',').filter(Boolean);
|
||||
const from = c.req.query('from') ? Number(c.req.query('from')) : undefined;
|
||||
const to = c.req.query('to') ? Number(c.req.query('to')) : undefined;
|
||||
const view = (c.req.query('view') as 'timeline' | 'calendar') ?? 'timeline';
|
||||
|
||||
// Derive the public base URL from the request so in-timeline "Open in
|
||||
// rApp" links resolve against the same origin the user arrived from.
|
||||
const host = c.req.header('x-forwarded-host') ?? c.req.header('host');
|
||||
const proto = c.req.header('x-forwarded-proto') ?? (host?.includes('localhost') ? 'http' : 'https');
|
||||
const baseUrl = host ? `${proto}://${host}` : undefined;
|
||||
|
||||
const sources = await enumerateCreations(space, { modules, from, to });
|
||||
const projection = renderMarkwhen(sources, {
|
||||
view, title: `${space} — rPast`, baseUrl,
|
||||
});
|
||||
return { space, sources, projection, view };
|
||||
}
|
||||
|
||||
routes.get('/api/chronicle', async c => {
|
||||
const { sources, projection } = await buildProjection(c);
|
||||
return c.json({
|
||||
text: projection.text,
|
||||
count: projection.count,
|
||||
sections: sources.map(s => ({ id: s.id, label: s.label, count: s.events.length })),
|
||||
});
|
||||
});
|
||||
|
||||
routes.get('/api/chronicle.mw', async c => {
|
||||
const { projection } = await buildProjection(c);
|
||||
return c.text(projection.text, 200, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
});
|
||||
|
||||
routes.get('/render', async c => {
|
||||
const { projection, view } = await buildProjection(c);
|
||||
const html = renderMarkwhenHtml(projection.text, view);
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
export const rpastModule: RSpaceModule = {
|
||||
id: 'rpast',
|
||||
name: 'rPast',
|
||||
icon: '🕰️',
|
||||
description: 'Chronicle-of-self — every rApp creation, ever, on one timeline.',
|
||||
routes,
|
||||
landingPage: renderLanding,
|
||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||
docSchemas: [{
|
||||
pattern: '{space}:rpast:chronicles',
|
||||
description: 'Saved chronicle configs (module selection + filters + mode)',
|
||||
init: pastSchema.init,
|
||||
}],
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* rPast Automerge schemas — chronicle-of-self viewer.
|
||||
*
|
||||
* DocId format: {space}:rpast:chronicles
|
||||
*
|
||||
* A chronicle is a saved configuration over the universal creation log:
|
||||
* - which modules to include (defaults: all registered)
|
||||
* - date window
|
||||
* - record-type filter
|
||||
* - tag filter
|
||||
* - view mode
|
||||
*
|
||||
* The projected `.mw` text is NEVER persisted — it is re-derived from
|
||||
* canonical CRDT state every open. This guarantees rPast can never drift
|
||||
* from source-of-truth.
|
||||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
|
||||
export interface Chronicle {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
/** Module ids included, in render-order (becomes section order). */
|
||||
modules: string[];
|
||||
/** Optional record-type narrowing (e.g. only "note" + "event"). */
|
||||
recordTypes: string[];
|
||||
/** Tag allow-list (match-any). Empty = no tag filter. */
|
||||
tags: string[];
|
||||
/** Unix ms; null = unbounded. */
|
||||
fromMs: number | null;
|
||||
toMs: number | null;
|
||||
viewMode: 'timeline' | 'calendar' | 'gantt';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PastDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
chronicles: Record<string, Chronicle>;
|
||||
}
|
||||
|
||||
export const pastSchema: DocSchema<PastDoc> = {
|
||||
module: 'rpast',
|
||||
collection: 'chronicles',
|
||||
version: 1,
|
||||
init: (): PastDoc => ({
|
||||
meta: {
|
||||
module: 'rpast',
|
||||
collection: 'chronicles',
|
||||
version: 1,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
chronicles: {},
|
||||
}),
|
||||
migrate: (doc: PastDoc, _fromVersion: number): PastDoc => doc,
|
||||
};
|
||||
|
||||
export function pastDocId(space: string) {
|
||||
return `${space}:rpast:chronicles` as const;
|
||||
}
|
||||
|
|
@ -1050,14 +1050,158 @@ folk-campaign-planner {
|
|||
filter: drop-shadow(0 0 2px #6366f1);
|
||||
}
|
||||
|
||||
/* ── Mobile: brief panel ── */
|
||||
@media (max-width: 768px) {
|
||||
.cp-brief-panel.open {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
/* ── Preview banner (brief-generated nodes awaiting confirmation) ── */
|
||||
.cp-preview-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 16px;
|
||||
margin: 8px 12px 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(236, 72, 153, 0.15), rgba(139, 92, 246, 0.15));
|
||||
border: 1px solid rgba(236, 72, 153, 0.35);
|
||||
}
|
||||
.cp-preview-banner__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f9a8d4;
|
||||
}
|
||||
.cp-preview-banner__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Preview nodes: dashed outline + pulse ── */
|
||||
.cp-node--preview {
|
||||
animation: cp-preview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.cp-node--preview foreignObject > div {
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 0 12px rgba(236, 72, 153, 0.4);
|
||||
border-radius: 10px;
|
||||
}
|
||||
@keyframes cp-preview-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
/* ── Primary button ── */
|
||||
.cp-btn--primary {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.cp-btn--primary:hover {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
/* ── Modal overlay + modal (import) ── */
|
||||
.cp-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.cp-modal-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.cp-modal {
|
||||
background: var(--rs-bg-surface, #1e293b);
|
||||
border: 1px solid var(--rs-input-border, #334155);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: 92%;
|
||||
max-width: 580px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cp-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.cp-modal__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--rs-text-primary, #f1f5f9);
|
||||
}
|
||||
.cp-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.cp-modal__close:hover {
|
||||
color: var(--rs-text-primary, #f1f5f9);
|
||||
}
|
||||
.cp-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rs-text-secondary, #94a3b8);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cp-modal__hint code {
|
||||
background: var(--rs-input-bg, #0f172a);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.cp-modal__textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
min-height: 180px;
|
||||
background: var(--rs-input-bg, #0f172a);
|
||||
color: var(--rs-input-text, #f1f5f9);
|
||||
border: 1px solid var(--rs-input-border, #334155);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cp-modal__textarea:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.cp-modal__row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cp-modal__label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--rs-text-secondary, #94a3b8);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cp-modal__select {
|
||||
background: var(--rs-input-bg, #0f172a);
|
||||
color: var(--rs-input-text, #f1f5f9);
|
||||
border: 1px solid var(--rs-input-border, #334155);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
min-width: 140px;
|
||||
}
|
||||
.cp-modal__select:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
/* ── Mobile: modal ── */
|
||||
@media (max-width: 768px) {
|
||||
.cp-modal {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
GoalNodeData,
|
||||
MessageNodeData,
|
||||
ToneNodeData,
|
||||
BriefNodeData,
|
||||
} from '../schemas';
|
||||
import { SocialsLocalFirstClient } from '../local-first-client';
|
||||
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
|
||||
|
|
@ -68,6 +69,9 @@ const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
|
|||
tone: [
|
||||
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
|
||||
],
|
||||
brief: [
|
||||
{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#ec4899', connectsTo: ['feeds-in', 'sequence-in'] },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Edge type → visual config ──
|
||||
|
|
@ -101,6 +105,7 @@ function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } {
|
|||
case 'goal': return { w: 240, h: 130 };
|
||||
case 'message': return { w: 220, h: 100 };
|
||||
case 'tone': return { w: 220, h: 110 };
|
||||
case 'brief': return { w: 260, h: 150 };
|
||||
default: return { w: 200, h: 100 };
|
||||
}
|
||||
}
|
||||
|
|
@ -198,11 +203,21 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
// Postiz
|
||||
private postizOpen = false;
|
||||
|
||||
// Brief panel
|
||||
private briefPanelOpen = false;
|
||||
private briefText = '';
|
||||
private briefPlatforms: string[] = ['x', 'linkedin', 'instagram', 'threads', 'bluesky'];
|
||||
private briefLoading = false;
|
||||
// Brief node generation state (keyed by node id)
|
||||
private briefLoading = new Set<string>();
|
||||
|
||||
// Preview state for brief-generated nodes (not yet persisted)
|
||||
private previewNodeIds = new Set<string>();
|
||||
private previewEdgeIds = new Set<string>();
|
||||
private previewSourceBriefId = '';
|
||||
|
||||
// Import modal state
|
||||
private importModalOpen = false;
|
||||
private importText = '';
|
||||
private importPlatform = 'x';
|
||||
|
||||
// Explicit flow id requested via attribute (overrides active flow)
|
||||
private requestedFlowId = '';
|
||||
|
||||
// Persistence
|
||||
private localFirstClient: SocialsLocalFirstClient | null = null;
|
||||
|
|
@ -230,6 +245,7 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute('space') || 'demo';
|
||||
this.requestedFlowId = this.getAttribute('flow-id') || '';
|
||||
this.initData();
|
||||
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' }));
|
||||
}
|
||||
|
|
@ -256,8 +272,17 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
if (!this.currentFlowId || this.saveTimer) return;
|
||||
const flow = doc.campaignFlows?.[this.currentFlowId];
|
||||
if (flow) {
|
||||
this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
|
||||
this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
|
||||
// Preserve uncommitted preview nodes/edges when applying remote updates
|
||||
const previewNodes = this.nodes.filter(n => this.previewNodeIds.has(n.id));
|
||||
const previewEdges = this.edges.filter(e => this.previewEdgeIds.has(e.id));
|
||||
this.nodes = [
|
||||
...flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||
...previewNodes,
|
||||
];
|
||||
this.edges = [
|
||||
...flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })),
|
||||
...previewEdges,
|
||||
];
|
||||
if (this.currentView === 'canvas') {
|
||||
this.drawCanvasContent();
|
||||
} else {
|
||||
|
|
@ -269,7 +294,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
const activeId = this.localFirstClient.getActiveFlowId();
|
||||
const flows = this.localFirstClient.listCampaignFlows();
|
||||
|
||||
if (activeId && this.localFirstClient.getCampaignFlow(activeId)) {
|
||||
if (this.requestedFlowId && this.localFirstClient.getCampaignFlow(this.requestedFlowId)) {
|
||||
this.loadFlow(this.requestedFlowId);
|
||||
} else if (activeId && this.localFirstClient.getCampaignFlow(activeId)) {
|
||||
this.loadFlow(activeId);
|
||||
} else if (flows.length > 0) {
|
||||
this.loadFlow(flows[0].id);
|
||||
|
|
@ -315,7 +342,14 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
|
||||
private executeSave() {
|
||||
if (this.localFirstClient && this.currentFlowId) {
|
||||
this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges);
|
||||
// Exclude preview-only nodes/edges from persistence until confirmed
|
||||
const committedNodes = this.previewNodeIds.size > 0
|
||||
? this.nodes.filter(n => !this.previewNodeIds.has(n.id))
|
||||
: this.nodes;
|
||||
const committedEdges = this.previewEdgeIds.size > 0
|
||||
? this.edges.filter(e => !this.previewEdgeIds.has(e.id))
|
||||
: this.edges;
|
||||
this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, committedNodes, committedEdges);
|
||||
} else if (this.currentFlowId) {
|
||||
localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({
|
||||
id: this.currentFlowId, name: this.flowName,
|
||||
|
|
@ -662,6 +696,9 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
case 'tone':
|
||||
data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' };
|
||||
break;
|
||||
case 'brief':
|
||||
data = { label: 'Campaign Brief', text: '', platforms: ['x', 'linkedin', 'instagram', 'threads', 'bluesky'] };
|
||||
break;
|
||||
}
|
||||
const node: CampaignPlannerNode = { id, type, position: { x, y }, data };
|
||||
this.nodes.push(node);
|
||||
|
|
@ -709,7 +746,7 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
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 : 220;
|
||||
const panelH = node.type === 'post' ? 380 : 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;
|
||||
|
||||
|
|
@ -893,6 +930,28 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
</div>`;
|
||||
break;
|
||||
}
|
||||
case 'brief': {
|
||||
const d = node.data as BriefNodeData;
|
||||
const loading = this.briefLoading.has(node.id);
|
||||
const plats = d.platforms || [];
|
||||
body = `
|
||||
<div class="cp-icp-body">
|
||||
<label>Campaign Brief</label>
|
||||
<textarea data-field="text" rows="6" placeholder="Describe your campaign: goal, audience, timeline, key messages...">${esc(d.text)}</textarea>
|
||||
<label>Platforms</label>
|
||||
<div class="cp-platform-checkboxes">
|
||||
${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p =>
|
||||
`<label class="cp-platform-check">
|
||||
<input type="checkbox" data-brief-platform="${p}" ${plats.includes(p) ? 'checked' : ''}/>
|
||||
${p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</label>`
|
||||
).join('')}
|
||||
</div>
|
||||
<button class="cp-btn--generate" data-action="generate-brief" ${loading || !d.text.trim() ? 'disabled' : ''}>${loading ? 'Generating...' : '\u2728 Generate Campaign'}</button>
|
||||
${d.lastGeneratedAt ? `<div style="font-size:10px;color:var(--rs-text-muted);margin-top:6px">Last generated ${new Date(d.lastGeneratedAt).toLocaleString()}</div>` : ''}
|
||||
</div>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// AI fill button for post/thread nodes
|
||||
|
|
@ -952,8 +1011,15 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
if (field === 'text' && node.type === 'message') {
|
||||
(node.data as MessageNodeData).label = val.substring(0, 40);
|
||||
}
|
||||
if (field === 'text' && node.type === 'brief') {
|
||||
const firstLine = val.split('\n')[0].substring(0, 40).trim();
|
||||
(node.data as BriefNodeData).label = firstLine || 'Campaign Brief';
|
||||
// Live-toggle the Generate button disabled state
|
||||
const genBtn = panel.querySelector('[data-action="generate-brief"]') as HTMLButtonElement | null;
|
||||
if (genBtn && !this.briefLoading.has(node.id)) genBtn.disabled = !val.trim();
|
||||
}
|
||||
// Mark downstream nodes stale when input nodes change
|
||||
if (node.type === 'goal' || node.type === 'message' || node.type === 'tone') {
|
||||
if (node.type === 'goal' || node.type === 'message' || node.type === 'tone' || node.type === 'brief') {
|
||||
this.markDownstreamStale(node.id);
|
||||
}
|
||||
this.scheduleSave();
|
||||
|
|
@ -962,6 +1028,19 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
el.addEventListener('change', handler);
|
||||
});
|
||||
|
||||
// Brief platform checkboxes
|
||||
panel.querySelectorAll('[data-brief-platform]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const picked: string[] = [];
|
||||
panel.querySelectorAll('[data-brief-platform]:checked').forEach(c => {
|
||||
const name = (c as HTMLInputElement).getAttribute('data-brief-platform');
|
||||
if (name) picked.push(name);
|
||||
});
|
||||
(node.data as BriefNodeData).platforms = picked;
|
||||
this.scheduleSave();
|
||||
});
|
||||
});
|
||||
|
||||
// Actions
|
||||
panel.querySelectorAll('[data-action]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
|
|
@ -979,6 +1058,8 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
}
|
||||
} else if (action === 'ai-fill') {
|
||||
this.aiFillNode(node.id);
|
||||
} else if (action === 'generate-brief') {
|
||||
this.generateFromBriefNode(node.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -994,6 +1075,7 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
case 'goal': return '<span style="font-size:14px">🎯</span>';
|
||||
case 'message': return '<span style="font-size:14px">💬</span>';
|
||||
case 'tone': return '<span style="font-size:14px">🎭</span>';
|
||||
case 'brief': return '<span style="font-size:14px">✨</span>';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -1397,7 +1479,8 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
</button>
|
||||
</div>
|
||||
<div class="cp-toolbar__actions">
|
||||
<button class="cp-btn cp-btn--brief ${this.briefPanelOpen ? 'active' : ''}" id="toggle-brief">From Brief</button>
|
||||
<button class="cp-btn cp-btn--brief" id="add-brief">\u2728 + Brief</button>
|
||||
<button class="cp-btn cp-btn--add" id="open-import">\u{1f4e5} Import</button>
|
||||
<button class="cp-btn cp-btn--regen" id="regen-stale-btn" style="display:${this.getStaleCount() > 0 ? '' : 'none'}">\u26a0 Regen ${this.getStaleCount()} Stale</button>
|
||||
<button class="cp-btn cp-btn--add" id="add-post">+ Post</button>
|
||||
<button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button>
|
||||
|
|
@ -1406,27 +1489,18 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
${this.previewNodeIds.size > 0 ? `
|
||||
<div class="cp-preview-banner">
|
||||
<span class="cp-preview-banner__label">\u2728 AI-generated preview \u2014 ${this.previewNodeIds.size} node${this.previewNodeIds.size === 1 ? '' : 's'}</span>
|
||||
<div class="cp-preview-banner__actions">
|
||||
<button class="cp-btn cp-btn--primary" id="preview-keep">Keep</button>
|
||||
<button class="cp-btn cp-btn--brief" id="preview-regen">Regenerate</button>
|
||||
<button class="cp-btn cp-btn--add" id="preview-discard">Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="cp-canvas-area">
|
||||
<div class="cp-brief-panel ${this.briefPanelOpen ? 'open' : ''}" id="brief-panel">
|
||||
<div class="cp-brief-header">
|
||||
<span class="cp-brief-title">\u2728 Generate from Brief</span>
|
||||
<button class="cp-brief-close" id="close-brief">\u2715</button>
|
||||
</div>
|
||||
<div class="cp-brief-body">
|
||||
<label>Campaign Brief</label>
|
||||
<textarea id="brief-text" placeholder="Describe your campaign: goal, audience, timeline, key messages...">${esc(this.briefText)}</textarea>
|
||||
<label>Platforms</label>
|
||||
<div class="cp-platform-checkboxes">
|
||||
${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p =>
|
||||
`<label class="cp-platform-check">
|
||||
<input type="checkbox" value="${p}" ${this.briefPlatforms.includes(p) ? 'checked' : ''}/>
|
||||
${p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</label>`
|
||||
).join('')}
|
||||
</div>
|
||||
<button class="cp-btn--generate" id="brief-generate" ${this.briefLoading ? 'disabled' : ''}>${this.briefLoading ? 'Generating...' : 'Generate Graph'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cp-canvas" id="cp-canvas">
|
||||
<svg id="cp-svg" width="100%" height="100%">
|
||||
<defs>
|
||||
|
|
@ -1457,6 +1531,26 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
<iframe class="cp-postiz-iframe" src="${this.postizOpen ? schedulerUrl : 'about:blank'}" title="Postiz"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cp-modal-overlay" id="import-modal" ${this.importModalOpen ? '' : 'hidden'}>
|
||||
<div class="cp-modal">
|
||||
<div class="cp-modal__header">
|
||||
<h3>Import Posts from Markdown</h3>
|
||||
<button class="cp-modal__close" id="import-close">\u00d7</button>
|
||||
</div>
|
||||
<p class="cp-modal__hint">Paste tweets or posts separated by <code>---</code> on its own line. Each becomes a post node wired to the selected platform.</p>
|
||||
<textarea class="cp-modal__textarea" id="import-text" placeholder="First tweet\n---\nSecond tweet\n---\nThird tweet">${esc(this.importText)}</textarea>
|
||||
<div class="cp-modal__row">
|
||||
<label class="cp-modal__label">Platform</label>
|
||||
<select class="cp-modal__select" id="import-platform">
|
||||
${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
|
||||
`<option value="${p}" ${this.importPlatform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button class="cp-btn cp-btn--primary" id="import-submit">Parse & Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -1503,12 +1597,14 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
case 'goal': inner = this.renderGoalNodeInner(node); break;
|
||||
case 'message': inner = this.renderMessageNodeInner(node); break;
|
||||
case 'tone': inner = this.renderToneNodeInner(node); break;
|
||||
case 'brief': inner = this.renderBriefNodeInner(node); break;
|
||||
}
|
||||
|
||||
const ports = this.renderPortsSvg(node);
|
||||
const previewClass = this.previewNodeIds.has(node.id) ? ' cp-node--preview' : '';
|
||||
|
||||
return `
|
||||
<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}" data-node-id="${node.id}" data-node-type="${node.type}">
|
||||
<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}${previewClass}" data-node-id="${node.id}" data-node-type="${node.type}">
|
||||
<foreignObject x="${x}" y="${y}" width="${s.w}" height="${s.h}">
|
||||
${inner}
|
||||
</foreignObject>
|
||||
|
|
@ -1629,6 +1725,27 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
private renderBriefNodeInner(node: CampaignPlannerNode): string {
|
||||
const d = node.data as BriefNodeData;
|
||||
const loading = this.briefLoading.has(node.id);
|
||||
const plats = d.platforms || [];
|
||||
const preview = (d.text || '').split('\n')[0].substring(0, 80);
|
||||
const platPills = plats.slice(0, 4).map(p => `<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">${p}</span>`).join('');
|
||||
const extra = plats.length > 4 ? `<span style="font-size:9px;color:var(--rs-text-muted)">+${plats.length - 4}</span>` : '';
|
||||
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#ec48990a;border:1px solid #ec489944;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
|
||||
<div style="width:4px;background:#ec4899;flex-shrink:0"></div>
|
||||
<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span style="font-size:14px">✨</span>
|
||||
<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Campaign Brief')}</span>
|
||||
${loading ? '<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">generating…</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview) || '<em>Click to write a brief</em>'}</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-top:auto">${platPills}${extra}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderToneNodeInner(node: CampaignPlannerNode): string {
|
||||
const d = node.data as ToneNodeData;
|
||||
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#8b5cf60a;border:1px solid #8b5cf633;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
|
||||
|
|
@ -1670,20 +1787,29 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
return this.nodes.filter(n => n.stale).length;
|
||||
}
|
||||
|
||||
// ── Brief panel methods ──
|
||||
// ── Brief-node generation ──
|
||||
|
||||
private async generateFromBrief() {
|
||||
if (this.briefLoading || !this.briefText.trim()) return;
|
||||
this.briefLoading = true;
|
||||
this.updateBriefPanel();
|
||||
private async generateFromBriefNode(briefNodeId: string, options?: { discardPrevious?: boolean }) {
|
||||
const briefNode = this.nodes.find(n => n.id === briefNodeId);
|
||||
if (!briefNode || briefNode.type !== 'brief') return;
|
||||
if (this.briefLoading.has(briefNodeId)) return;
|
||||
const d = briefNode.data as BriefNodeData;
|
||||
if (!d.text.trim()) return;
|
||||
|
||||
if (options?.discardPrevious) this.discardPreviewInternal();
|
||||
|
||||
this.briefLoading.add(briefNodeId);
|
||||
this.exitInlineEdit();
|
||||
this.enterInlineEdit(briefNodeId);
|
||||
this.drawCanvasContent();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rawBrief: this.briefText,
|
||||
platforms: this.briefPlatforms,
|
||||
rawBrief: d.text,
|
||||
platforms: d.platforms,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -1693,22 +1819,167 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
}
|
||||
|
||||
const flow: CampaignFlow = await res.json();
|
||||
this.currentFlowId = flow.id;
|
||||
this.flowName = flow.name;
|
||||
this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
|
||||
this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
|
||||
this.briefPanelOpen = false;
|
||||
|
||||
// Offset generated nodes to the right of the brief so the brief stays visible
|
||||
const offsetX = briefNode.position.x + 300;
|
||||
const offsetY = briefNode.position.y;
|
||||
const generated = flow.nodes.map(n => ({
|
||||
...n,
|
||||
position: { x: n.position.x + offsetX, y: n.position.y + offsetY },
|
||||
data: { ...n.data },
|
||||
}));
|
||||
d.lastGeneratedAt = Date.now();
|
||||
|
||||
// brief → first goal (if present)
|
||||
const generatedEdges: CampaignEdge[] = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
|
||||
const firstGoal = generated.find(n => n.type === 'goal');
|
||||
if (firstGoal) {
|
||||
generatedEdges.push({
|
||||
id: `e-brief-${Date.now()}-goal`,
|
||||
from: briefNode.id,
|
||||
to: firstGoal.id,
|
||||
type: 'feeds',
|
||||
});
|
||||
}
|
||||
|
||||
// Append generated nodes/edges to the CURRENT flow as preview (not persisted)
|
||||
this.previewNodeIds = new Set(generated.map(n => n.id));
|
||||
this.previewEdgeIds = new Set(generatedEdges.map(e => e.id));
|
||||
this.previewSourceBriefId = briefNode.id;
|
||||
|
||||
this.nodes = [...this.nodes, ...generated];
|
||||
this.edges = [...this.edges, ...generatedEdges];
|
||||
|
||||
this.exitInlineEdit();
|
||||
this.render();
|
||||
requestAnimationFrame(() => this.fitView());
|
||||
} catch (e: any) {
|
||||
console.error('[CampaignPlanner] Generate from brief error:', e.message);
|
||||
alert('Failed to generate: ' + e.message);
|
||||
} finally {
|
||||
this.briefLoading = false;
|
||||
this.briefLoading.delete(briefNodeId);
|
||||
// Re-render to clear loading state in the brief node
|
||||
this.drawCanvasContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview actions ──
|
||||
|
||||
private confirmPreview() {
|
||||
if (this.previewNodeIds.size === 0) return;
|
||||
this.previewNodeIds.clear();
|
||||
this.previewEdgeIds.clear();
|
||||
this.previewSourceBriefId = '';
|
||||
// Persist immediately (not debounced)
|
||||
if (this.localFirstClient && this.currentFlowId) {
|
||||
this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
private discardPreviewInternal() {
|
||||
if (this.previewNodeIds.size === 0 && this.previewEdgeIds.size === 0) return;
|
||||
this.nodes = this.nodes.filter(n => !this.previewNodeIds.has(n.id));
|
||||
this.edges = this.edges.filter(e => !this.previewEdgeIds.has(e.id));
|
||||
this.previewNodeIds.clear();
|
||||
this.previewEdgeIds.clear();
|
||||
this.previewSourceBriefId = '';
|
||||
}
|
||||
|
||||
private discardPreview() {
|
||||
this.discardPreviewInternal();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private regeneratePreview() {
|
||||
const briefId = this.previewSourceBriefId;
|
||||
if (!briefId) return;
|
||||
this.generateFromBriefNode(briefId, { discardPrevious: true });
|
||||
}
|
||||
|
||||
// ── Markdown import ──
|
||||
|
||||
private openImportModal() {
|
||||
this.importModalOpen = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private closeImportModal() {
|
||||
this.importModalOpen = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private importFromMarkdown() {
|
||||
const raw = this.importText || '';
|
||||
const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
|
||||
if (tweets.length === 0) {
|
||||
alert('No posts to import — separate tweets with lines containing just ---');
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
|
||||
const rect = svg?.getBoundingClientRect();
|
||||
const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 300;
|
||||
const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 200;
|
||||
|
||||
const platform = this.importPlatform || 'x';
|
||||
|
||||
// Find or create a platform node for this platform
|
||||
let platformNode = this.nodes.find(n =>
|
||||
n.type === 'platform' && (n.data as PlatformNodeData).platform === platform
|
||||
);
|
||||
const newNodes: CampaignPlannerNode[] = [];
|
||||
const newEdges: CampaignEdge[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
if (!platformNode) {
|
||||
const platId = `platform-${now}-${platform}`;
|
||||
platformNode = {
|
||||
id: platId,
|
||||
type: 'platform',
|
||||
position: { x: cx + 600, y: cy },
|
||||
data: { label: platform.charAt(0).toUpperCase() + platform.slice(1), platform, handle: '' } as PlatformNodeData,
|
||||
};
|
||||
newNodes.push(platformNode);
|
||||
}
|
||||
|
||||
// Grid-position imported posts in a 3-wide column
|
||||
tweets.forEach((text, i) => {
|
||||
const postId = `post-${now}-${i}`;
|
||||
const col = i % 3;
|
||||
const row = Math.floor(i / 3);
|
||||
newNodes.push({
|
||||
id: postId,
|
||||
type: 'post',
|
||||
position: { x: cx + col * 260, y: cy + row * 140 },
|
||||
data: {
|
||||
label: text.split('\n')[0].substring(0, 40) || 'Imported post',
|
||||
platform,
|
||||
postType: 'text',
|
||||
content: text,
|
||||
scheduledAt: '',
|
||||
status: 'draft',
|
||||
hashtags: [],
|
||||
} as PostNodeData,
|
||||
});
|
||||
newEdges.push({
|
||||
id: `e-import-${now}-${i}`,
|
||||
from: postId,
|
||||
to: platformNode!.id,
|
||||
type: 'publish',
|
||||
});
|
||||
});
|
||||
|
||||
this.nodes = [...this.nodes, ...newNodes];
|
||||
this.edges = [...this.edges, ...newEdges];
|
||||
|
||||
this.importText = '';
|
||||
this.importModalOpen = false;
|
||||
this.render();
|
||||
this.scheduleSave();
|
||||
requestAnimationFrame(() => this.fitView());
|
||||
}
|
||||
|
||||
private async regenStaleNodes() {
|
||||
const staleIds = this.nodes.filter(n => n.stale).map(n => n.id);
|
||||
if (staleIds.length === 0) return;
|
||||
|
|
@ -1773,15 +2044,6 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
private updateBriefPanel() {
|
||||
const panel = this.shadow.getElementById('brief-panel');
|
||||
const genBtn = panel?.querySelector('#brief-generate') as HTMLButtonElement | null;
|
||||
if (genBtn) {
|
||||
genBtn.disabled = this.briefLoading;
|
||||
genBtn.textContent = this.briefLoading ? 'Generating...' : 'Generate Graph';
|
||||
}
|
||||
}
|
||||
|
||||
private updateToolbarStaleCount() {
|
||||
const btn = this.shadow.getElementById('regen-stale-btn');
|
||||
const count = this.getStaleCount();
|
||||
|
|
@ -1933,33 +2195,34 @@ class FolkCampaignPlanner extends HTMLElement {
|
|||
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
|
||||
this.addNode('audience', cx, cy);
|
||||
});
|
||||
this.shadow.getElementById('add-brief')?.addEventListener('click', () => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
|
||||
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
|
||||
this.addNode('brief', cx, cy);
|
||||
// Auto-open inline edit so user can start typing
|
||||
const lastNode = this.nodes[this.nodes.length - 1];
|
||||
if (lastNode) setTimeout(() => this.enterInlineEdit(lastNode.id), 50);
|
||||
});
|
||||
|
||||
// Brief panel
|
||||
this.shadow.getElementById('toggle-brief')?.addEventListener('click', () => {
|
||||
this.briefPanelOpen = !this.briefPanelOpen;
|
||||
const panel = this.shadow.getElementById('brief-panel');
|
||||
if (panel) panel.classList.toggle('open', this.briefPanelOpen);
|
||||
// Import modal
|
||||
this.shadow.getElementById('open-import')?.addEventListener('click', () => this.openImportModal());
|
||||
this.shadow.getElementById('import-close')?.addEventListener('click', () => this.closeImportModal());
|
||||
this.shadow.getElementById('import-modal')?.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).id === 'import-modal') this.closeImportModal();
|
||||
});
|
||||
this.shadow.getElementById('close-brief')?.addEventListener('click', () => {
|
||||
this.briefPanelOpen = false;
|
||||
const panel = this.shadow.getElementById('brief-panel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
this.shadow.getElementById('import-text')?.addEventListener('input', (e) => {
|
||||
this.importText = (e.target as HTMLTextAreaElement).value;
|
||||
});
|
||||
this.shadow.getElementById('brief-text')?.addEventListener('input', (e) => {
|
||||
this.briefText = (e.target as HTMLTextAreaElement).value;
|
||||
});
|
||||
this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
const checked: string[] = [];
|
||||
this.shadow.getElementById('brief-panel')?.querySelectorAll('input[type="checkbox"]:checked').forEach(c => {
|
||||
checked.push((c as HTMLInputElement).value);
|
||||
});
|
||||
this.briefPlatforms = checked;
|
||||
});
|
||||
});
|
||||
this.shadow.getElementById('brief-generate')?.addEventListener('click', () => {
|
||||
this.generateFromBrief();
|
||||
this.shadow.getElementById('import-platform')?.addEventListener('change', (e) => {
|
||||
this.importPlatform = (e.target as HTMLSelectElement).value;
|
||||
});
|
||||
this.shadow.getElementById('import-submit')?.addEventListener('click', () => this.importFromMarkdown());
|
||||
|
||||
// Preview banner actions
|
||||
this.shadow.getElementById('preview-keep')?.addEventListener('click', () => this.confirmPreview());
|
||||
this.shadow.getElementById('preview-discard')?.addEventListener('click', () => this.discardPreview());
|
||||
this.shadow.getElementById('preview-regen')?.addEventListener('click', () => this.regeneratePreview());
|
||||
|
||||
// Regen stale
|
||||
this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => {
|
||||
|
|
|
|||
|
|
@ -604,9 +604,9 @@ export class FolkCampaignWizard extends HTMLElement {
|
|||
</div>` : result.threadIds?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.threadIds.length} thread(s) created</p>` : ''}
|
||||
${result.newsletters?.length ? `<p style="font-size:0.85rem;color:var(--rs-text-muted,#64748b)">${result.newsletters.length} newsletter draft(s)</p>` : ''}
|
||||
<div class="cw-success__links">
|
||||
<a href="${this.basePath}/campaign">View Campaign</a>
|
||||
${result.flowId ? `<a href="${this.basePath}/campaign-flow?id=${result.flowId}">Open in Planner</a>` : ''}
|
||||
<a href="${this.basePath}/campaigns">All Campaigns</a>
|
||||
<a href="${this.basePath}/threads">View Threads</a>
|
||||
<a href="${this.basePath}/campaigns?workflow=${result.workflowId || ''}">View Workflow</a>
|
||||
<a href="${this.basePath}/campaign-wizard">New Wizard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* <folk-campaigns-dashboard> — Campaign workflow gallery grid.
|
||||
* <folk-campaigns-dashboard> — Campaign gallery grid.
|
||||
*
|
||||
* Fetches all campaign workflows and renders them as cards with miniature
|
||||
* SVG previews of the node graph. Click a card to open the workflow editor.
|
||||
* Lists all campaign flows and renders mini SVG previews. Click a card to
|
||||
* open that flow in the planner. Also surfaces the AI wizard (which sets up
|
||||
* a new flow) and a blank "+ New" affordance.
|
||||
*
|
||||
* Attributes:
|
||||
* space — space slug (default "demo")
|
||||
|
|
@ -10,39 +11,43 @@
|
|||
|
||||
import { TourEngine } from '../../../shared/tour-engine';
|
||||
import type { TourStep } from '../../../shared/tour-engine';
|
||||
import { CAMPAIGN_NODE_CATALOG } from '../schemas';
|
||||
import type {
|
||||
CampaignWorkflowNodeDef,
|
||||
CampaignWorkflowNodeCategory,
|
||||
CampaignWorkflowNode,
|
||||
CampaignWorkflowEdge,
|
||||
CampaignWorkflow,
|
||||
CampaignFlow,
|
||||
CampaignPlannerNode,
|
||||
CampaignEdge,
|
||||
PhaseNodeData,
|
||||
} from '../schemas';
|
||||
|
||||
// ── Constants (match folk-campaign-workflow.ts) ──
|
||||
// ── Mini SVG constants ──
|
||||
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
const CATEGORY_COLORS: Record<CampaignWorkflowNodeCategory, string> = {
|
||||
trigger: '#3b82f6',
|
||||
delay: '#a855f7',
|
||||
condition: '#f59e0b',
|
||||
action: '#10b981',
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
post: '#3b82f6',
|
||||
thread: '#8b5cf6',
|
||||
platform: '#10b981',
|
||||
audience: '#f59e0b',
|
||||
phase: '#64748b',
|
||||
goal: '#6366f1',
|
||||
message: '#6366f1',
|
||||
tone: '#8b5cf6',
|
||||
brief: '#ec4899',
|
||||
};
|
||||
|
||||
function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined {
|
||||
return CAMPAIGN_NODE_CATALOG.find(n => n.type === type);
|
||||
}
|
||||
|
||||
function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number {
|
||||
const def = getNodeDef(node.type);
|
||||
if (!def) return node.position.y + NODE_HEIGHT / 2;
|
||||
const ports = direction === 'input' ? def.inputs : def.outputs;
|
||||
const idx = ports.findIndex(p => p.name === portName);
|
||||
if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
|
||||
const spacing = NODE_HEIGHT / (ports.length + 1);
|
||||
return node.position.y + spacing * (idx + 1);
|
||||
function nodeSize(n: CampaignPlannerNode): { w: number; h: number } {
|
||||
switch (n.type) {
|
||||
case 'post': return { w: 240, h: 120 };
|
||||
case 'thread': return { w: 240, h: 100 };
|
||||
case 'platform': return { w: 180, h: 80 };
|
||||
case 'audience': return { w: 180, h: 80 };
|
||||
case 'phase': {
|
||||
const d = n.data as PhaseNodeData;
|
||||
return { w: d.size?.w || 400, h: d.size?.h || 300 };
|
||||
}
|
||||
case 'goal': return { w: 240, h: 130 };
|
||||
case 'message': return { w: 220, h: 100 };
|
||||
case 'tone': return { w: 220, h: 110 };
|
||||
case 'brief': return { w: 260, h: 150 };
|
||||
default: return { w: 200, h: 100 };
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
|
|
@ -53,78 +58,81 @@ function esc(s: string): string {
|
|||
|
||||
// ── Mini SVG renderer ──
|
||||
|
||||
function renderMiniSVG(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): string {
|
||||
function renderMiniFlowSVG(nodes: CampaignPlannerNode[], edges: CampaignEdge[]): string {
|
||||
if (nodes.length === 0) {
|
||||
return `<svg viewBox="0 0 200 100" preserveAspectRatio="xMidYMid meet" class="cd-card__svg">
|
||||
<text x="100" y="55" text-anchor="middle" fill="#666" font-size="14">Empty workflow</text>
|
||||
<text x="100" y="55" text-anchor="middle" fill="#666" font-size="14">Empty flow</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Compute bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of nodes) {
|
||||
const s = nodeSize(n);
|
||||
minX = Math.min(minX, n.position.x);
|
||||
minY = Math.min(minY, n.position.y);
|
||||
maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
|
||||
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
|
||||
maxX = Math.max(maxX, n.position.x + s.w);
|
||||
maxY = Math.max(maxY, n.position.y + s.h);
|
||||
}
|
||||
const pad = 20;
|
||||
const pad = 40;
|
||||
const vx = minX - pad;
|
||||
const vy = minY - pad;
|
||||
const vw = maxX - minX + pad * 2;
|
||||
const vh = maxY - minY + pad * 2;
|
||||
const vw = (maxX - minX) + pad * 2;
|
||||
const vh = (maxY - minY) + pad * 2;
|
||||
|
||||
// Render edges as Bezier paths
|
||||
// Edges (simple line midpoint-to-midpoint; kept light)
|
||||
const edgePaths = edges.map(e => {
|
||||
const fromNode = nodes.find(n => n.id === e.fromNode);
|
||||
const toNode = nodes.find(n => n.id === e.toNode);
|
||||
if (!fromNode || !toNode) return '';
|
||||
const x1 = fromNode.position.x + NODE_WIDTH;
|
||||
const y1 = getPortY(fromNode, e.fromPort, 'output');
|
||||
const x2 = toNode.position.x;
|
||||
const y2 = getPortY(toNode, e.toPort, 'input');
|
||||
const from = nodes.find(n => n.id === e.from);
|
||||
const to = nodes.find(n => n.id === e.to);
|
||||
if (!from || !to) return '';
|
||||
const fs = nodeSize(from);
|
||||
const ts = nodeSize(to);
|
||||
const x1 = from.position.x + fs.w;
|
||||
const y1 = from.position.y + fs.h / 2;
|
||||
const x2 = to.position.x;
|
||||
const y2 = to.position.y + ts.h / 2;
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
return `<path d="M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}" fill="none" stroke="#555" stroke-width="2" opacity="0.6"/>`;
|
||||
return `<path d="M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}" fill="none" stroke="#555" stroke-width="4" opacity="0.5"/>`;
|
||||
}).join('');
|
||||
|
||||
// Render nodes as rects
|
||||
const nodeRects = nodes.map(n => {
|
||||
const def = getNodeDef(n.type);
|
||||
const cat = def?.category || 'action';
|
||||
const color = CATEGORY_COLORS[cat] || '#666';
|
||||
const label = def?.icon ? `${def.icon} ${esc(n.label || def.label)}` : esc(n.label || n.type);
|
||||
// Truncate long labels for mini view
|
||||
const shortLabel = label.length > 18 ? label.slice(0, 16) + '…' : label;
|
||||
return `
|
||||
<rect x="${n.position.x}" y="${n.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="8" fill="${color}" opacity="0.85"/>
|
||||
<text x="${n.position.x + NODE_WIDTH / 2}" y="${n.position.y + NODE_HEIGHT / 2 + 5}" text-anchor="middle" fill="#fff" font-size="13" font-weight="500">${shortLabel}</text>
|
||||
`;
|
||||
// Phase rects behind
|
||||
const phaseRects = nodes.filter(n => n.type === 'phase').map(n => {
|
||||
const s = nodeSize(n);
|
||||
const d = n.data as PhaseNodeData;
|
||||
return `<rect x="${n.position.x}" y="${n.position.y}" width="${s.w}" height="${s.h}" rx="8" fill="${d.color || '#64748b'}" opacity="0.08" stroke="${d.color || '#64748b'}" stroke-opacity="0.25"/>`;
|
||||
}).join('');
|
||||
|
||||
// Content nodes as solid rects
|
||||
const contentRects = nodes.filter(n => n.type !== 'phase').map(n => {
|
||||
const s = nodeSize(n);
|
||||
const color = NODE_COLORS[n.type] || '#666';
|
||||
return `<rect x="${n.position.x}" y="${n.position.y}" width="${s.w}" height="${s.h}" rx="10" fill="${color}" opacity="0.85"/>`;
|
||||
}).join('');
|
||||
|
||||
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" class="cd-card__svg">
|
||||
${phaseRects}
|
||||
${edgePaths}
|
||||
${nodeRects}
|
||||
${contentRects}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
const DASHBOARD_TOUR_STEPS: TourStep[] = [
|
||||
{ target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign creation flow — answer a few questions and get a ready-to-run workflow.' },
|
||||
{ target: '#btn-new', title: 'New Workflow', message: 'Create a blank workflow from scratch and wire up your own nodes.' },
|
||||
{ target: '.cd-card', title: 'Workflow Cards', message: 'Click any card to open and edit its node graph.' },
|
||||
{ target: '#btn-wizard', title: 'Campaign Wizard', message: 'AI-guided campaign setup — answer a few questions and the wizard builds the flow for you.' },
|
||||
{ target: '#btn-new', title: 'New Campaign', message: 'Start a blank campaign flow and lay out your own posts, platforms, and audiences.' },
|
||||
{ target: '.cd-card', title: 'Campaign Cards', message: 'Click any card to open its flow in the planner.' },
|
||||
];
|
||||
|
||||
class FolkCampaignsDashboard extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = '';
|
||||
private workflows: CampaignWorkflow[] = [];
|
||||
private flows: CampaignFlow[] = [];
|
||||
private loading = true;
|
||||
private _tour!: TourEngine;
|
||||
|
||||
private get basePath() {
|
||||
const host = window.location.hostname;
|
||||
if (host.endsWith('.rspace.online')) return '/rsocials/';
|
||||
if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) return '/rsocials/';
|
||||
return `/${this.space}/rsocials/`;
|
||||
}
|
||||
|
||||
|
|
@ -142,18 +150,18 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
connectedCallback() {
|
||||
this.space = this.getAttribute('space') || 'demo';
|
||||
this.render();
|
||||
this.loadWorkflows();
|
||||
this.loadFlows();
|
||||
}
|
||||
|
||||
private async loadWorkflows() {
|
||||
private async loadFlows() {
|
||||
try {
|
||||
const res = await fetch(`${this.basePath}api/campaign-workflows`);
|
||||
const res = await fetch(`${this.basePath}api/campaign/flows`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.workflows = data.results || [];
|
||||
this.flows = data.results || [];
|
||||
}
|
||||
} catch {
|
||||
console.warn('[CampaignsDashboard] Failed to load workflows');
|
||||
console.warn('[CampaignsDashboard] Failed to load flows');
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
|
|
@ -164,26 +172,24 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
|
||||
startTour() { this._tour.start(); }
|
||||
|
||||
private async createWorkflow() {
|
||||
private async createFlow() {
|
||||
try {
|
||||
const res = await fetch(`${this.basePath}api/campaign-workflows`, {
|
||||
const res = await fetch(`${this.basePath}api/campaign/flows`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New Campaign Workflow' }),
|
||||
body: JSON.stringify({ name: 'New Campaign' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const wf = await res.json();
|
||||
this.navigateToWorkflow(wf.id);
|
||||
const flow = await res.json();
|
||||
this.navigateToFlow(flow.id);
|
||||
}
|
||||
} catch {
|
||||
console.error('[CampaignsDashboard] Failed to create workflow');
|
||||
console.error('[CampaignsDashboard] Failed to create flow');
|
||||
}
|
||||
}
|
||||
|
||||
private navigateToWorkflow(id: string) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('workflow', id);
|
||||
window.location.href = url.toString();
|
||||
private navigateToFlow(id: string) {
|
||||
window.location.href = `${this.basePath}campaign-flow?id=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
private formatDate(ts: number | null): string {
|
||||
|
|
@ -198,47 +204,43 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private render() {
|
||||
const cards = this.workflows.map(wf => {
|
||||
const nodeCount = wf.nodes.length;
|
||||
const statusClass = wf.enabled ? 'cd-badge--enabled' : 'cd-badge--disabled';
|
||||
const statusLabel = wf.enabled ? 'Enabled' : 'Disabled';
|
||||
const runBadge = wf.lastRunStatus
|
||||
? `<span class="cd-badge cd-badge--${wf.lastRunStatus === 'success' ? 'success' : 'error'}">${wf.lastRunStatus}</span>`
|
||||
: '';
|
||||
const cards = this.flows.map(flow => {
|
||||
const nodeCount = flow.nodes.length;
|
||||
const postCount = flow.nodes.filter(n => n.type === 'post').length;
|
||||
const platformCount = flow.nodes.filter(n => n.type === 'platform').length;
|
||||
|
||||
return `
|
||||
<div class="cd-card" data-wf-id="${wf.id}">
|
||||
<div class="cd-card" data-flow-id="${flow.id}">
|
||||
<div class="cd-card__preview">
|
||||
${renderMiniSVG(wf.nodes, wf.edges)}
|
||||
${renderMiniFlowSVG(flow.nodes, flow.edges)}
|
||||
</div>
|
||||
<div class="cd-card__info">
|
||||
<div class="cd-card__name">${esc(wf.name)}</div>
|
||||
<div class="cd-card__name">${esc(flow.name)}</div>
|
||||
<div class="cd-card__badges">
|
||||
<span class="cd-badge ${statusClass}">${statusLabel}</span>
|
||||
<span class="cd-badge cd-badge--nodes">${nodeCount} node${nodeCount !== 1 ? 's' : ''}</span>
|
||||
${runBadge}
|
||||
${wf.runCount > 0 ? `<span class="cd-badge cd-badge--runs">${wf.runCount} run${wf.runCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
${postCount > 0 ? `<span class="cd-badge cd-badge--posts">${postCount} post${postCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
${platformCount > 0 ? `<span class="cd-badge cd-badge--platforms">${platformCount} platform${platformCount !== 1 ? 's' : ''}</span>` : ''}
|
||||
</div>
|
||||
<div class="cd-card__date">Updated ${this.formatDate(wf.updatedAt)}</div>
|
||||
<div class="cd-card__date">Updated ${this.formatDate(flow.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const emptyState = !this.loading && this.workflows.length === 0 ? `
|
||||
const emptyState = !this.loading && this.flows.length === 0 ? `
|
||||
<div class="cd-empty">
|
||||
<div class="cd-empty__icon">📋</div>
|
||||
<div class="cd-empty__title">No campaign workflows yet</div>
|
||||
<div class="cd-empty__subtitle">Use the AI wizard for guided campaign creation, or start a blank workflow</div>
|
||||
<div class="cd-empty__icon">📢</div>
|
||||
<div class="cd-empty__title">No campaigns yet</div>
|
||||
<div class="cd-empty__subtitle">Use the AI wizard to set up a flow from a brief, or start a blank canvas</div>
|
||||
<div class="cd-header__actions" style="justify-content:center">
|
||||
<button class="cd-btn cd-btn--wizard cd-btn--new-empty" id="btn-wizard-empty">\uD83E\uDDD9 Campaign Wizard</button>
|
||||
<button class="cd-btn cd-btn--primary cd-btn--new-empty">+ New Workflow</button>
|
||||
<button class="cd-btn cd-btn--primary cd-btn--new-empty">+ New Campaign</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const loadingState = this.loading ? `
|
||||
<div class="cd-loading">Loading workflows…</div>
|
||||
<div class="cd-loading">Loading campaigns…</div>
|
||||
` : '';
|
||||
|
||||
this.shadow.innerHTML = `
|
||||
|
|
@ -249,6 +251,7 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
|
||||
.cd-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
.cd-header h2 { margin: 0; font-size: 1.4rem; font-weight: 600; }
|
||||
.cd-header__subtitle { font-size: 0.85rem; color: var(--rs-text-muted, #888); margin-top: 0.25rem; }
|
||||
|
||||
.cd-btn {
|
||||
border: none; border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer;
|
||||
|
|
@ -289,12 +292,9 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px;
|
||||
font-weight: 500; text-transform: capitalize;
|
||||
}
|
||||
.cd-badge--enabled { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); }
|
||||
.cd-badge--disabled { background: var(--rs-bg-surface-raised, #3f3f46); color: var(--rs-text-muted, #a1a1aa); }
|
||||
.cd-badge--success { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #6ee7b7); }
|
||||
.cd-badge--error { background: rgba(239, 68, 68, 0.15); color: var(--rs-error, #fca5a5); }
|
||||
.cd-badge--nodes { background: rgba(59, 130, 246, 0.15); color: var(--rs-primary, #93c5fd); }
|
||||
.cd-badge--runs { background: rgba(99, 102, 241, 0.15); color: var(--rs-primary-hover, #c4b5fd); }
|
||||
.cd-badge--posts { background: rgba(59, 130, 246, 0.15); color: #93c5fd; }
|
||||
.cd-badge--platforms { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; }
|
||||
|
||||
.cd-card__date { font-size: 0.75rem; color: var(--rs-text-muted, #888); }
|
||||
|
||||
|
|
@ -312,16 +312,19 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
|
||||
<div class="cd-root">
|
||||
<div class="cd-header">
|
||||
<h2>Campaign Workflows</h2>
|
||||
<div>
|
||||
<h2>Campaigns</h2>
|
||||
<div class="cd-header__subtitle">Plan, wire up, and schedule multi-platform campaigns</div>
|
||||
</div>
|
||||
<div class="cd-header__actions">
|
||||
<button class="cd-btn cd-btn--wizard" id="btn-wizard">\uD83E\uDDD9 Campaign Wizard</button>
|
||||
${!this.loading && this.workflows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Workflow</button>' : ''}
|
||||
${!this.loading && this.flows.length > 0 ? '<button class="cd-btn cd-btn--primary" id="btn-new">+ New Campaign</button>' : ''}
|
||||
<button style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:0.78rem;" id="btn-tour">Tour</button>
|
||||
</div>
|
||||
</div>
|
||||
${loadingState}
|
||||
${emptyState}
|
||||
${!this.loading && this.workflows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''}
|
||||
${!this.loading && this.flows.length > 0 ? `<div class="cd-grid">${cards}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -330,22 +333,19 @@ class FolkCampaignsDashboard extends HTMLElement {
|
|||
}
|
||||
|
||||
private attachListeners() {
|
||||
// Card clicks
|
||||
this.shadow.querySelectorAll('.cd-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const id = (card as HTMLElement).dataset.wfId;
|
||||
if (id) this.navigateToWorkflow(id);
|
||||
const id = (card as HTMLElement).dataset.flowId;
|
||||
if (id) this.navigateToFlow(id);
|
||||
});
|
||||
});
|
||||
|
||||
// New workflow buttons
|
||||
const btnNew = this.shadow.getElementById('btn-new');
|
||||
if (btnNew) btnNew.addEventListener('click', () => this.createWorkflow());
|
||||
if (btnNew) btnNew.addEventListener('click', () => this.createFlow());
|
||||
|
||||
const btnNewEmpty = this.shadow.querySelector('.cd-btn--new-empty:not(#btn-wizard-empty)');
|
||||
if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createWorkflow());
|
||||
if (btnNewEmpty) btnNewEmpty.addEventListener('click', () => this.createFlow());
|
||||
|
||||
// Wizard buttons
|
||||
const wizardUrl = `${this.basePath}campaign-wizard`;
|
||||
const btnWizard = this.shadow.getElementById('btn-wizard');
|
||||
if (btnWizard) btnWizard.addEventListener('click', () => { window.location.href = wizardUrl; });
|
||||
|
|
|
|||
|
|
@ -992,6 +992,64 @@ Rules:
|
|||
}
|
||||
});
|
||||
|
||||
// ── Campaign Flow CRUD API (Automerge-backed, powers the dashboard) ──
|
||||
|
||||
routes.get("/api/campaign/flows", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const flows = Object.values(doc.campaignFlows || {});
|
||||
flows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||
return c.json({ count: flows.length, results: flows });
|
||||
});
|
||||
|
||||
routes.post("/api/campaign/flows", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
|
||||
const docId = socialsDocId(dataSpace);
|
||||
ensureDoc(dataSpace);
|
||||
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const now = Date.now();
|
||||
|
||||
const flow: CampaignFlow = {
|
||||
id: flowId,
|
||||
name: body.name || "New Campaign",
|
||||
nodes: body.nodes || [],
|
||||
edges: body.edges || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign flow ${flowId}`, (d) => {
|
||||
if (!d.campaignFlows) d.campaignFlows = {} as any;
|
||||
(d.campaignFlows as any)[flowId] = flow;
|
||||
d.activeFlowId = flowId;
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
|
||||
return c.json(updated.campaignFlows[flowId], 201);
|
||||
});
|
||||
|
||||
routes.delete("/api/campaign/flows/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const id = c.req.param("id");
|
||||
|
||||
const docId = socialsDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
if (!doc.campaignFlows?.[id]) return c.json({ error: "Campaign flow not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `delete campaign flow ${id}`, (d) => {
|
||||
delete d.campaignFlows[id];
|
||||
if (d.activeFlowId === id) d.activeFlowId = '';
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Campaign Workflow CRUD API ──
|
||||
|
||||
routes.get("/api/campaign-workflows", (c) => {
|
||||
|
|
@ -1875,7 +1933,145 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
|
|||
(d.campaignWorkflows as any)[wfId] = workflow;
|
||||
});
|
||||
|
||||
// 6. Mark wizard as committed
|
||||
// 6. Build a CampaignFlow from the same data so the planner can open it
|
||||
const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const flowNodes: CampaignPlannerNode[] = [];
|
||||
const flowEdges: CampaignEdge[] = [];
|
||||
const brief = wizard.extractedBrief;
|
||||
|
||||
// Input column (x=50): goal, messages, tone
|
||||
const goalNodeId = `goal-${now}`;
|
||||
flowNodes.push({
|
||||
id: goalNodeId, type: 'goal',
|
||||
position: { x: 50, y: 50 },
|
||||
data: {
|
||||
label: campaign.title || 'Campaign Goal',
|
||||
objective: campaign.description || brief?.audience || '',
|
||||
startDate: brief?.startDate || '',
|
||||
endDate: brief?.endDate || '',
|
||||
} as GoalNodeData,
|
||||
});
|
||||
|
||||
const msgIds: string[] = [];
|
||||
(brief?.keyMessages || []).forEach((msg, i) => {
|
||||
const mid = `message-${now}-${i}`;
|
||||
msgIds.push(mid);
|
||||
flowNodes.push({
|
||||
id: mid, type: 'message',
|
||||
position: { x: 50, y: 220 + i * 130 },
|
||||
data: {
|
||||
label: msg.substring(0, 40),
|
||||
text: msg,
|
||||
priority: i === 0 ? 'high' : 'medium',
|
||||
} as MessageNodeData,
|
||||
});
|
||||
});
|
||||
|
||||
const toneNodeId = `tone-${now}`;
|
||||
flowNodes.push({
|
||||
id: toneNodeId, type: 'tone',
|
||||
position: { x: 50, y: 220 + (brief?.keyMessages?.length || 0) * 130 },
|
||||
data: {
|
||||
label: `${brief?.tone || 'Professional'} tone`,
|
||||
tone: brief?.tone || 'professional',
|
||||
style: brief?.style || 'awareness',
|
||||
audience: brief?.audience || '',
|
||||
sizeEstimate: '',
|
||||
} as ToneNodeData,
|
||||
});
|
||||
|
||||
// Phase column (x=350)
|
||||
const phaseIds: string[] = [];
|
||||
campaign.phases.forEach((phase, pi) => {
|
||||
const pid = `phase-${now}-${pi}`;
|
||||
phaseIds.push(pid);
|
||||
const phaseColors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
flowNodes.push({
|
||||
id: pid, type: 'phase',
|
||||
position: { x: 350, y: 50 + pi * 320 },
|
||||
data: {
|
||||
label: phase.label || `Phase ${pi + 1}`,
|
||||
dateRange: phase.days || '',
|
||||
color: phaseColors[pi % phaseColors.length],
|
||||
progress: 0,
|
||||
childNodeIds: [],
|
||||
size: { w: 400, h: 280 },
|
||||
} as PhaseNodeData,
|
||||
});
|
||||
flowEdges.push({
|
||||
id: `e-goal-phase-${pi}`,
|
||||
from: goalNodeId, to: pid, type: 'feeds',
|
||||
});
|
||||
});
|
||||
|
||||
// Post + platform columns
|
||||
const platformMap = new Map<string, string>();
|
||||
campaign.posts.forEach((post, pi) => {
|
||||
const postNodeId = `post-${now}-${pi}`;
|
||||
const phaseIdx = Math.max(0, (post.phase || 1) - 1);
|
||||
const phaseY = 50 + phaseIdx * 320;
|
||||
flowNodes.push({
|
||||
id: postNodeId, type: 'post',
|
||||
position: { x: 400 + (pi % 3) * 260, y: phaseY + 60 + Math.floor(pi / 3) * 140 },
|
||||
data: {
|
||||
label: (post.content || '').split('\n')[0].substring(0, 40) || post.platform,
|
||||
platform: post.platform || 'x',
|
||||
postType: post.postType || 'text',
|
||||
content: post.content || '',
|
||||
scheduledAt: post.scheduledAt || '',
|
||||
status: (post.status as any) || 'draft',
|
||||
hashtags: post.hashtags || [],
|
||||
} as PostNodeData,
|
||||
});
|
||||
if (msgIds.length > 0) {
|
||||
flowEdges.push({
|
||||
id: `e-msg-post-${pi}`,
|
||||
from: msgIds[pi % msgIds.length], to: postNodeId, type: 'feeds',
|
||||
});
|
||||
}
|
||||
flowEdges.push({
|
||||
id: `e-tone-post-${pi}`,
|
||||
from: toneNodeId, to: postNodeId, type: 'feeds',
|
||||
});
|
||||
|
||||
const plat = post.platform || 'x';
|
||||
if (!platformMap.has(plat)) {
|
||||
const platId = `platform-${now}-${plat}`;
|
||||
platformMap.set(plat, platId);
|
||||
const platIdx = platformMap.size - 1;
|
||||
flowNodes.push({
|
||||
id: platId, type: 'platform',
|
||||
position: { x: 950, y: 50 + platIdx * 120 },
|
||||
data: {
|
||||
label: plat.charAt(0).toUpperCase() + plat.slice(1),
|
||||
platform: plat,
|
||||
handle: '',
|
||||
} as PlatformNodeData,
|
||||
});
|
||||
}
|
||||
flowEdges.push({
|
||||
id: `e-post-plat-${pi}`,
|
||||
from: postNodeId, to: platformMap.get(plat)!, type: 'publish',
|
||||
});
|
||||
});
|
||||
|
||||
const flow: CampaignFlow = {
|
||||
id: flowId,
|
||||
name: campaign.title,
|
||||
nodes: flowNodes,
|
||||
edges: flowEdges,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create flow`, (d) => {
|
||||
if (!d.campaignFlows) d.campaignFlows = {} as any;
|
||||
(d.campaignFlows as any)[flowId] = flow;
|
||||
d.activeFlowId = flowId;
|
||||
});
|
||||
|
||||
// 7. Mark wizard as committed
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → committed`, (d) => {
|
||||
const w = d.campaignWizards?.[id];
|
||||
if (!w) return;
|
||||
|
|
@ -1887,6 +2083,7 @@ routes.post("/api/campaign/wizard/:id/commit", async (c) => {
|
|||
return c.json({
|
||||
ok: true,
|
||||
campaignId: campaign.id,
|
||||
flowId,
|
||||
threadIds,
|
||||
threads: threadInfos,
|
||||
workflowId: wfId,
|
||||
|
|
@ -2308,13 +2505,15 @@ Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goa
|
|||
|
||||
routes.get("/campaign-flow", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const flowId = c.req.query("id") || "";
|
||||
const idAttr = flowId ? ` flow-id="${escapeHtml(flowId)}"` : "";
|
||||
return c.html(renderShell({
|
||||
title: `Campaign Flow — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-campaign-planner space="${escapeHtml(space)}"></folk-campaign-planner>`,
|
||||
body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`,
|
||||
}));
|
||||
|
|
@ -2462,23 +2661,8 @@ routes.get("/threads", (c) => {
|
|||
|
||||
routes.get("/campaigns", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const workflowId = c.req.query("workflow");
|
||||
|
||||
if (workflowId) {
|
||||
return c.html(renderShell({
|
||||
title: `Campaign Workflows — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-campaign-workflow space="${escapeHtml(space)}" workflow="${escapeHtml(workflowId)}"></folk-campaign-workflow>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-workflow.css">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-workflow.js"></script>`,
|
||||
}));
|
||||
}
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `Campaign Workflows — rSocials | rSpace`,
|
||||
title: `Campaigns — rSocials | rSpace`,
|
||||
moduleId: "rsocials",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export interface Campaign {
|
|||
|
||||
// ── Campaign planner (flow canvas) types ──
|
||||
|
||||
export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone';
|
||||
export type CampaignNodeType = 'post' | 'thread' | 'platform' | 'audience' | 'phase' | 'goal' | 'message' | 'tone' | 'brief';
|
||||
|
||||
export interface PostNodeData {
|
||||
label: string;
|
||||
|
|
@ -118,11 +118,19 @@ export interface ToneNodeData {
|
|||
sizeEstimate: string;
|
||||
}
|
||||
|
||||
export interface BriefNodeData {
|
||||
label: string;
|
||||
text: string;
|
||||
platforms: string[];
|
||||
generating?: boolean;
|
||||
lastGeneratedAt?: number;
|
||||
}
|
||||
|
||||
export interface CampaignPlannerNode {
|
||||
id: string;
|
||||
type: CampaignNodeType;
|
||||
position: { x: number; y: number };
|
||||
data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData;
|
||||
data: PostNodeData | ThreadNodeData | PlatformNodeData | AudienceNodeData | PhaseNodeData | GoalNodeData | MessageNodeData | ToneNodeData | BriefNodeData;
|
||||
stale?: boolean;
|
||||
staleReason?: string;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
title: demo — rPast (smoke)
|
||||
view: timeline
|
||||
timezone: UTC
|
||||
#rcal: blue
|
||||
#rnotes: green
|
||||
#rtasks: orange
|
||||
#rvote: purple
|
||||
---
|
||||
|
||||
// Generated by shared/markwhen/projection.ts — do not hand-edit.
|
||||
|
||||
section 📅 Calendar #rcal
|
||||
2026-01-16 20:10: Personal #calendar-source
|
||||
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=s1)
|
||||
2026-03-16 20:10: TEC retrospective kickoff #event #research #virtual
|
||||
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e1)
|
||||
2026-04-01 20:10: Markwhen exploration #event #booked
|
||||
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e2)
|
||||
2026-04-15 20:10: rPast ship review #event
|
||||
[Open in Calendar](https://demo.rspace.online/demo/rcal?focus=e3)
|
||||
endSection
|
||||
|
||||
section 📓 Notes #rnotes
|
||||
2026-04-10 20:10: April 10 daily #note #daily
|
||||
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/daily%2F2026-04-10.md)
|
||||
2026-04-13 20:10: markwhen idea #note
|
||||
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/ideas%2Fmarkwhen.md)
|
||||
2026-04-15 20:10: April 15 daily #note #daily
|
||||
[Open in Notes](https://demo.rspace.online/demo/rnotes/vault-a/daily%2F2026-04-15.md)
|
||||
endSection
|
||||
|
||||
section ✅ Tasks #rtasks
|
||||
2026-04-11 20:10 / 2026-04-14 20:10: Build universal enumerator #task #done #high
|
||||
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t2)
|
||||
2026-04-14 20:10: Wire rPast bootstrap #task #done #high
|
||||
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t1)
|
||||
2026-04-15 20:10: Add rVote adapter #task #todo
|
||||
[Open in Tasks](https://demo.rspace.online/demo/rtasks?focus=t3)
|
||||
endSection
|
||||
|
||||
section 🗳️ Votes #rvote
|
||||
2026-03-27 20:10 / 2026-04-06 20:10: Fund rPast v1 #proposal #executed
|
||||
[Open in ️ Votes](https://demo.rspace.online/demo/rvote?focus=p1)
|
||||
2026-04-14 20:10: Enable x402 export #proposal #open
|
||||
[Open in ️ Votes](https://demo.rspace.online/demo/rvote?focus=p2)
|
||||
endSection
|
||||
|
|
@ -24,6 +24,10 @@
|
|||
"@google/genai": "^1.43.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
"@markwhen/calendar": "^1.3.6",
|
||||
"@markwhen/mw": "^1.2.4",
|
||||
"@markwhen/parser": "^1.0.1",
|
||||
"@markwhen/timeline": "^1.4.5",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@noble/curves": "^1.8.0",
|
||||
"@noble/hashes": "^1.7.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Smoke test: universal creation-log projection end-to-end.
|
||||
* Run with: bun run scripts/smoke-rpast.ts
|
||||
*/
|
||||
|
||||
import { initMarkwhen, enumerateCreations, renderMarkwhen } from '../shared/markwhen';
|
||||
import { renderMarkwhenHtml } from '../shared/markwhen/html-render';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
const now = Date.now();
|
||||
const d = (offsetDays: number) => now + offsetDays * 86_400_000;
|
||||
|
||||
const docs: Record<string, any> = {
|
||||
'demo:cal:events': {
|
||||
events: {
|
||||
'e1': { id: 'e1', title: 'TEC retrospective kickoff', startTime: d(-30), endTime: d(-30), createdAt: d(-31), updatedAt: d(-31), allDay: false, tags: ['research'], isVirtual: true, virtualUrl: 'https://meet.example' },
|
||||
'e2': { id: 'e2', title: 'Markwhen exploration', startTime: d(-14), endTime: d(-14), createdAt: d(-15), updatedAt: d(-15), allDay: false, bookingStatus: 'booked' },
|
||||
'e3': { id: 'e3', title: 'rPast ship review', startTime: d(3), endTime: d(3), createdAt: d(-1), updatedAt: d(-1), allDay: false },
|
||||
},
|
||||
sources: {
|
||||
's1': { id: 's1', name: 'Personal', createdAt: d(-90), updatedAt: d(-90) },
|
||||
},
|
||||
views: {},
|
||||
},
|
||||
'demo:rnotes:vaults:vault-a': {
|
||||
notes: {
|
||||
'daily/2026-04-10.md': { path: 'daily/2026-04-10.md', title: 'April 10 daily', tags: ['daily'], lastModifiedAt: d(-6) },
|
||||
'daily/2026-04-15.md': { path: 'daily/2026-04-15.md', title: 'April 15 daily', tags: ['daily'], lastModifiedAt: d(-1) },
|
||||
'ideas/markwhen.md': { path: 'ideas/markwhen.md', title: 'markwhen idea', lastModifiedAt: d(-3), frontmatter: { date: '2026-04-13' } },
|
||||
},
|
||||
},
|
||||
'demo:rtasks:boards:main': {
|
||||
tasks: {
|
||||
't1': { id: 't1', title: 'Wire rPast bootstrap', status: 'done', priority: 'high', createdAt: d(-2), updatedAt: d(-1) },
|
||||
't2': { id: 't2', title: 'Build universal enumerator', status: 'done', priority: 'high', createdAt: d(-5), updatedAt: d(-2) },
|
||||
't3': { id: 't3', title: 'Add rVote adapter', status: 'todo', createdAt: d(-1) },
|
||||
},
|
||||
},
|
||||
'demo:rvote:proposals:round-1': {
|
||||
proposals: {
|
||||
'p1': { id: 'p1', title: 'Fund rPast v1', status: 'executed', createdAt: d(-20), updatedAt: d(-10) },
|
||||
'p2': { id: 'p2', title: 'Enable x402 export', status: 'open', createdAt: d(-2) },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
initMarkwhen({
|
||||
async loadDoc<T>(id: string) { return (docs[id] as T) ?? null; },
|
||||
async listDocIds(prefix: string) { return Object.keys(docs).filter(k => k.startsWith(prefix)); },
|
||||
});
|
||||
|
||||
const sources = await enumerateCreations('demo');
|
||||
const projection = renderMarkwhen(sources, {
|
||||
view: 'timeline',
|
||||
title: 'demo — rPast (smoke)',
|
||||
baseUrl: 'https://demo.rspace.online',
|
||||
});
|
||||
|
||||
console.log(`\n== Sources ==`);
|
||||
for (const s of sources) console.log(` ${s.id}: ${s.events.length} events`);
|
||||
console.log(`\n== Total ==\n ${projection.count} creations across ${sources.length} modules`);
|
||||
|
||||
console.log(`\n== .mw (first 40 lines) ==`);
|
||||
console.log(projection.text.split('\n').slice(0, 40).join('\n'));
|
||||
|
||||
mkdirSync('output', { recursive: true });
|
||||
writeFileSync('output/rpast-smoke.mw', projection.text);
|
||||
writeFileSync('output/rpast-smoke.html', renderMarkwhenHtml(projection.text, 'timeline'));
|
||||
|
||||
console.log(`\n== Wrote ==`);
|
||||
console.log(` output/rpast-smoke.mw (${projection.text.length} bytes)`);
|
||||
console.log(` output/rpast-smoke.html (${renderMarkwhenHtml(projection.text, 'timeline').length} bytes)`);
|
||||
|
|
@ -93,6 +93,8 @@ import { exchangeModule } from "../modules/rexchange/mod";
|
|||
import { auctionsModule } from "../modules/rauctions/mod";
|
||||
import { credModule } from "../modules/rcred/mod";
|
||||
import { feedsModule } from "../modules/rfeeds/mod";
|
||||
import { rpastModule } from "../modules/rpast/mod";
|
||||
import { initMarkwhen } from "../shared/markwhen";
|
||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||
import type { SpaceRoleString } from "./spaces";
|
||||
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
|
||||
|
|
@ -186,6 +188,19 @@ registerModule(tripsModule);
|
|||
registerModule(booksModule);
|
||||
registerModule(sheetsModule);
|
||||
registerModule(docsModule); // Full TipTap editor (split from rNotes)
|
||||
registerModule(rpastModule); // Chronicle-of-self timeline across all rApps
|
||||
|
||||
// Wire rPast / markwhen projection layer to syncServer.
|
||||
// Scans the in-memory doc cache for `loadDoc` / `listDocIds` — loadAllDocs
|
||||
// hydrates the cache at startup, so this sees every persisted doc.
|
||||
initMarkwhen({
|
||||
async loadDoc<T>(docId: string): Promise<T | null> {
|
||||
return (syncServer.getDoc<T>(docId) as T | undefined) ?? null;
|
||||
},
|
||||
async listDocIds(prefix: string): Promise<string[]> {
|
||||
return syncServer.getDocIds().filter(id => id.startsWith(prefix));
|
||||
},
|
||||
});
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* <rstack-markwhen-view> — iframe-based markwhen viewer.
|
||||
*
|
||||
* Two modes:
|
||||
* - `src` attribute: fetch a server-rendered `/render` endpoint (preferred)
|
||||
* - `text` property: render an ad-hoc .mw string via the server-side
|
||||
* render pipeline, delivered via srcdoc for isolation.
|
||||
*
|
||||
* The upstream @markwhen/{timeline,calendar} packages are pre-built Vue
|
||||
* apps, not importable libraries — so the iframe route is the sane embed.
|
||||
*/
|
||||
|
||||
export type MarkwhenViewMode = 'timeline' | 'calendar';
|
||||
|
||||
export class RstackMarkwhenView extends HTMLElement {
|
||||
static get observedAttributes() { return ['src', 'view']; }
|
||||
|
||||
#src = '';
|
||||
#view: MarkwhenViewMode = 'timeline';
|
||||
#shadow: ShadowRoot;
|
||||
#iframe: HTMLIFrameElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: 'open' });
|
||||
this.#shadow.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; width: 100%; height: 100%; min-height: 320px;
|
||||
background: #0b1221; border-radius: 8px; overflow: hidden; }
|
||||
iframe { width: 100%; height: 100%; border: 0; background: #0b1221; }
|
||||
.empty { display: grid; place-items: center; height: 100%;
|
||||
font: 13px/1.4 system-ui; color: #94a3b8; padding: 24px; text-align: center; }
|
||||
</style>
|
||||
<iframe sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" title="Timeline"></iframe>
|
||||
`;
|
||||
this.#iframe = this.#shadow.querySelector('iframe')!;
|
||||
}
|
||||
|
||||
set src(v: string) { this.#src = v; this.#render(); }
|
||||
get src() { return this.#src; }
|
||||
set view(v: MarkwhenViewMode) { this.#view = v; this.#render(); }
|
||||
get view() { return this.#view; }
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, next: string | null) {
|
||||
if (name === 'src') this.src = next ?? '';
|
||||
if (name === 'view') this.view = (next === 'calendar' ? 'calendar' : 'timeline');
|
||||
}
|
||||
|
||||
connectedCallback() { this.#render(); }
|
||||
|
||||
#render() {
|
||||
if (!this.#src) {
|
||||
this.#iframe.removeAttribute('src');
|
||||
this.#iframe.srcdoc = `<body style="display:grid;place-items:center;height:100vh;margin:0;font:13px system-ui;color:#94a3b8;background:#0b1221">No timeline source yet.</body>`;
|
||||
return;
|
||||
}
|
||||
// Append view param if caller used a bare endpoint URL.
|
||||
const url = new URL(this.#src, window.location.origin);
|
||||
if (!url.searchParams.has('view')) url.searchParams.set('view', this.#view);
|
||||
this.#iframe.removeAttribute('srcdoc');
|
||||
this.#iframe.src = url.pathname + url.search;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('rstack-markwhen-view')) {
|
||||
customElements.define('rstack-markwhen-view', RstackMarkwhenView);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Declarative creation-specs for every rApp that stores dated records.
|
||||
*
|
||||
* Each spec says: "here's my doc pattern, here's where my records live,
|
||||
* here's how to pick a title / href / tags." The universal enumerator
|
||||
* consumes these to produce the rPast chronicle-of-self.
|
||||
*
|
||||
* Adding a new rApp: append a new `CreationSpec` literal below and
|
||||
* register it in `shared/markwhen/index.ts`. No bespoke code required.
|
||||
*/
|
||||
|
||||
import type { CreationSpec } from './universal-enumerator';
|
||||
|
||||
const hrefFor = (module: string) =>
|
||||
({ space, id }: { space: string; id: string }) => `/${space}/${module}?focus=${encodeURIComponent(id)}`;
|
||||
|
||||
export const rcalSpec: CreationSpec = {
|
||||
module: 'rcal',
|
||||
label: 'Calendar',
|
||||
icon: '📅',
|
||||
color: 'blue',
|
||||
docPatterns: ['{space}:cal:events'],
|
||||
collections: [
|
||||
{
|
||||
path: 'events',
|
||||
recordType: 'event',
|
||||
href: hrefFor('rcal'),
|
||||
tags: r => {
|
||||
const t: string[] = [];
|
||||
if (Array.isArray(r.tags)) t.push(...(r.tags as string[]));
|
||||
if (typeof r.bookingStatus === 'string') t.push(r.bookingStatus as string);
|
||||
if (r.isVirtual) t.push('virtual');
|
||||
return t.length ? t : undefined;
|
||||
},
|
||||
description: r => (typeof r.description === 'string' ? (r.description as string).slice(0, 200) : undefined),
|
||||
},
|
||||
{ path: 'sources', recordType: 'calendar-source', href: hrefFor('rcal') },
|
||||
{ path: 'views', recordType: 'saved-view' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rnotesSpec: CreationSpec = {
|
||||
module: 'rnotes',
|
||||
label: 'Notes',
|
||||
icon: '📓',
|
||||
color: 'green',
|
||||
docPatterns: ['{space}:rnotes:vaults:*'],
|
||||
collections: [
|
||||
{
|
||||
path: 'notes',
|
||||
recordType: 'note',
|
||||
timestampField: 'lastModifiedAt',
|
||||
title: (r, id) => (typeof r.title === 'string' && r.title ? r.title as string : id),
|
||||
href: ({ space, docId, id }) => {
|
||||
const vaultId = docId.split(':').pop() ?? '';
|
||||
return `/${space}/rnotes/${vaultId}/${encodeURIComponent(id)}`;
|
||||
},
|
||||
tags: r => (Array.isArray(r.tags) ? (r.tags as string[]) : undefined),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const rtasksSpec: CreationSpec = {
|
||||
module: 'rtasks',
|
||||
label: 'Tasks',
|
||||
icon: '✅',
|
||||
color: 'orange',
|
||||
docPatterns: ['{space}:rtasks:boards:*'],
|
||||
collections: [
|
||||
{
|
||||
path: 'tasks',
|
||||
recordType: 'task',
|
||||
href: hrefFor('rtasks'),
|
||||
tags: r => {
|
||||
const t: string[] = [];
|
||||
if (typeof r.status === 'string') t.push(r.status as string);
|
||||
if (typeof r.priority === 'string') t.push(r.priority as string);
|
||||
if (Array.isArray(r.tags)) t.push(...(r.tags as string[]));
|
||||
return t.length ? t : undefined;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const rvoteSpec: CreationSpec = {
|
||||
module: 'rvote',
|
||||
label: 'Votes',
|
||||
icon: '🗳️',
|
||||
color: 'purple',
|
||||
docPatterns: ['{space}:rvote:proposals:*'],
|
||||
collections: [
|
||||
{
|
||||
path: 'proposals',
|
||||
recordType: 'proposal',
|
||||
href: hrefFor('rvote'),
|
||||
tags: r => (typeof r.status === 'string' ? [r.status as string] : undefined),
|
||||
},
|
||||
{ path: 'votes', recordType: 'vote' },
|
||||
{ path: 'finalVotes', recordType: 'final-vote' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rphotosSpec: CreationSpec = {
|
||||
module: 'rphotos',
|
||||
label: 'Photos',
|
||||
icon: '📸',
|
||||
color: 'rose',
|
||||
docPatterns: ['{space}:rphotos:albums'],
|
||||
collections: [
|
||||
{ path: 'sharedAlbums', recordType: 'album', href: hrefFor('rphotos') },
|
||||
{ path: 'subAlbums', recordType: 'album', href: hrefFor('rphotos') },
|
||||
{ path: 'annotations', recordType: 'annotation' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rfilesSpec: CreationSpec = {
|
||||
module: 'rfiles',
|
||||
label: 'Files',
|
||||
icon: '📁',
|
||||
color: 'teal',
|
||||
docPatterns: ['{space}:rfiles:library'],
|
||||
collections: [
|
||||
{
|
||||
path: 'files',
|
||||
recordType: 'file',
|
||||
title: (r, id) => (typeof r.name === 'string' ? r.name as string : id),
|
||||
href: hrefFor('rfiles'),
|
||||
},
|
||||
{ path: 'memoryCards', recordType: 'memory-card', href: hrefFor('rfiles') },
|
||||
],
|
||||
};
|
||||
|
||||
export const rdocsSpec: CreationSpec = {
|
||||
module: 'rdocs',
|
||||
label: 'Docs',
|
||||
icon: '📄',
|
||||
color: 'indigo',
|
||||
docPatterns: ['{space}:rdocs:notebooks:*'],
|
||||
collections: [
|
||||
{ path: 'items', recordType: 'doc', href: hrefFor('rdocs') },
|
||||
],
|
||||
};
|
||||
|
||||
export const rsheetsSpec: CreationSpec = {
|
||||
module: 'rsheets',
|
||||
label: 'Sheets',
|
||||
icon: '📊',
|
||||
color: 'lime',
|
||||
docPatterns: ['{space}:rsheets:sheets:*'],
|
||||
collections: [
|
||||
{ path: 'rows', recordType: 'row' },
|
||||
{ path: 'columns', recordType: 'column' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rtripsSpec: CreationSpec = {
|
||||
module: 'rtrips',
|
||||
label: 'Trips',
|
||||
icon: '🧳',
|
||||
color: 'amber',
|
||||
docPatterns: ['{space}:rtrips:trips:*'],
|
||||
collections: [
|
||||
{ path: 'stops', recordType: 'stop', href: hrefFor('rtrips') },
|
||||
{ path: 'expenses', recordType: 'expense' },
|
||||
{ path: 'packingList', recordType: 'packing' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rchoicesSpec: CreationSpec = {
|
||||
module: 'rchoices',
|
||||
label: 'Choices',
|
||||
icon: '🎯',
|
||||
color: 'fuchsia',
|
||||
docPatterns: ['{space}:rchoices:sessions'],
|
||||
collections: [
|
||||
{ path: 'sessions', recordType: 'session', href: hrefFor('rchoices') },
|
||||
{ path: 'votes', recordType: 'vote' },
|
||||
],
|
||||
};
|
||||
|
||||
export const rinboxSpec: CreationSpec = {
|
||||
module: 'rinbox',
|
||||
label: 'Inbox',
|
||||
icon: '📧',
|
||||
color: 'gray',
|
||||
docPatterns: ['{space}:rinbox:mailboxes:*'],
|
||||
collections: [
|
||||
{
|
||||
path: 'messages',
|
||||
recordType: 'message',
|
||||
title: r => (typeof r.subject === 'string' ? r.subject as string : '(no subject)'),
|
||||
href: hrefFor('rinbox'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const rcartSpec: CreationSpec = {
|
||||
module: 'rcart',
|
||||
label: 'Cart',
|
||||
icon: '🛒',
|
||||
color: 'yellow',
|
||||
docPatterns: ['{space}:rcart:catalog', '{space}:rcart:orders:*'],
|
||||
collections: [
|
||||
{ path: 'items', recordType: 'item', href: hrefFor('rcart') },
|
||||
{
|
||||
path: 'orders',
|
||||
recordType: 'order',
|
||||
title: (r, id) => `Order ${id.slice(0, 8)}`,
|
||||
href: hrefFor('rcart'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const ALL_CREATION_SPECS: CreationSpec[] = [
|
||||
rcalSpec, rnotesSpec, rtasksSpec, rvoteSpec, rphotosSpec, rfilesSpec,
|
||||
rdocsSpec, rsheetsSpec, rtripsSpec, rchoicesSpec, rinboxSpec, rcartSpec,
|
||||
];
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Universal creation-log enumerators.
|
||||
*
|
||||
* Where `MarkwhenSourceFactory` projects *scheduled* events (when will X
|
||||
* happen), a `CreationEnumerator` projects *creations* (when did X come
|
||||
* into being). Every rApp that stores CRDT records with a `createdAt`
|
||||
* field registers one here — and automatically appears in rPast, the
|
||||
* unified chronicle-of-self viewer.
|
||||
*
|
||||
* The contract is deliberately minimal: the enumerator walks whichever
|
||||
* docs it owns and emits one `Creation` per record. The meta-adapter
|
||||
* below turns the merged stream into a single `MwSource` per module.
|
||||
*/
|
||||
|
||||
import type { MwSource, MwEvent } from './types';
|
||||
|
||||
export interface Creation {
|
||||
/** Unix ms — when the record was created. Required. */
|
||||
createdAt: number;
|
||||
/** Unix ms — last edit. Omit if never updated. */
|
||||
updatedAt?: number;
|
||||
/** Single-line display name. */
|
||||
title: string;
|
||||
/** Record-type label (e.g. "event", "note", "task", "vote"). Used as a sub-tag. */
|
||||
recordType: string;
|
||||
/** Stable ID within the module (prefix not required; registry prefixes with module). */
|
||||
recordId: string;
|
||||
/** Optional deep-link back to the canonical record. */
|
||||
href?: string;
|
||||
/** Extra per-record tags (status, category, etc.). */
|
||||
tags?: string[];
|
||||
/** Short body shown indented under the event. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreationEnumerator {
|
||||
/** Module id, e.g. "rcal". */
|
||||
module: string;
|
||||
/** Human label shown in the rPast chip bar / filter UI. */
|
||||
label: string;
|
||||
/** Emoji for chip + section header. */
|
||||
icon: string;
|
||||
/** Color for the module tag in frontmatter. */
|
||||
color?: string;
|
||||
/** Walk owned docs and emit one Creation per record. */
|
||||
enumerate(ctx: { space: string; from?: number; to?: number }): Promise<Creation[]>;
|
||||
}
|
||||
|
||||
const enumerators: Map<string, CreationEnumerator> = new Map();
|
||||
|
||||
export function registerCreationEnumerator(e: CreationEnumerator): void {
|
||||
enumerators.set(e.module, e);
|
||||
}
|
||||
|
||||
export function listCreationEnumerators(): CreationEnumerator[] {
|
||||
return [...enumerators.values()];
|
||||
}
|
||||
|
||||
export async function enumerateCreations(
|
||||
space: string,
|
||||
opts: { modules?: string[]; from?: number; to?: number } = {},
|
||||
): Promise<MwSource[]> {
|
||||
const chosen = opts.modules
|
||||
? opts.modules.map(m => enumerators.get(m)).filter((e): e is CreationEnumerator => !!e)
|
||||
: [...enumerators.values()];
|
||||
|
||||
const sources: MwSource[] = [];
|
||||
for (const e of chosen) {
|
||||
try {
|
||||
const creations = await e.enumerate({ space, from: opts.from, to: opts.to });
|
||||
if (creations.length === 0) continue;
|
||||
|
||||
const events: MwEvent[] = creations.map(c => ({
|
||||
start: c.createdAt,
|
||||
// A creation is a point in time by default. If updatedAt is
|
||||
// meaningfully later, span it so the timeline can show the
|
||||
// "alive" range of the record.
|
||||
end: c.updatedAt && c.updatedAt - c.createdAt > 24 * 3600_000 ? c.updatedAt : undefined,
|
||||
title: `${c.title}`,
|
||||
description: c.description,
|
||||
tags: [c.recordType, ...(c.tags ?? [])],
|
||||
href: c.href,
|
||||
sourceId: `${e.module}:${c.recordType}:${c.recordId}`,
|
||||
}));
|
||||
|
||||
sources.push({
|
||||
id: e.module,
|
||||
label: `${e.icon} ${e.label}`,
|
||||
tag: e.module,
|
||||
color: e.color,
|
||||
events,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[rpast] enumerator ${e.module} failed:`, err);
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Thin shim over rSpace's local-first storage for markwhen source adapters.
|
||||
*
|
||||
* Keeps adapters free of direct dependencies on the concrete storage class,
|
||||
* so they remain unit-testable with plain in-memory docs. Wire the real
|
||||
* implementation in server/local-first at bootstrap via `setDocLoader`.
|
||||
*/
|
||||
|
||||
export interface DocLoader {
|
||||
loadDoc<T>(docId: string): Promise<T | null>;
|
||||
listDocIds(prefix: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
let impl: DocLoader | null = null;
|
||||
|
||||
export function setDocLoader(loader: DocLoader): void {
|
||||
impl = loader;
|
||||
}
|
||||
|
||||
export async function loadDoc<T>(docId: string): Promise<T | null> {
|
||||
if (!impl) throw new Error('markwhen doc-loader not wired — call setDocLoader at bootstrap');
|
||||
return impl.loadDoc<T>(docId);
|
||||
}
|
||||
|
||||
export async function listDocIds(prefix: string): Promise<string[]> {
|
||||
if (!impl) throw new Error('markwhen doc-loader not wired — call setDocLoader at bootstrap');
|
||||
return impl.listDocIds(prefix);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Server-side HTML renderer for markwhen projections.
|
||||
*
|
||||
* Shells out to `@markwhen/mw` CLI (same binary the official tool uses)
|
||||
* rather than importing the parser directly. Why: the view templates'
|
||||
* inlined Vue app was built against `@markwhen/parser@0.10.x`, whose
|
||||
* output shape differs from the npm-current `@markwhen/parser@1.x`. The
|
||||
* CLI ships its own matched parser, so delegating to it avoids any
|
||||
* version-skew silently breaking the render.
|
||||
*
|
||||
* The binary writes HTML to a destination file, so we round-trip through
|
||||
* a per-request temp file pair. ~5ms overhead; cheap vs. the correctness.
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createRequire } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const require_ = createRequire(import.meta.url);
|
||||
let mwBinPath: string | null = null;
|
||||
|
||||
function findMwBinary(): string {
|
||||
if (mwBinPath) return mwBinPath;
|
||||
const pkgJson = require_.resolve('@markwhen/mw/package.json');
|
||||
const pkgDir = pkgJson.slice(0, pkgJson.length - '/package.json'.length);
|
||||
mwBinPath = join(pkgDir, 'lib', 'index.js');
|
||||
return mwBinPath;
|
||||
}
|
||||
|
||||
export function renderMarkwhenHtml(
|
||||
mwText: string,
|
||||
view: 'timeline' | 'calendar' = 'timeline',
|
||||
): string {
|
||||
if (!mwText.trim()) {
|
||||
return `<!doctype html><meta charset="utf-8"><title>empty</title>
|
||||
<body style="display:grid;place-items:center;height:100vh;margin:0;font:13px system-ui;color:#94a3b8;background:#0b1221">
|
||||
<div>No dated events to show yet.</div>
|
||||
</body>`;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rpast-'));
|
||||
const input = join(dir, 'in.mw');
|
||||
const output = join(dir, `out.${view === 'calendar' ? 'calendar' : 'timeline'}.html`);
|
||||
writeFileSync(input, mwText, 'utf-8');
|
||||
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [findMwBinary(), input, '-o', view, '-d', output], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString() ?? '';
|
||||
throw new Error(`mw CLI exited ${result.status}: ${stderr.slice(0, 400)}`);
|
||||
}
|
||||
return readFileSync(output, 'utf-8');
|
||||
} finally {
|
||||
try { unlinkSync(input); } catch {}
|
||||
try { unlinkSync(output); } catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Bootstrap + re-exports for the markwhen projection layer.
|
||||
*
|
||||
* Two complementary projections:
|
||||
* - **Scheduled** (MarkwhenSourceFactory): "what's happening / will happen" —
|
||||
* events with explicit start/end (rCal, rSchedule).
|
||||
* - **Creations** (CreationEnumerator / CreationSpec): "what has been made" —
|
||||
* every CRDT record's birth moment across every rApp. This is what rPast
|
||||
* renders. Declared statically in `creation-specs.ts`.
|
||||
*
|
||||
* Call `initMarkwhen(loader)` once at server/client start.
|
||||
*/
|
||||
|
||||
import { setDocLoader, type DocLoader } from './doc-loader';
|
||||
import { registerMarkwhenSource } from './registry';
|
||||
import { registerCreationEnumerator } from './creations';
|
||||
import { createCreationEnumerator } from './universal-enumerator';
|
||||
import { ALL_CREATION_SPECS } from './creation-specs';
|
||||
|
||||
// Scheduled-event adapters (keep alongside the creation log).
|
||||
import { rcalMarkwhenSource } from './sources/rcal';
|
||||
import { rnotesMarkwhenSource } from './sources/rnotes';
|
||||
|
||||
export { projectSpace, listMarkwhenSources, getMarkwhenSource, registerMarkwhenSource } from './registry';
|
||||
export { renderMarkwhen } from './projection';
|
||||
export {
|
||||
enumerateCreations, registerCreationEnumerator, listCreationEnumerators,
|
||||
} from './creations';
|
||||
export type { Creation, CreationEnumerator } from './creations';
|
||||
export type { CreationSpec, CreationCollection } from './universal-enumerator';
|
||||
export { createCreationEnumerator } from './universal-enumerator';
|
||||
export type { MwEvent, MwSource, MwProjection, MarkwhenSourceFactory } from './types';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initMarkwhen(loader: DocLoader): void {
|
||||
if (initialized) return;
|
||||
setDocLoader(loader);
|
||||
|
||||
// Scheduled-event sources (rCal standalone timeline, etc.).
|
||||
registerMarkwhenSource(rcalMarkwhenSource);
|
||||
registerMarkwhenSource(rnotesMarkwhenSource);
|
||||
|
||||
// Creation-log enumerators — the declarative universal pass.
|
||||
for (const spec of ALL_CREATION_SPECS) {
|
||||
registerCreationEnumerator(createCreationEnumerator(spec));
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Render a set of MwSource into a single markwhen `.mw` document.
|
||||
*
|
||||
* The output is deterministic for a given input (stable sort + escaping)
|
||||
* so that projections can be cached and diffed.
|
||||
*/
|
||||
|
||||
import type { MwEvent, MwProjection, MwSource } from './types';
|
||||
|
||||
const MAX_TITLE = 90;
|
||||
const TAG_COLOR_DEFAULTS: Record<string, string> = {
|
||||
rcal: 'blue',
|
||||
rnotes: 'green',
|
||||
rtasks: 'orange',
|
||||
rvote: 'purple',
|
||||
rschedule: 'teal',
|
||||
rtrips: 'amber',
|
||||
rinbox: 'gray',
|
||||
};
|
||||
|
||||
export interface RenderOptions {
|
||||
title?: string;
|
||||
timezone?: string;
|
||||
/** Default view when a markwhen renderer opens the doc. */
|
||||
view?: 'timeline' | 'calendar' | 'gantt';
|
||||
/** Extra frontmatter tag colors to merge with inferred defaults. */
|
||||
colors?: Record<string, string>;
|
||||
/**
|
||||
* Absolute URL base (protocol + host, no trailing slash). If set, any
|
||||
* relative `href` on events is prefixed with this so the markwhen
|
||||
* parser recognizes it as a link. Required for in-timeline "open in
|
||||
* rApp" clickthrough.
|
||||
*/
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
function iso(ts: number): string {
|
||||
// UTC YYYY-MM-DD HH:mm:ss form; markwhen accepts ISO-like.
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
function sanitizeTitle(raw: string): string {
|
||||
const t = raw.replace(/[\r\n]+/g, ' ').trim();
|
||||
return t.length > MAX_TITLE ? t.slice(0, MAX_TITLE - 1) + '…' : t;
|
||||
}
|
||||
|
||||
function formatRange(ev: MwEvent): string {
|
||||
if (ev.end && ev.end > ev.start) return `${iso(ev.start)} / ${iso(ev.end)}`;
|
||||
return iso(ev.start);
|
||||
}
|
||||
|
||||
function formatTags(tags: string[] | undefined): string {
|
||||
if (!tags || tags.length === 0) return '';
|
||||
return ' ' + tags.map(t => `#${t.replace(/\s+/g, '_')}`).join(' ');
|
||||
}
|
||||
|
||||
function dedup(events: MwEvent[]): MwEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const out: MwEvent[] = [];
|
||||
for (const ev of events) {
|
||||
if (seen.has(ev.sourceId)) continue;
|
||||
seen.add(ev.sourceId);
|
||||
out.push(ev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function absolutizeHref(href: string, baseUrl?: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(href)) return href;
|
||||
if (!baseUrl) return href;
|
||||
const base = baseUrl.replace(/\/$/, '');
|
||||
return href.startsWith('/') ? `${base}${href}` : `${base}/${href}`;
|
||||
}
|
||||
|
||||
function stripEmoji(s: string): string {
|
||||
// Pull leading emoji + space off a label like "📅 Calendar" → "Calendar".
|
||||
return s.replace(/^\p{Extended_Pictographic}\s*/u, '').trim() || s;
|
||||
}
|
||||
|
||||
export function renderMarkwhen(sources: MwSource[], opts: RenderOptions = {}): MwProjection {
|
||||
const title = opts.title ?? 'rSpace Timeline';
|
||||
const tz = opts.timezone ?? 'UTC';
|
||||
const view = opts.view ?? 'timeline';
|
||||
|
||||
const colors: Record<string, string> = { ...opts.colors };
|
||||
for (const s of sources) {
|
||||
if (s.color) colors[s.tag] = s.color;
|
||||
else if (!colors[s.tag] && TAG_COLOR_DEFAULTS[s.id]) colors[s.tag] = TAG_COLOR_DEFAULTS[s.id];
|
||||
}
|
||||
|
||||
const frontColors = Object.entries(colors)
|
||||
.map(([t, c]) => `#${t}: ${c}`)
|
||||
.join('\n');
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('---');
|
||||
lines.push(`title: ${title}`);
|
||||
lines.push(`view: ${view}`);
|
||||
lines.push(`timezone: ${tz}`);
|
||||
if (frontColors) lines.push(frontColors);
|
||||
lines.push('---', '');
|
||||
lines.push('// Generated by shared/markwhen/projection.ts — do not hand-edit.', '');
|
||||
|
||||
const allEvents: MwEvent[] = [];
|
||||
|
||||
for (const src of sources) {
|
||||
const events = dedup([...src.events].sort((a, b) => a.start - b.start));
|
||||
if (events.length === 0) continue;
|
||||
|
||||
const openLabel = `Open in ${stripEmoji(src.label)}`;
|
||||
lines.push(`section ${src.label} #${src.tag}`);
|
||||
for (const ev of events) {
|
||||
const titleLine = `${formatRange(ev)}: ${sanitizeTitle(ev.title)}${formatTags(ev.tags)}`;
|
||||
lines.push(titleLine);
|
||||
if (ev.description) {
|
||||
for (const dLine of ev.description.split(/\r?\n/)) {
|
||||
if (dLine.trim()) lines.push(' ' + dLine);
|
||||
}
|
||||
}
|
||||
if (ev.href) {
|
||||
const url = absolutizeHref(ev.href, opts.baseUrl);
|
||||
// Markdown-style link so the markwhen parser detects it as a
|
||||
// clickable link in the event's description tooltip/popup.
|
||||
lines.push(` [${openLabel}](${url})`);
|
||||
}
|
||||
}
|
||||
lines.push('endSection', '');
|
||||
allEvents.push(...events);
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
events: allEvents,
|
||||
count: allEvents.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Central registry of markwhen source factories.
|
||||
*
|
||||
* Each rApp that has date-bearing records registers a factory here.
|
||||
* The composite viewer (rSaga) and per-module timeline applets pull
|
||||
* from this registry.
|
||||
*/
|
||||
|
||||
import type { MarkwhenSourceFactory, MwSource } from './types';
|
||||
import { renderMarkwhen, type RenderOptions } from './projection';
|
||||
|
||||
const factories: Map<string, MarkwhenSourceFactory> = new Map();
|
||||
|
||||
export function registerMarkwhenSource(factory: MarkwhenSourceFactory): void {
|
||||
factories.set(factory.module, factory);
|
||||
}
|
||||
|
||||
export function listMarkwhenSources(): MarkwhenSourceFactory[] {
|
||||
return [...factories.values()];
|
||||
}
|
||||
|
||||
export function getMarkwhenSource(module: string): MarkwhenSourceFactory | undefined {
|
||||
return factories.get(module);
|
||||
}
|
||||
|
||||
export interface ProjectOptions extends RenderOptions {
|
||||
/** Module ids to include. If omitted, projects all registered sources. */
|
||||
modules?: string[];
|
||||
from?: number;
|
||||
to?: number;
|
||||
}
|
||||
|
||||
export async function projectSpace(space: string, opts: ProjectOptions = {}) {
|
||||
const chosen = opts.modules
|
||||
? opts.modules.map(m => factories.get(m)).filter((f): f is MarkwhenSourceFactory => !!f)
|
||||
: [...factories.values()];
|
||||
|
||||
const sources: MwSource[] = [];
|
||||
for (const f of chosen) {
|
||||
try {
|
||||
const src = await f.build({ space, from: opts.from, to: opts.to });
|
||||
if (src && src.events.length > 0) sources.push(src);
|
||||
} catch (err) {
|
||||
console.warn(`[markwhen] source ${f.module} failed:`, err);
|
||||
}
|
||||
}
|
||||
return renderMarkwhen(sources, opts);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* rCal → markwhen projection.
|
||||
*
|
||||
* Maps `CalendarEvent` records to `MwEvent`. Handles:
|
||||
* - all-day events (normalized to date-only via midnight-UTC)
|
||||
* - per-event timezone (passes through to MwEvent.timezone)
|
||||
* - virtual vs physical events (location shown in description)
|
||||
* - tags: existing event tags + booking status + source name
|
||||
*
|
||||
* NOT handled yet (future work, flagged in markwhen_integration spec):
|
||||
* - RRULE expansion — only stored master events are emitted.
|
||||
* For recurring, expand the next N instances via rrule.js first.
|
||||
*/
|
||||
|
||||
import type { MarkwhenSourceFactory, MwEvent, MwSource } from '../types';
|
||||
import type { CalendarDoc, CalendarEvent } from '../../../modules/rcal/schemas';
|
||||
import { calendarDocId } from '../../../modules/rcal/schemas';
|
||||
import { loadDoc } from '../doc-loader'; // thin wrapper around local-first storage
|
||||
|
||||
const SECTION_LABEL = 'Calendar';
|
||||
const SECTION_TAG = 'rcal';
|
||||
|
||||
function eventToMw(ev: CalendarEvent): MwEvent | null {
|
||||
if (!ev.startTime) return null;
|
||||
const start = ev.allDay ? Math.floor(ev.startTime / 86_400_000) * 86_400_000 : ev.startTime;
|
||||
const end = ev.endTime && ev.endTime > ev.startTime
|
||||
? (ev.allDay ? Math.floor(ev.endTime / 86_400_000) * 86_400_000 : ev.endTime)
|
||||
: undefined;
|
||||
|
||||
const tags = [...(ev.tags ?? [])];
|
||||
if (ev.bookingStatus) tags.push(ev.bookingStatus);
|
||||
if (ev.sourceType && ev.sourceType !== 'local') tags.push(ev.sourceType);
|
||||
if (ev.isVirtual) tags.push('virtual');
|
||||
|
||||
const descParts: string[] = [];
|
||||
if (ev.description) descParts.push(ev.description.slice(0, 500));
|
||||
if (ev.locationName) descParts.push(`📍 ${ev.locationName}`);
|
||||
else if (ev.locationBreadcrumb) descParts.push(`📍 ${ev.locationBreadcrumb}`);
|
||||
if (ev.isVirtual && ev.virtualUrl) descParts.push(`🔗 ${ev.virtualUrl}`);
|
||||
if (ev.attendeeCount) descParts.push(`👥 ${ev.attendeeCount} attendee(s)`);
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
title: ev.title || '(untitled)',
|
||||
description: descParts.length > 0 ? descParts.join('\n') : undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
timezone: ev.timezone ?? undefined,
|
||||
sourceId: `rcal:ev:${ev.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const rcalMarkwhenSource: MarkwhenSourceFactory = {
|
||||
module: 'rcal',
|
||||
label: 'Calendar',
|
||||
icon: '📅',
|
||||
async build({ space, from, to }): Promise<MwSource | null> {
|
||||
const doc = await loadDoc<CalendarDoc>(calendarDocId(space));
|
||||
if (!doc) return null;
|
||||
|
||||
const events: MwEvent[] = [];
|
||||
for (const ev of Object.values(doc.events)) {
|
||||
if (from !== undefined && ev.endTime && ev.endTime < from) continue;
|
||||
if (to !== undefined && ev.startTime && ev.startTime > to) continue;
|
||||
const mw = eventToMw(ev);
|
||||
if (mw) events.push(mw);
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'rcal',
|
||||
label: SECTION_LABEL,
|
||||
tag: SECTION_TAG,
|
||||
color: 'blue',
|
||||
events,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* rNotes → markwhen projection.
|
||||
*
|
||||
* Two strategies, applied in order:
|
||||
* 1. **Daily notes**: paths matching `YYYY-MM-DD` (anywhere in the path)
|
||||
* become all-day events at that date. Title = note title.
|
||||
* 2. **Frontmatter dates**: any note with `frontmatter.date` (ISO) or
|
||||
* `frontmatter.created` / `frontmatter.event_date` becomes a point
|
||||
* event at that moment.
|
||||
*
|
||||
* Notes without extractable dates are silently skipped — this is a view
|
||||
* layer, not a completeness contract.
|
||||
*/
|
||||
|
||||
import type { MarkwhenSourceFactory, MwEvent, MwSource } from '../types';
|
||||
import type { VaultDoc, VaultNoteMeta } from '../../../modules/rnotes/schemas';
|
||||
import { vaultDocId } from '../../../modules/rnotes/schemas';
|
||||
import { loadDoc, listDocIds } from '../doc-loader';
|
||||
|
||||
const DAILY_RE = /(\d{4}-\d{2}-\d{2})(?!\d)/;
|
||||
|
||||
function extractDate(note: VaultNoteMeta): number | null {
|
||||
// Strategy 1: path-embedded YYYY-MM-DD
|
||||
const m = note.path.match(DAILY_RE);
|
||||
if (m) {
|
||||
const d = new Date(m[1] + 'T00:00:00Z');
|
||||
if (!isNaN(d.getTime())) return d.getTime();
|
||||
}
|
||||
// Strategy 2: frontmatter
|
||||
const fm = note.frontmatter ?? {};
|
||||
for (const key of ['date', 'created', 'event_date', 'published']) {
|
||||
const v = fm[key];
|
||||
if (typeof v === 'string' || typeof v === 'number') {
|
||||
const d = new Date(v);
|
||||
if (!isNaN(d.getTime())) return d.getTime();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function noteToMw(vaultId: string, note: VaultNoteMeta): MwEvent | null {
|
||||
const ts = extractDate(note);
|
||||
if (ts === null) return null;
|
||||
|
||||
const tags = note.tags?.length ? [...note.tags] : undefined;
|
||||
|
||||
return {
|
||||
start: ts,
|
||||
title: note.title || note.path,
|
||||
description: undefined, // content is on-demand from ZIP — don't inline
|
||||
tags,
|
||||
href: `rspace://rnotes/${vaultId}/${encodeURIComponent(note.path)}`,
|
||||
sourceId: `rnotes:${vaultId}:${note.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const rnotesMarkwhenSource: MarkwhenSourceFactory = {
|
||||
module: 'rnotes',
|
||||
label: 'Notes',
|
||||
icon: '📓',
|
||||
async build({ space, from, to }): Promise<MwSource | null> {
|
||||
// Each vault is its own doc — fan out over all vaults in the space.
|
||||
const prefix = `${space}:rnotes:vaults:`;
|
||||
const docIds = await listDocIds(prefix);
|
||||
if (docIds.length === 0) return null;
|
||||
|
||||
const events: MwEvent[] = [];
|
||||
for (const docId of docIds) {
|
||||
const vaultId = docId.slice(prefix.length);
|
||||
const doc = await loadDoc<VaultDoc>(vaultDocId(space, vaultId));
|
||||
if (!doc) continue;
|
||||
for (const note of Object.values(doc.notes)) {
|
||||
const mw = noteToMw(vaultId, note);
|
||||
if (!mw) continue;
|
||||
if (from !== undefined && mw.start < from) continue;
|
||||
if (to !== undefined && mw.start > to) continue;
|
||||
events.push(mw);
|
||||
}
|
||||
}
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'rnotes',
|
||||
label: 'Notes',
|
||||
tag: 'rnotes',
|
||||
color: 'green',
|
||||
events,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Universal markwhen projection types for rSpace.
|
||||
*
|
||||
* Any rApp can expose date-bearing Automerge records as a MwSource.
|
||||
* The renderer unions sources into a single `.mw` document that feeds
|
||||
* timeline / calendar / gantt views.
|
||||
*
|
||||
* Storage model: `.mw` is NEVER the source of truth. It is a pure
|
||||
* projection over canonical CRDT state. Do not round-trip edits through it.
|
||||
*/
|
||||
|
||||
/** Hex color or markwhen named color (e.g. "blue"). */
|
||||
export type MwColor = string;
|
||||
|
||||
/** A single event in markwhen's mental model. */
|
||||
export interface MwEvent {
|
||||
/** UTC epoch ms. For all-day events, the renderer normalizes to date-only. */
|
||||
start: number;
|
||||
/** UTC epoch ms. Omit for a point event. */
|
||||
end?: number;
|
||||
/** Single-line headline (truncated at ~90 chars when serialized). */
|
||||
title: string;
|
||||
/** Optional body — becomes indented description under the event. */
|
||||
description?: string;
|
||||
/** Freeform tags. Prefixed with `#` on serialize. Used for coloring + filtering. */
|
||||
tags?: string[];
|
||||
/** Optional deep-link back into the canonical module (e.g. rspace:/cal/ev/123). */
|
||||
href?: string;
|
||||
/** Treat date as wall-clock in the named IANA zone, not UTC. */
|
||||
timezone?: string;
|
||||
/** Stable ID for dedup across projections (e.g. module:collection:recordId). */
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
/** A module's contribution to a composite timeline. */
|
||||
export interface MwSource {
|
||||
/** Short section slug, e.g. "rcal", "rnotes", "rvote". */
|
||||
id: string;
|
||||
/** Display label for the markwhen `section` header. */
|
||||
label: string;
|
||||
/** Section-level tag; applied to every event from this source. */
|
||||
tag: string;
|
||||
/** Optional color for the tag in frontmatter. */
|
||||
color?: MwColor;
|
||||
/** The events themselves. */
|
||||
events: MwEvent[];
|
||||
}
|
||||
|
||||
/** Result of projecting + rendering. */
|
||||
export interface MwProjection {
|
||||
/** Serialized `.mw` text. */
|
||||
text: string;
|
||||
/** Flat list of all events across sources (post-dedup). */
|
||||
events: MwEvent[];
|
||||
/** Total event count. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A module registers a factory that builds its MwSource on demand.
|
||||
* The composite rApp (rSaga) calls these at projection time.
|
||||
*/
|
||||
export interface MarkwhenSourceFactory {
|
||||
/** Module id, e.g. "rcal". */
|
||||
module: string;
|
||||
/** Human label for picker UI. */
|
||||
label: string;
|
||||
/** Emoji for picker UI. */
|
||||
icon: string;
|
||||
/**
|
||||
* Build the source. Receives the current space slug and an optional
|
||||
* date window — implementations should filter events to the window
|
||||
* to keep DOM render sizes bounded (~5k event ceiling for smooth UX).
|
||||
*/
|
||||
build(ctx: { space: string; from?: number; to?: number }): Promise<MwSource | null>;
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Universal declarative creation enumerator.
|
||||
*
|
||||
* Rather than write a bespoke enumerator per rApp, each module declares a
|
||||
* `CreationSpec` that names its Automerge doc patterns, the collection keys
|
||||
* holding records, and a few per-collection accessors. One generic engine
|
||||
* walks the docs and emits creations for all modules uniformly.
|
||||
*
|
||||
* Adding a new module to rPast = ~10 lines in `creation-specs.ts`.
|
||||
*/
|
||||
|
||||
import { loadDoc, listDocIds } from './doc-loader';
|
||||
import type { Creation, CreationEnumerator } from './creations';
|
||||
|
||||
export interface CreationCollection {
|
||||
/** Top-level key in the doc (e.g. "events", "tasks", "notes"). */
|
||||
path: string;
|
||||
/** Short record-type tag, e.g. "event", "task", "note". */
|
||||
recordType: string;
|
||||
/** Field on the record holding the creation timestamp. Defaults to "createdAt". */
|
||||
timestampField?: string;
|
||||
/** Pick the display title. Default: record.title || record.name || record.path || id. */
|
||||
title?: (record: Record<string, unknown>, id: string) => string;
|
||||
/** Build a deep-link URL for a record. */
|
||||
href?: (args: { space: string; docId: string; id: string; record: Record<string, unknown> }) => string | undefined;
|
||||
/** Emit extra per-record tags. */
|
||||
tags?: (record: Record<string, unknown>) => string[] | undefined;
|
||||
/** Return false to skip a record (e.g. abstain votes, tombstones). */
|
||||
filter?: (record: Record<string, unknown>) => boolean;
|
||||
/** Pull a short description body. */
|
||||
description?: (record: Record<string, unknown>) => string | undefined;
|
||||
}
|
||||
|
||||
export interface CreationSpec {
|
||||
module: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
/**
|
||||
* Doc-ID patterns this module owns.
|
||||
* `{space}` is replaced with the space slug.
|
||||
* Trailing `*` is a prefix wildcard (expanded via listDocIds).
|
||||
* Otherwise the pattern is loaded as a single doc.
|
||||
* Examples:
|
||||
* `{space}:cal:events` (single doc)
|
||||
* `{space}:rnotes:vaults:*` (fan-out over all vaults)
|
||||
*/
|
||||
docPatterns: string[];
|
||||
collections: CreationCollection[];
|
||||
}
|
||||
|
||||
function pickTitle(record: Record<string, unknown>, id: string, col: CreationCollection): string {
|
||||
if (col.title) return col.title(record, id);
|
||||
const candidates = ['title', 'name', 'path', 'subject', 'label'] as const;
|
||||
for (const k of candidates) {
|
||||
const v = record[k];
|
||||
if (typeof v === 'string' && v.trim()) return v;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function expandDocIds(pattern: string, space: string): Promise<string[]> {
|
||||
const resolved = pattern.replace(/\{space\}/g, space);
|
||||
if (resolved.endsWith('*')) {
|
||||
return listDocIds(resolved.slice(0, -1));
|
||||
}
|
||||
return [resolved];
|
||||
}
|
||||
|
||||
export function createCreationEnumerator(spec: CreationSpec): CreationEnumerator {
|
||||
return {
|
||||
module: spec.module,
|
||||
label: spec.label,
|
||||
icon: spec.icon,
|
||||
color: spec.color,
|
||||
async enumerate({ space, from, to }) {
|
||||
const out: Creation[] = [];
|
||||
for (const pattern of spec.docPatterns) {
|
||||
const docIds = await expandDocIds(pattern, space);
|
||||
for (const docId of docIds) {
|
||||
const doc = await loadDoc<Record<string, unknown>>(docId);
|
||||
if (!doc) continue;
|
||||
for (const col of spec.collections) {
|
||||
const records = doc[col.path];
|
||||
if (!records || typeof records !== 'object') continue;
|
||||
const tsField = col.timestampField ?? 'createdAt';
|
||||
for (const [id, raw] of Object.entries(records as Record<string, unknown>)) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (col.filter && !col.filter(rec)) continue;
|
||||
const ts = rec[tsField];
|
||||
if (typeof ts !== 'number' || !Number.isFinite(ts)) continue;
|
||||
if (from !== undefined && ts < from) continue;
|
||||
if (to !== undefined && ts > to) continue;
|
||||
const updatedAt = typeof rec.updatedAt === 'number' ? rec.updatedAt as number : undefined;
|
||||
out.push({
|
||||
createdAt: ts,
|
||||
updatedAt,
|
||||
title: pickTitle(rec, id, col),
|
||||
recordType: col.recordType,
|
||||
recordId: id,
|
||||
href: col.href?.({ space, docId, id, record: rec }),
|
||||
tags: col.tags?.(rec),
|
||||
description: col.description?.(rec),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -848,6 +848,24 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rcal/cal.css"),
|
||||
);
|
||||
|
||||
// Build rpast module component
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rpast/components"),
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rpast"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rpast/components/rpast-viewer.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "rpast-viewer.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: { entryFileNames: "rpast-viewer.js" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Build network module component
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue