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 <noreply@anthropic.com>
This commit is contained in:
parent
e1bdc98b98
commit
c92ca0fe05
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
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)
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
/**
|
||||
* <folk-newsletter-manager> — 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<Response> {
|
||||
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') || '<p>Newsletter content</p>',
|
||||
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 = `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">${this.renderBody()}`;
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
private renderBody(): string {
|
||||
if (this._loading && !this._configured && this._lists.length === 0) {
|
||||
return `<div class="nl-loading">Loading...</div>`;
|
||||
}
|
||||
|
||||
if (!this._configured) {
|
||||
return this.renderSetup();
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="nl-header">
|
||||
<h2>Newsletter Manager</h2>
|
||||
<p>Manage mailing lists, subscribers, and email campaigns via Listmonk</p>
|
||||
</div>
|
||||
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
|
||||
<div class="nl-tabs">
|
||||
<button class="nl-tab ${this._tab === 'lists' ? 'active' : ''}" data-tab="lists">Lists</button>
|
||||
<button class="nl-tab ${this._tab === 'subscribers' ? 'active' : ''}" data-tab="subscribers">Subscribers</button>
|
||||
<button class="nl-tab ${this._tab === 'campaigns' ? 'active' : ''}" data-tab="campaigns">Campaigns</button>
|
||||
</div>
|
||||
${this._loading ? '<div class="nl-loading">Loading...</div>' : this.renderTabContent()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSetup(): string {
|
||||
return `
|
||||
<div class="nl-setup">
|
||||
<h3>Newsletter Not Configured</h3>
|
||||
<p>Connect your Listmonk instance to manage newsletters from here.</p>
|
||||
<ol class="nl-setup-steps">
|
||||
<li>Open the space settings panel (gear icon in the top bar)</li>
|
||||
<li>Find <strong>rSocials</strong> and click the settings gear</li>
|
||||
<li>Enter your Listmonk URL, username, and password</li>
|
||||
<li>Click <strong>Save Module Config</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `<div class="nl-empty">No mailing lists found</div>`;
|
||||
|
||||
const rows = this._lists.map(l => `
|
||||
<tr>
|
||||
<td>${this.esc(l.name)}</td>
|
||||
<td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
|
||||
<td>${l.subscriber_count ?? '—'}</td>
|
||||
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<table class="nl-table">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSubscribers(): string {
|
||||
if (this._subscribers.length === 0) return `<div class="nl-empty">No subscribers found</div>`;
|
||||
|
||||
const rows = this._subscribers.map(s => `
|
||||
<tr>
|
||||
<td>${this.esc(s.email)}</td>
|
||||
<td>${this.esc(s.name || '—')}</td>
|
||||
<td><span class="nl-badge nl-badge--${s.status === 'enabled' ? 'enabled' : 'blocklisted'}">${this.esc(s.status)}</span></td>
|
||||
<td>${(s.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—'}</td>
|
||||
<td>${s.created_at ? new Date(s.created_at).toLocaleDateString() : '—'}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const totalPages = Math.ceil(this._subscriberTotal / 50);
|
||||
const pagination = totalPages > 1 ? `
|
||||
<div class="nl-pagination">
|
||||
<button class="nl-btn nl-btn--sm" data-action="prev-page" ${this._subscriberPage <= 1 ? 'disabled' : ''}>Prev</button>
|
||||
<span>Page ${this._subscriberPage} of ${totalPages}</span>
|
||||
<button class="nl-btn nl-btn--sm" data-action="next-page" ${this._subscriberPage >= totalPages ? 'disabled' : ''}>Next</button>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<table class="nl-table">
|
||||
<thead><tr><th>Email</th><th>Name</th><th>Status</th><th>Lists</th><th>Created</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
${pagination}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCampaigns(): string {
|
||||
const createBtn = this.isAdmin ? `
|
||||
<div class="nl-toolbar">
|
||||
<span></span>
|
||||
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const form = this._showCreateForm && this.isAdmin ? `
|
||||
<form class="nl-form" data-form="create-campaign">
|
||||
<div class="nl-form-row">
|
||||
<div>
|
||||
<label>Campaign Name</label>
|
||||
<input name="name" required placeholder="My Newsletter #1" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Subject Line</label>
|
||||
<input name="subject" required placeholder="This week's update" />
|
||||
</div>
|
||||
</div>
|
||||
<label>Body (HTML)</label>
|
||||
<textarea name="body" placeholder="<p>Newsletter content goes here...</p>"></textarea>
|
||||
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem;">
|
||||
<button type="button" class="nl-btn" data-action="toggle-create">Cancel</button>
|
||||
<button type="submit" class="nl-btn nl-btn--primary">Create Campaign</button>
|
||||
</div>
|
||||
</form>
|
||||
` : '';
|
||||
|
||||
if (this._campaigns.length === 0 && !this._showCreateForm) {
|
||||
return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
|
||||
}
|
||||
|
||||
const statusLabel = (s: string) => {
|
||||
const map: Record<string, string> = { 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(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
|
||||
} else if (c.status === 'running') {
|
||||
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
|
||||
} else if (c.status === 'paused') {
|
||||
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td>${this.esc(c.name)}</td>
|
||||
<td>${this.esc(c.subject || '—')}</td>
|
||||
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td>
|
||||
<td>${c.sent ?? '—'}</td>
|
||||
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td>${actions.join(' ')}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
${createBtn}
|
||||
${form}
|
||||
<table class="nl-table">
|
||||
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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<ListmonkConfig | null> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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: `<folk-newsletter-manager space="${escapeHtml(space)}" role="${escapeHtml(role)}"></folk-newsletter-manager>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">`,
|
||||
scripts: `<script type="module" src="/modules/rsocials/folk-newsletter-manager.js"></script>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -915,6 +915,8 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
|||
html += `</select>`;
|
||||
} else if (field.type === 'notebook-id') {
|
||||
html += `<select class="mod-setting scope-select mod-notebook-select" data-mod-id="${m.id}" data-key="${field.key}" data-space="${slug}"><option value="">None</option></select>`;
|
||||
} else if (field.type === 'password') {
|
||||
html += `<input type="password" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" value="${typeof val === 'string' ? val.replace(/"/g, '"') : ''}" style="width:100%;padding:5px 8px;background:#0a0a0a;border:1px solid #404040;border-radius:4px;color:#e5e5e5;font-size:12px;" autocomplete="off" />`;
|
||||
} else {
|
||||
html += `<input type="text" class="mod-setting" data-mod-id="${m.id}" data-key="${field.key}" value="${typeof val === 'string' ? val.replace(/"/g, '"') : ''}" style="width:100%;padding:5px 8px;background:#0a0a0a;border:1px solid #404040;border-radius:4px;color:#e5e5e5;font-size:12px;" />`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue