From c92ca0fe05e231da4ff13adaf5628d36af51cc3f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 12:24:02 -0700 Subject: [PATCH] feat(rsocials): newsletter manager + listmonk proxy + backlog tasks Add Listmonk newsletter management proxy API with role-based auth, newsletter manager component, password setting type support, and new backlog task files. Update newsletter subscribe URL. Co-Authored-By: Claude Opus 4.6 --- ...n-style-automation-canvas-for-rSchedule.md | 55 +++ ...th-cards-sections-and-scroll-navigation.md | 23 ++ ...te-landing-page-demo-page-and-dashboard.md | 25 ++ .../components/folk-newsletter-manager.ts | 382 ++++++++++++++++++ modules/rsocials/components/newsletter.css | 64 +++ modules/rsocials/lib/listmonk-proxy.ts | 45 +++ modules/rsocials/mod.ts | 128 +++++- shared/components/rstack-space-switcher.ts | 2 + shared/module.ts | 2 +- vite.config.ts | 26 ++ website/index.html | 2 +- 11 files changed, 744 insertions(+), 10 deletions(-) create mode 100644 backlog/tasks/task-104 - n8n-style-automation-canvas-for-rSchedule.md create mode 100644 backlog/tasks/task-medium.2 - Enhanced-feed-view-with-cards-sections-and-scroll-navigation.md create mode 100644 backlog/tasks/task-medium.3 - Enhanced-rVote-landing-page-demo-page-and-dashboard.md create mode 100644 modules/rsocials/components/folk-newsletter-manager.ts create mode 100644 modules/rsocials/components/newsletter.css create mode 100644 modules/rsocials/lib/listmonk-proxy.ts diff --git a/backlog/tasks/task-104 - n8n-style-automation-canvas-for-rSchedule.md b/backlog/tasks/task-104 - n8n-style-automation-canvas-for-rSchedule.md new file mode 100644 index 0000000..4851e55 --- /dev/null +++ b/backlog/tasks/task-104 - n8n-style-automation-canvas-for-rSchedule.md @@ -0,0 +1,55 @@ +--- +id: TASK-104 +title: n8n-style automation canvas for rSchedule +status: Done +assignee: [] +created_date: '2026-03-10 18:43' +labels: + - rschedule + - feature + - automation +dependencies: [] +references: + - modules/rschedule/schemas.ts + - modules/rschedule/mod.ts + - modules/rschedule/components/folk-automation-canvas.ts + - modules/rschedule/components/automation-canvas.css + - vite.config.ts +priority: medium +--- + +## Description + + +Visual workflow builder at /:space/rschedule/reminders that lets users wire together triggers, conditions, and actions from any rApp — enabling automations like "if my location approaches home, notify family" or "when document sign-off completes, schedule posts and notify comms director." + +Built with SVG canvas (pan/zoom/Bezier wiring), 15 node types across 3 categories, REST-persisted CRUD, topological execution engine, cron tick loop integration, and webhook trigger endpoint. + + +## Acceptance Criteria + +- [ ] #1 Canvas loads at /:space/rschedule/reminders with node palette +- [ ] #2 Drag nodes from palette, wire ports, configure — auto-saves via REST +- [ ] #3 Run All on manual-trigger workflow — nodes animate, execution log shows results +- [ ] #4 Cron workflows execute on tick loop +- [ ] #5 POST to /api/workflows/webhook/:hookId triggers webhook workflows +- [ ] #6 Demo workflows render correctly on fresh space seed + + +## Final Summary + + +Implemented n8n-style automation canvas for rSchedule with 5 files (2490 lines added): + +**schemas.ts** — 15 automation node types (5 triggers, 4 conditions, 6 actions), NODE_CATALOG with typed ports and config schemas, Workflow/WorkflowNode/WorkflowEdge types, extended ScheduleDoc. + +**folk-automation-canvas.ts** — SVG canvas with pan/zoom, left sidebar node palette (drag-to-add), Bezier edge wiring between typed ports, right sidebar config panel driven by NODE_CATALOG, execution visualization, REST persistence with 1.5s debounced auto-save. + +**automation-canvas.css** — Full dark-theme styling, responsive mobile layout. + +**mod.ts** — Page route (GET /reminders), CRUD API (GET/POST/PUT/DELETE /api/workflows/*), topological execution engine with condition branching, tick loop integration for cron workflows, webhook trigger endpoint, 2 demo workflows (proximity notification + document sign-off pipeline). + +**vite.config.ts** — Build step for component + CSS copy. + +Commits: cc6b5a9 (dev), f22bc47 (main) + diff --git a/backlog/tasks/task-medium.2 - Enhanced-feed-view-with-cards-sections-and-scroll-navigation.md b/backlog/tasks/task-medium.2 - Enhanced-feed-view-with-cards-sections-and-scroll-navigation.md new file mode 100644 index 0000000..1f1765f --- /dev/null +++ b/backlog/tasks/task-medium.2 - Enhanced-feed-view-with-cards-sections-and-scroll-navigation.md @@ -0,0 +1,23 @@ +--- +id: TASK-MEDIUM.2 +title: 'Enhanced feed view with cards, sections, and scroll navigation' +status: Done +assignee: [] +created_date: '2026-03-10 06:20' +updated_date: '2026-03-10 06:20' +labels: [] +dependencies: [] +parent_task_id: TASK-MEDIUM +--- + +## Description + + +Replace minimal feed mode in canvas with polished scroll-through view: shapes wrapped in card containers with icon/title/type headers, grouped by section (type, date, position, alpha) with dividers, sticky scroll summary bar with item counter and clickable section chips for quick navigation. Targets all 35+ shape types. + + +## Implementation Notes + + +Implemented in commit eedf2cf on dev, merged to main. Changes in website/canvas.html: comprehensive CSS for all shape types, feed card wrappers, section headers, scroll summary bar with chips, scroll tracking, and proper enter/exit lifecycle. + diff --git a/backlog/tasks/task-medium.3 - Enhanced-rVote-landing-page-demo-page-and-dashboard.md b/backlog/tasks/task-medium.3 - Enhanced-rVote-landing-page-demo-page-and-dashboard.md new file mode 100644 index 0000000..d2b98bf --- /dev/null +++ b/backlog/tasks/task-medium.3 - Enhanced-rVote-landing-page-demo-page-and-dashboard.md @@ -0,0 +1,25 @@ +--- +id: TASK-MEDIUM.3 +title: 'Enhanced rVote landing page, demo page, and dashboard' +status: Done +assignee: [] +created_date: '2026-03-10 06:43' +updated_date: '2026-03-10 06:53' +labels: [] +dependencies: [] +parent_task_id: TASK-MEDIUM +--- + +## Description + + +Upgraded rVote module to match quality of old rvote.online standalone app: added /demo route with interactive poll page (live sync, connection badge, reset), expanded vote.css with full demo card styling, fixed landing page links to use relative /rvote/demo, enhanced folk-vote-dashboard with inline voting on proposal cards, status-grouped views, create-proposal form, tally bars, and downvote support. + + +## Implementation Notes + + +Implemented in commit 192659b on dev, merged to main. Changes: mod.ts (new /demo route), vote.css (full rd-* styling system), landing.ts (fixed demo links), folk-vote-dashboard.ts (major UI enhancement), vite.config.ts (vote-demo.ts build step). + +Added Reddit-style vote column (up/down chevrons + score), quadratic weight picker, and Priority Trends SVG line chart with score history tracking. Commit 62a96c1. + diff --git a/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts new file mode 100644 index 0000000..116d75d --- /dev/null +++ b/modules/rsocials/components/folk-newsletter-manager.ts @@ -0,0 +1,382 @@ +/** + * — Newsletter management UI backed by Listmonk API proxy. + * + * Attributes: space, role + * Uses EncryptID access token for auth headers. + * Three tabs: Lists, Subscribers, Campaigns. + */ + +import { getAccessToken } from '../../../shared/components/rstack-identity'; + +type Tab = 'lists' | 'subscribers' | 'campaigns'; + +export class FolkNewsletterManager extends HTMLElement { + private _space = 'demo'; + private _role = 'viewer'; + private _tab: Tab = 'lists'; + private _configured = false; + private _loading = true; + private _error = ''; + + // Data + private _lists: any[] = []; + private _subscribers: any[] = []; + private _subscriberTotal = 0; + private _subscriberPage = 1; + private _campaigns: any[] = []; + private _showCreateForm = false; + + static get observedAttributes() { return ['space', 'role']; } + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._space = this.getAttribute('space') || 'demo'; + this._role = this.getAttribute('role') || 'viewer'; + this.render(); + this.checkStatus(); + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this._space = val; + if (name === 'role') this._role = val; + } + + private apiBase(): string { + return `/${this._space}/rsocials/api/newsletter`; + } + + private async apiFetch(path: string, opts: RequestInit = {}): Promise { + const headers = new Headers(opts.headers); + const token = getAccessToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (opts.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + return fetch(`${this.apiBase()}${path}`, { ...opts, headers }); + } + + private async checkStatus() { + this._loading = true; + this.render(); + try { + const res = await fetch(`${this.apiBase()}/status`); + const data = await res.json(); + this._configured = data.configured; + if (this._configured) await this.loadTab(); + } catch (e: any) { + this._error = e.message || 'Failed to check status'; + } + this._loading = false; + this.render(); + } + + private async loadTab() { + this._error = ''; + this._loading = true; + this.render(); + try { + if (this._tab === 'lists') await this.loadLists(); + else if (this._tab === 'subscribers') await this.loadSubscribers(); + else if (this._tab === 'campaigns') await this.loadCampaigns(); + } catch (e: any) { + this._error = e.message || 'Request failed'; + } + this._loading = false; + this.render(); + } + + private async loadLists() { + const res = await this.apiFetch('/lists'); + if (!res.ok) throw new Error(`Failed to load lists (${res.status})`); + const data = await res.json(); + this._lists = data.data?.results || data.results || []; + } + + private async loadSubscribers() { + const res = await this.apiFetch(`/subscribers?page=${this._subscriberPage}&per_page=50`); + if (!res.ok) throw new Error(`Failed to load subscribers (${res.status})`); + const data = await res.json(); + this._subscribers = data.data?.results || data.results || []; + this._subscriberTotal = data.data?.total || data.total || 0; + } + + private async loadCampaigns() { + const res = await this.apiFetch('/campaigns'); + if (!res.ok) throw new Error(`Failed to load campaigns (${res.status})`); + const data = await res.json(); + this._campaigns = data.data?.results || data.results || []; + } + + private async createCampaign(form: HTMLFormElement) { + const fd = new FormData(form); + const body = JSON.stringify({ + name: fd.get('name'), + subject: fd.get('subject'), + body: fd.get('body') || '

Newsletter content

', + content_type: 'richtext', + type: 'regular', + lists: this._lists.length > 0 ? [this._lists[0].id] : [], + }); + + const res = await this.apiFetch('/campaigns', { method: 'POST', body }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || err.error || 'Failed to create campaign'); + } + this._showCreateForm = false; + await this.loadCampaigns(); + } + + private async setCampaignStatus(id: number, status: string) { + const res = await this.apiFetch(`/campaigns/${id}/status`, { + method: 'PUT', + body: JSON.stringify({ status }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + this._error = err.message || err.error || 'Failed to update status'; + this.render(); + return; + } + await this.loadCampaigns(); + this.render(); + } + + private get isAdmin(): boolean { + return this._role === 'admin'; + } + + // ── Rendering ── + + private render() { + const root = this.shadowRoot!; + root.innerHTML = `${this.renderBody()}`; + this.attachListeners(); + } + + private renderBody(): string { + if (this._loading && !this._configured && this._lists.length === 0) { + return `
Loading...
`; + } + + if (!this._configured) { + return this.renderSetup(); + } + + return ` +
+

Newsletter Manager

+

Manage mailing lists, subscribers, and email campaigns via Listmonk

+
+ ${this._error ? `
${this.esc(this._error)}
` : ''} +
+ + + +
+ ${this._loading ? '
Loading...
' : this.renderTabContent()} + `; + } + + private renderSetup(): string { + return ` +
+

Newsletter Not Configured

+

Connect your Listmonk instance to manage newsletters from here.

+
    +
  1. Open the space settings panel (gear icon in the top bar)
  2. +
  3. Find rSocials and click the settings gear
  4. +
  5. Enter your Listmonk URL, username, and password
  6. +
  7. Click Save Module Config
  8. +
+
+ `; + } + + private renderTabContent(): string { + if (this._tab === 'lists') return this.renderLists(); + if (this._tab === 'subscribers') return this.renderSubscribers(); + return this.renderCampaigns(); + } + + private renderLists(): string { + if (this._lists.length === 0) return `
No mailing lists found
`; + + const rows = this._lists.map(l => ` + + ${this.esc(l.name)} + ${this.esc(l.type)} + ${l.subscriber_count ?? '—'} + ${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'} + + `).join(''); + + return ` + + + ${rows} +
NameTypeSubscribersCreated
+ `; + } + + private renderSubscribers(): string { + if (this._subscribers.length === 0) return `
No subscribers found
`; + + const rows = this._subscribers.map(s => ` + + ${this.esc(s.email)} + ${this.esc(s.name || '—')} + ${this.esc(s.status)} + ${(s.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'} + ${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'} + + `).join(''); + + const totalPages = Math.ceil(this._subscriberTotal / 50); + const pagination = totalPages > 1 ? ` +
+ + Page ${this._subscriberPage} of ${totalPages} + +
+ ` : ''; + + return ` + + + ${rows} +
EmailNameStatusListsCreated
+ ${pagination} + `; + } + + private renderCampaigns(): string { + const createBtn = this.isAdmin ? ` +
+ + +
+ ` : ''; + + const form = this._showCreateForm && this.isAdmin ? ` +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ ` : ''; + + if (this._campaigns.length === 0 && !this._showCreateForm) { + return `${createBtn}
No campaigns found
`; + } + + const statusLabel = (s: string) => { + const map: Record = { draft: 'draft', running: 'running', paused: 'paused', finished: 'finished', scheduled: 'active' }; + return map[s] || s; + }; + + const rows = this._campaigns.map(c => { + const actions: string[] = []; + if (c.status === 'draft' && this.isAdmin) { + actions.push(``); + } else if (c.status === 'running') { + actions.push(``); + } else if (c.status === 'paused') { + actions.push(``); + } + return ` + + ${this.esc(c.name)} + ${this.esc(c.subject || '—')} + ${this.esc(c.status)} + ${c.sent ?? '—'} + ${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'} + ${actions.join(' ')} + + `; + }).join(''); + + return ` + ${createBtn} + ${form} + + + ${rows} +
NameSubjectStatusSentCreatedActions
+ `; + } + + // ── Event listeners ── + + private attachListeners() { + const root = this.shadowRoot!; + + // Tab switching + root.querySelectorAll('.nl-tab').forEach(btn => { + btn.addEventListener('click', () => { + this._tab = (btn as HTMLElement).dataset.tab as Tab; + this.loadTab(); + }); + }); + + // Pagination + root.querySelector('[data-action="prev-page"]')?.addEventListener('click', () => { + if (this._subscriberPage > 1) { this._subscriberPage--; this.loadTab(); } + }); + root.querySelector('[data-action="next-page"]')?.addEventListener('click', () => { + this._subscriberPage++; + this.loadTab(); + }); + + // Create campaign toggle + root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => { + btn.addEventListener('click', () => { + this._showCreateForm = !this._showCreateForm; + this.render(); + }); + }); + + // Create campaign form + const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null; + form?.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + await this.createCampaign(form); + this.render(); + } catch (err: any) { + this._error = err.message; + this.render(); + } + }); + + // Campaign status actions + root.querySelectorAll('[data-action="start-campaign"]').forEach(btn => { + btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); + }); + root.querySelectorAll('[data-action="pause-campaign"]').forEach(btn => { + btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'paused')); + }); + root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => { + btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); + }); + } + + private esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +} + +customElements.define('folk-newsletter-manager', FolkNewsletterManager); diff --git a/modules/rsocials/components/newsletter.css b/modules/rsocials/components/newsletter.css new file mode 100644 index 0000000..c95317a --- /dev/null +++ b/modules/rsocials/components/newsletter.css @@ -0,0 +1,64 @@ +/* folk-newsletter-manager styles */ +:host { display: block; max-width: 960px; margin: 0 auto; padding: 1.5rem; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; } + +.nl-header { margin-bottom: 1.5rem; } +.nl-header h2 { font-size: 1.5rem; margin: 0 0 .25rem; } +.nl-header p { color: #a3a3a3; margin: 0; font-size: .9rem; } + +/* Not-configured state */ +.nl-setup { text-align: center; padding: 3rem 1.5rem; background: #1e1e2e; border: 1px dashed #404040; border-radius: 12px; } +.nl-setup h3 { font-size: 1.2rem; margin: 0 0 .5rem; } +.nl-setup p { color: #a3a3a3; margin: .5rem 0; font-size: .9rem; } +.nl-setup .nl-setup-steps { text-align: left; max-width: 420px; margin: 1.5rem auto 0; } +.nl-setup .nl-setup-steps li { color: #a3a3a3; margin-bottom: .5rem; font-size: .85rem; } + +/* Tabs */ +.nl-tabs { display: flex; gap: .5rem; margin-bottom: 1.5rem; border-bottom: 1px solid #333; padding-bottom: 0; } +.nl-tab { padding: .6rem 1.2rem; background: none; border: none; border-bottom: 2px solid transparent; color: #a3a3a3; cursor: pointer; font-size: .9rem; transition: color .15s, border-color .15s; } +.nl-tab:hover { color: #e5e5e5; } +.nl-tab.active { color: #14b8a6; border-bottom-color: #14b8a6; } + +/* Tables */ +.nl-table { width: 100%; border-collapse: collapse; font-size: .85rem; } +.nl-table th { text-align: left; padding: .6rem .8rem; color: #a3a3a3; border-bottom: 1px solid #333; font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; } +.nl-table td { padding: .6rem .8rem; border-bottom: 1px solid #1a1a2e; } +.nl-table tr:hover td { background: #1e1e2e; } + +/* Badges */ +.nl-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: .75rem; font-weight: 500; } +.nl-badge--active { background: #14b8a622; color: #14b8a6; } +.nl-badge--draft { background: #f59e0b22; color: #f59e0b; } +.nl-badge--finished { background: #6366f122; color: #818cf8; } +.nl-badge--running { background: #14b8a622; color: #14b8a6; } +.nl-badge--paused { background: #ef444422; color: #f87171; } +.nl-badge--enabled { background: #14b8a622; color: #14b8a6; } +.nl-badge--blocklisted { background: #ef444422; color: #f87171; } + +/* Buttons */ +.nl-btn { padding: .5rem 1rem; border-radius: 6px; border: 1px solid #333; background: #1e1e2e; color: #e5e5e5; cursor: pointer; font-size: .85rem; transition: background .15s, border-color .15s; } +.nl-btn:hover { background: #252538; border-color: #14b8a6; } +.nl-btn--primary { background: #14b8a6; border-color: #14b8a6; color: #0a0a0a; font-weight: 500; } +.nl-btn--primary:hover { background: #0d9488; } +.nl-btn--sm { padding: .3rem .6rem; font-size: .8rem; } + +/* Toolbar */ +.nl-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; gap: .75rem; flex-wrap: wrap; } +.nl-toolbar input[type="search"] { padding: .4rem .8rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 6px; color: #e5e5e5; font-size: .85rem; min-width: 200px; } + +/* Pagination */ +.nl-pagination { display: flex; align-items: center; justify-content: center; gap: .75rem; margin-top: 1rem; font-size: .85rem; color: #a3a3a3; } + +/* Loading / Empty */ +.nl-loading { text-align: center; padding: 3rem; color: #a3a3a3; } +.nl-empty { text-align: center; padding: 2rem; color: #525252; font-size: .9rem; } + +/* Create campaign form */ +.nl-form { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 1.25rem; margin-bottom: 1.5rem; } +.nl-form label { display: block; font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; } +.nl-form input, .nl-form select, .nl-form textarea { width: 100%; padding: .45rem .7rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; margin-bottom: .75rem; box-sizing: border-box; } +.nl-form textarea { min-height: 80px; resize: vertical; font-family: inherit; } +.nl-form-row { display: flex; gap: .75rem; } +.nl-form-row > * { flex: 1; } + +/* Error */ +.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; } diff --git a/modules/rsocials/lib/listmonk-proxy.ts b/modules/rsocials/lib/listmonk-proxy.ts new file mode 100644 index 0000000..d61c9cd --- /dev/null +++ b/modules/rsocials/lib/listmonk-proxy.ts @@ -0,0 +1,45 @@ +/** + * Listmonk API proxy helper — reads per-space credentials from module settings + * and forwards requests with Basic Auth. + */ + +import { loadCommunity, getDocumentData } from "../../../server/community-store"; + +export interface ListmonkConfig { + url: string; + user: string; + password: string; +} + +/** Read Listmonk credentials from the space's module settings. */ +export async function getListmonkConfig(spaceSlug: string): Promise { + await loadCommunity(spaceSlug); + const data = getDocumentData(spaceSlug); + if (!data) return null; + + const settings = data.meta.moduleSettings?.rsocials; + if (!settings) return null; + + const url = settings.listmonkUrl as string | undefined; + const user = settings.listmonkUser as string | undefined; + const password = settings.listmonkPassword as string | undefined; + + if (!url || !user || !password) return null; + return { url: url.replace(/\/+$/, ''), user, password }; +} + +/** Proxy a request to the Listmonk API with Basic Auth. */ +export async function listmonkFetch( + config: ListmonkConfig, + path: string, + opts: RequestInit = {}, +): Promise { + const auth = Buffer.from(`${config.user}:${config.password}`).toString("base64"); + const headers = new Headers(opts.headers); + headers.set("Authorization", `Basic ${auth}`); + if (!headers.has("Content-Type") && opts.body) { + headers.set("Content-Type", "application/json"); + } + + return fetch(`${config.url}${path}`, { ...opts, headers }); +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index a172d65..fad2969 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -30,6 +30,11 @@ import { deleteOldImage, } from "./lib/image-gen"; import { DEMO_FEED } from "./lib/types"; +import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy"; +import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; +import type { EncryptIDClaims } from "@encryptid/sdk/server"; +import { resolveCallerRole, roleAtLeast } from "../../server/spaces"; +import type { SpaceRoleString } from "../../server/spaces"; let _syncServer: SyncServer | null = null; @@ -407,6 +412,98 @@ routes.delete("/api/threads/:id/images", async (c) => { return c.json({ ok: true }); }); +// ── Newsletter (Listmonk) proxy API ── + +async function requireNewsletterRole(c: any, minRole: SpaceRoleString): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString } | Response> { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const result = await resolveCallerRole(space, claims); + if (!result || !roleAtLeast(result.role, minRole)) { + return c.json({ error: "Insufficient permissions" }, 403); + } + return { claims, role: result.role }; +} + +routes.get("/api/newsletter/status", async (c) => { + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + return c.json({ configured: !!config }); +}); + +routes.get("/api/newsletter/lists", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const res = await listmonkFetch(config, "/api/lists"); + const data = await res.json(); + return c.json(data, res.status as any); +}); + +routes.get("/api/newsletter/subscribers", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const url = new URL(c.req.url); + const query = url.search || ""; + const res = await listmonkFetch(config, `/api/subscribers${query}`); + const data = await res.json(); + return c.json(data, res.status as any); +}); + +routes.get("/api/newsletter/campaigns", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const res = await listmonkFetch(config, "/api/campaigns"); + const data = await res.json(); + return c.json(data, res.status as any); +}); + +routes.post("/api/newsletter/campaigns", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const body = await c.req.text(); + const res = await listmonkFetch(config, "/api/campaigns", { method: "POST", body }); + const data = await res.json(); + return c.json(data, res.status as any); +}); + +routes.put("/api/newsletter/campaigns/:id/status", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const config = await getListmonkConfig(space); + if (!config) return c.json({ error: "Listmonk not configured" }, 404); + + const campaignId = c.req.param("id"); + const body = await c.req.text(); + const res = await listmonkFetch(config, `/api/campaigns/${campaignId}/status`, { method: "PUT", body }); + const data = await res.json(); + return c.json(data, res.status as any); +}); + // ── Page routes (inject web components) ── routes.get("/campaign", (c) => { @@ -576,9 +673,6 @@ function renderDemoFeedHTML(): string { // The /scheduler route renders a full-page iframe shell. const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online"; -// ── Listmonk newsletter — embedded via iframe ── -// Listmonk admin at newsletter.cosmolocal.world (not behind CF Access). -const LISTMONK_URL = process.env.LISTMONK_URL || "https://newsletter.cosmolocal.world"; routes.get("/scheduler", (c) => { const space = c.req.param("space") || "demo"; @@ -594,16 +688,29 @@ routes.get("/scheduler", (c) => { })); }); -routes.get("/newsletter-list", (c) => { +routes.get("/newsletter-list", async (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderExternalAppShell({ - title: `Newsletter List — rSocials | rSpace`, + + // Resolve caller role for UI gating (viewer fallback for unauthenticated) + let role: SpaceRoleString = 'viewer'; + const token = extractToken(c.req.raw.headers); + if (token) { + try { + const claims = await verifyEncryptIDToken(token); + const result = await resolveCallerRole(space, claims); + if (result) role = result.role; + } catch { /* keep viewer default */ } + } + + return c.html(renderShell({ + title: `Newsletter — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - appName: "Listmonk", - appUrl: LISTMONK_URL, + body: ``, + styles: ``, + scripts: ``, })); }); @@ -712,6 +819,11 @@ export const socialsModule: RSpaceModule = { docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }], routes, publicWrite: true, + settingsSchema: [ + { key: 'listmonkUrl', label: 'Listmonk URL', type: 'string', description: 'Base URL of your Listmonk instance (e.g. https://newsletter.example.com)' }, + { key: 'listmonkUser', label: 'Listmonk Username', type: 'string', description: 'API username for Listmonk' }, + { key: 'listmonkPassword', label: 'Listmonk Password', type: 'password', description: 'API password for Listmonk' }, + ], standaloneDomain: "rsocials.online", landingPage: renderLanding, seedTemplate: seedTemplateSocials, diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 13b56c8..0aced90 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -915,6 +915,8 @@ export class RStackSpaceSwitcher extends HTMLElement { html += ``; } else if (field.type === 'notebook-id') { html += ``; + } else if (field.type === 'password') { + html += ``; } else { html += ``; } diff --git a/shared/module.ts b/shared/module.ts index 10cce7e..9110502 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -54,7 +54,7 @@ export interface SubPageInfo { // ── Per-Module Settings ── -export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id'; +export type ModuleSettingType = 'string' | 'boolean' | 'select' | 'notebook-id' | 'password'; export interface ModuleSettingField { /** Storage key */ diff --git a/vite.config.ts b/vite.config.ts index 2aeebc1..d054b54 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -718,6 +718,32 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rsocials/campaign-planner.css"), ); + // Build newsletter manager component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-newsletter-manager.ts"), + formats: ["es"], + fileName: () => "folk-newsletter-manager.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-newsletter-manager.js", + }, + }, + }, + }); + + // Copy newsletter CSS + copyFileSync( + resolve(__dirname, "modules/rsocials/components/newsletter.css"), + resolve(__dirname, "dist/modules/rsocials/newsletter.css"), + ); + // Build tube module component await build({ configFile: false, diff --git a/website/index.html b/website/index.html index 81481af..7fafa52 100644 --- a/website/index.html +++ b/website/index.html @@ -579,7 +579,7 @@ newsletterStatus.className = "newsletter-status"; try { - const res = await fetch("https://newsletter.jeffemmett.com/subscribe", { + const res = await fetch("https://newsletter.rspace.online/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({