merge(dev): rsocials per-platform post fields + auth headers
CI/CD / deploy (push) Successful in 3m44s Details

This commit is contained in:
Jeff Emmett 2026-04-17 14:16:20 -04:00
commit 0be5e15c22
6 changed files with 829 additions and 18 deletions

View File

@ -95,6 +95,228 @@ folk-campaign-planner {
position: relative;
}
/* ── Platform palette (drag source sidebar) ── */
.cp-pp {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface-sunken, #14141e);
transition: width 0.15s ease;
overflow: hidden;
}
.cp-pp--collapsed {
width: 38px;
}
.cp-pp--collapsed .cp-pp-title,
.cp-pp--collapsed .cp-pp-hint,
.cp-pp--collapsed .cp-pp-list {
display: none;
}
.cp-pp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
}
.cp-pp-title {
font-size: 12px;
font-weight: 600;
color: var(--rs-text-primary, #e1e1e1);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cp-pp-toggle {
background: transparent;
border: 1px solid var(--rs-border, #3d3d5c);
color: var(--rs-text-secondary, #a0a0b8);
border-radius: 5px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
}
.cp-pp-toggle:hover { background: var(--rs-bg-surface-raised, #252540); }
.cp-pp-hint {
padding: 8px 12px;
font-size: 11px;
color: var(--rs-text-muted, #888);
line-height: 1.4;
border-bottom: 1px solid var(--rs-border, #2d2d44);
}
.cp-pp-list {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.cp-pp-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--rs-bg-surface, #1a1a2e);
border: 1px solid var(--rs-border, #2d2d44);
cursor: grab;
user-select: none;
transition: border-color 0.1s, background 0.1s, transform 0.05s;
}
.cp-pp-chip:hover {
border-color: var(--rs-border-strong, #3d3d5c);
background: var(--rs-bg-surface-raised, #252540);
}
.cp-pp-chip:active { cursor: grabbing; }
.cp-pp-chip.dragging {
opacity: 0.5;
transform: scale(0.96);
}
.cp-pp-chip__icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.cp-pp-chip__meta {
min-width: 0;
flex: 1;
}
.cp-pp-chip__label {
font-size: 12px;
font-weight: 600;
color: var(--rs-text-primary, #e1e1e1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cp-pp-chip__limit {
font-size: 10px;
color: var(--rs-text-muted, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cp-canvas--drop-target {
outline: 2px dashed #3b82f6;
outline-offset: -4px;
background: color-mix(in srgb, var(--rs-canvas-bg, #0f0f23) 92%, #3b82f6);
}
/* ── Inline config: platform-specific bits ── */
.cp-icp-platform-note {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 8px;
border-radius: 6px;
background: color-mix(in srgb, var(--plat, #3b82f6) 10%, transparent);
border-left: 3px solid var(--plat, #3b82f6);
font-size: 11px;
line-height: 1.35;
color: var(--rs-text-secondary, #a0a0b8);
}
.cp-icp-platform-icon {
width: 22px;
height: 22px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.cp-icp-platform-text {
flex: 1;
min-width: 0;
}
.cp-icp-sub {
font-size: 10px;
color: var(--rs-text-muted, #888);
font-weight: 400;
margin-left: 4px;
}
.cp-icp-charcount.over,
.cp-icp-sub.over {
color: #ef4444;
font-weight: 600;
}
.cp-inline-config textarea.over,
.cp-inline-config input.over {
border-color: #ef4444 !important;
}
.cp-icp-warn {
padding: 6px 8px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
color: #f59e0b;
font-size: 11px;
margin: 4px 0 6px;
}
.cp-icp-warn.ok {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.35);
color: #22c55e;
}
.cp-icp-check {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--rs-text-secondary, #a0a0b8);
cursor: pointer;
margin: 4px 0;
}
.cp-icp-check input { width: auto !important; margin: 0; }
@media (max-width: 720px) {
.cp-pp { width: 56px; }
.cp-pp-title, .cp-pp-hint { display: none; }
.cp-pp-chip__meta { display: none; }
.cp-pp-chip { padding: 6px; justify-content: center; }
}
.cp-canvas {
flex: 1;
position: relative;

View File

@ -28,6 +28,14 @@ import type {
import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import {
PLATFORM_SPECS,
ALL_PLATFORM_IDS,
getPlatformSpec,
charLimitFor,
type PostizPlatformId,
type PlatformSpec,
} from '../lib/platform-specs';
// ── Port definitions ──
@ -718,6 +726,45 @@ class FolkCampaignPlanner extends HTMLElement {
this.scheduleSave();
}
/**
* Create a platform-tagged post node from a Postiz spec. Pre-populates
* platform, postType, title, and platformSettings so the inline editor
* surfaces the right constraints immediately.
*/
private addPostForPlatform(platformId: PostizPlatformId, x: number, y: number) {
const spec = getPlatformSpec(platformId);
if (!spec) {
this.addNode('post', x, y);
return;
}
const id = `post-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const defaults: Record<string, unknown> = {};
for (const f of spec.extraFields || []) {
if (f.type === 'select' && f.options?.length) defaults[f.key] = f.options[0];
else if (f.type === 'checkbox') defaults[f.key] = false;
else defaults[f.key] = '';
}
const data: PostNodeData = {
label: `${spec.label} Post`,
platform: platformId,
postType: spec.defaultPostType,
content: '',
scheduledAt: '',
status: 'draft',
hashtags: [],
title: spec.titleRequired || spec.titleLimit ? '' : undefined,
mediaUrls: spec.mediaRequired ? [] : undefined,
platformSettings: Object.keys(defaults).length ? defaults : undefined,
};
const node: CampaignPlannerNode = { id, type: 'post', position: { x, y }, data };
this.nodes.push(node);
this.drawCanvasContent();
this.selectedNodeId = id;
this.updateSelectionHighlight();
this.scheduleSave();
setTimeout(() => this.enterInlineEdit(id), 50);
}
private deleteNode(id: string) {
this.nodes = this.nodes.filter(n => n.id !== id);
this.edges = this.edges.filter(e => e.from !== id && e.to !== id);
@ -814,19 +861,68 @@ class FolkCampaignPlanner extends HTMLElement {
: `\u23f3 Queued in Postiz (${esc(d.postizPostId || '')})`}
</div>`
: '';
const canSend = !hasPostiz && !!d.content?.trim();
const spec = getPlatformSpec(d.platform);
const charCount = (d.content || '').length;
const charMax = spec?.charLimit ?? 2200;
const overLimit = charCount > charMax;
const hashtagCount = (d.hashtags || []).length;
const hashtagMax = spec?.hashtagLimit ?? 30;
const tagOver = hashtagMax > 0 && hashtagCount > hashtagMax;
const needsMedia = !!spec?.mediaRequired;
const hasMedia = !!(d.mediaUrls && d.mediaUrls.length > 0);
const canSend = !hasPostiz && !!d.content?.trim()
&& !overLimit && (!needsMedia || hasMedia);
const titleField = (spec?.titleLimit || spec?.titleRequired) ? `
<label>${spec?.titleRequired ? 'Title *' : 'Title'}${spec?.titleLimit ? ` <span class="cp-icp-sub">(${(d.title || '').length}/${spec.titleLimit})</span>` : ''}</label>
<input data-field="title" value="${esc(d.title || '')}" maxlength="${spec?.titleLimit || ''}" placeholder="${esc(spec?.label || '')} title"/>
` : '';
const postTypeOptions = (spec?.postTypes || ['text', 'thread', 'image', 'carousel', 'video', 'short'])
.map(t => `<option value="${t}" ${d.postType === t ? 'selected' : ''}>${t}</option>`).join('');
const extraFields = (spec?.extraFields || []).map(f => {
const val = (d.platformSettings?.[f.key] ?? '') as any;
if (f.type === 'select') {
return `<label>${esc(f.label)}</label>
<select data-platform-field="${f.key}">
${(f.options || []).map(o =>
`<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`
).join('')}
</select>`;
}
if (f.type === 'checkbox') {
return `<label class="cp-icp-check">
<input type="checkbox" data-platform-field="${f.key}" ${val ? 'checked' : ''}/>
${esc(f.label)}
</label>`;
}
if (f.type === 'textarea') {
return `<label>${esc(f.label)}</label>
<textarea data-platform-field="${f.key}" rows="2"${f.maxLength ? ` maxlength="${f.maxLength}"` : ''} placeholder="${esc(f.placeholder || '')}">${esc(String(val || ''))}</textarea>`;
}
return `<label>${esc(f.label)}${f.required ? ' *' : ''}</label>
<input data-platform-field="${f.key}" value="${esc(String(val || ''))}"${f.maxLength ? ` maxlength="${f.maxLength}"` : ''} placeholder="${esc(f.placeholder || '')}"/>`;
}).join('');
body = `
<div class="cp-icp-body">
<label>Content</label>
<textarea data-field="content" rows="4">${esc(d.content)}</textarea>
${spec ? `<div class="cp-icp-platform-note" style="--plat:${spec.color}">
<span class="cp-icp-platform-icon" style="background:${spec.color}22;color:${spec.color}">${spec.icon}</span>
<span class="cp-icp-platform-text">${esc(spec.note)}</span>
</div>` : ''}
${titleField}
<label>Content <span class="cp-icp-sub cp-icp-charcount ${overLimit ? 'over' : ''}">${charCount}/${charMax}</span></label>
<textarea data-field="content" rows="4" class="${overLimit ? 'over' : ''}" placeholder="Write your ${esc(spec?.label || 'post')}…">${esc(d.content)}</textarea>
<label>Platform</label>
<select data-field="platform">
${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
${ALL_PLATFORM_IDS.map(p =>
`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${PLATFORM_SPECS[p].label}</option>`
).join('')}
</select>
<label>Post Type</label>
<input data-field="postType" value="${esc(d.postType)}" placeholder="text, thread, carousel..."/>
<select data-field="postType">${postTypeOptions}</select>
${needsMedia ? `<div class="cp-icp-warn ${hasMedia ? 'ok' : ''}">${hasMedia ? '\u2713' : '\u26a0'} ${esc(spec!.label)} requires ${spec!.mediaTypes.join('/')}. Up to ${spec!.maxMedia}.</div>` : ''}
<label>Scheduled</label>
<input type="datetime-local" data-field="scheduledAt" value="${d.scheduledAt}"/>
<label>Status</label>
@ -835,8 +931,9 @@ class FolkCampaignPlanner extends HTMLElement {
<option value="scheduled" ${d.status === 'scheduled' ? 'selected' : ''}>Scheduled</option>
<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
</select>
<label>Hashtags (comma-separated)</label>
<input data-field="hashtags" value="${esc(d.hashtags.join(', '))}"/>
<label>Hashtags${spec && spec.hashtagLimit > 0 ? ` <span class="cp-icp-sub ${tagOver ? 'over' : ''}">${hashtagCount}/${spec.hashtagLimit}</span>` : ''}</label>
<input data-field="hashtags" value="${esc(d.hashtags.join(', '))}" placeholder="${spec?.hashtagLimit === 0 ? 'Not used on this platform' : 'comma-separated'}"/>
${extraFields}
${postizBadge}
<button class="cp-btn cp-btn--postiz" data-action="send-postiz" ${canSend ? '' : 'disabled'} style="margin-top:8px;width:100%">
${hasPostiz ? 'Sent to Postiz' : (d.scheduledAt ? '\u{1f4c5} Schedule to Postiz' : '\u{1f680} Send to Postiz now')}
@ -1053,6 +1150,52 @@ class FolkCampaignPlanner extends HTMLElement {
el.addEventListener('change', handler);
});
// Platform-specific extra fields (e.g. YouTube title/category, Reddit subreddit)
panel.querySelectorAll('[data-platform-field]').forEach(el => {
const key = el.getAttribute('data-platform-field')!;
const handler = () => {
const input = el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
const val = (input as HTMLInputElement).type === 'checkbox'
? (input as HTMLInputElement).checked
: input.value;
const d = node.data as PostNodeData;
if (!d.platformSettings) d.platformSettings = {};
d.platformSettings[key] = val;
this.scheduleSave();
};
el.addEventListener('input', handler);
el.addEventListener('change', handler);
});
// When platform changes, reopen inline edit so platform-specific
// fields (title, extras, char counter) re-render for the new spec.
if (node.type === 'post') {
const platSel = panel.querySelector('select[data-field="platform"]') as HTMLSelectElement | null;
platSel?.addEventListener('change', () => {
const d = node.data as PostNodeData;
const spec = getPlatformSpec(d.platform);
if (spec) {
d.postType = spec.postTypes.includes(d.postType as any)
? d.postType : spec.defaultPostType;
if ((spec.titleRequired || spec.titleLimit) && d.title === undefined) d.title = '';
if (spec.mediaRequired && !d.mediaUrls) d.mediaUrls = [];
const settings: Record<string, unknown> = { ...(d.platformSettings || {}) };
for (const f of spec.extraFields || []) {
if (!(f.key in settings)) {
settings[f.key] = f.type === 'select' && f.options?.length
? f.options[0]
: f.type === 'checkbox' ? false : '';
}
}
d.platformSettings = Object.keys(settings).length ? settings : undefined;
}
const id = node.id;
this.exitInlineEdit();
this.drawCanvasContent();
setTimeout(() => this.enterInlineEdit(id), 20);
});
}
// Brief platform checkboxes
panel.querySelectorAll('[data-brief-platform]').forEach(cb => {
cb.addEventListener('change', () => {
@ -1529,6 +1672,32 @@ class FolkCampaignPlanner extends HTMLElement {
// ── Rendering ──
private paletteCollapsed = false;
private renderPlatformPalette(): string {
const chips = ALL_PLATFORM_IDS.map(id => {
const s = PLATFORM_SPECS[id];
const limit = s.charLimit >= 10000 ? `${Math.round(s.charLimit / 1000)}k` : `${s.charLimit}`;
return `
<div class="cp-pp-chip" draggable="true" data-platform="${id}" title="${esc(s.label)} — ${esc(s.note)}">
<span class="cp-pp-chip__icon" style="background:${s.color}22;color:${s.color}">${s.icon}</span>
<div class="cp-pp-chip__meta">
<div class="cp-pp-chip__label">${esc(s.label)}</div>
<div class="cp-pp-chip__limit">${limit} chars${s.mediaRequired ? ' · media' : ''}${s.titleRequired ? ' · title' : ''}${s.supportsThreads ? ' · thread' : ''}</div>
</div>
</div>`;
}).join('');
return `
<aside class="cp-pp ${this.paletteCollapsed ? 'cp-pp--collapsed' : ''}" id="cp-platform-palette">
<div class="cp-pp-header">
<span class="cp-pp-title">Drag onto canvas</span>
<button class="cp-pp-toggle" id="cp-pp-toggle" title="Toggle palette">${this.paletteCollapsed ? '\u00bb' : '\u00ab'}</button>
</div>
<div class="cp-pp-hint">Each drop creates a post pre-tagged with the platform's limits &amp; fields.</div>
<div class="cp-pp-list">${chips}</div>
</aside>`;
}
private render() {
const schedulerUrl = 'https://demo.rsocials.online';
@ -1577,6 +1746,7 @@ class FolkCampaignPlanner extends HTMLElement {
` : ''}
<div class="cp-canvas-area">
${this.renderPlatformPalette()}
<div class="cp-canvas" id="cp-canvas">
<svg id="cp-svg" width="100%" height="100%">
<defs>
@ -1700,7 +1870,7 @@ class FolkCampaignPlanner extends HTMLElement {
: '#f59e0b';
const preview = (d.content || '').split('\n')[0].substring(0, 50);
const charCount = (d.content || '').length;
const charMax = platform === 'x' ? 280 : 2200;
const charMax = charLimitFor(platform);
const charPct = Math.min(1, charCount / charMax) * 100;
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled';
const staleBadge = node.stale ? `<span style="display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;background:#f59e0b22;color:#f59e0b;font-size:8px;font-weight:600;flex-shrink:0">\u26a0 stale</span>` : '';
@ -2249,10 +2419,61 @@ class FolkCampaignPlanner extends HTMLElement {
// ── Event listeners ──
private attachPaletteListeners() {
const palette = this.shadow.getElementById('cp-platform-palette');
const canvas = this.shadow.getElementById('cp-canvas');
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
if (!palette || !canvas || !svg) return;
this.shadow.getElementById('cp-pp-toggle')?.addEventListener('click', () => {
this.paletteCollapsed = !this.paletteCollapsed;
palette.classList.toggle('cp-pp--collapsed', this.paletteCollapsed);
const btn = this.shadow.getElementById('cp-pp-toggle');
if (btn) btn.textContent = this.paletteCollapsed ? '\u00bb' : '\u00ab';
});
palette.querySelectorAll<HTMLElement>('.cp-pp-chip').forEach(chip => {
chip.addEventListener('dragstart', (e: DragEvent) => {
const platform = chip.getAttribute('data-platform') || '';
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/x-rsocials-platform', platform);
e.dataTransfer.setData('text/plain', platform);
chip.classList.add('dragging');
});
chip.addEventListener('dragend', () => chip.classList.remove('dragging'));
});
canvas.addEventListener('dragover', (e: DragEvent) => {
const types = e.dataTransfer?.types;
if (!types || !Array.from(types).some(t =>
t === 'application/x-rsocials-platform' || t === 'text/plain'
)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
canvas.classList.add('cp-canvas--drop-target');
});
canvas.addEventListener('dragleave', (e: DragEvent) => {
if (e.target === canvas) canvas.classList.remove('cp-canvas--drop-target');
});
canvas.addEventListener('drop', (e: DragEvent) => {
const platform = (e.dataTransfer?.getData('application/x-rsocials-platform')
|| e.dataTransfer?.getData('text/plain') || '').trim();
canvas.classList.remove('cp-canvas--drop-target');
if (!platform || !(ALL_PLATFORM_IDS as string[]).includes(platform)) return;
e.preventDefault();
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom - 120;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom - 60;
this.addPostForPlatform(platform as PostizPlatformId, canvasX, canvasY);
});
}
private attachListeners() {
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
const canvas = this.shadow.getElementById('cp-canvas');
if (!svg || !canvas) return;
this.attachPaletteListeners();
// View switcher buttons
this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => {

View File

@ -11,6 +11,7 @@
import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine';
import { getAccessToken } from '../../../shared/components/rstack-identity';
import type {
CampaignFlow,
CampaignPlannerNode,
@ -153,12 +154,24 @@ class FolkCampaignsDashboard extends HTMLElement {
this.loadFlows();
}
private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
const token = getAccessToken();
return {
...extra,
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
private async loadFlows() {
try {
const res = await fetch(`${this.basePath}api/campaign/flows`);
const res = await fetch(`${this.basePath}api/campaign/flows`, {
headers: this.authHeaders(),
});
if (res.ok) {
const data = await res.json();
this.flows = data.results || [];
} else {
console.warn('[CampaignsDashboard] loadFlows failed:', res.status);
}
} catch {
console.warn('[CampaignsDashboard] Failed to load flows');
@ -173,18 +186,29 @@ class FolkCampaignsDashboard extends HTMLElement {
startTour() { this._tour.start(); }
private async createFlow() {
if (!getAccessToken()) {
alert('Sign in to create a campaign.');
return;
}
try {
const res = await fetch(`${this.basePath}api/campaign/flows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this.authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: 'New Campaign' }),
});
if (res.ok) {
const flow = await res.json();
this.navigateToFlow(flow.id);
if (!res.ok) {
const msg = await res.text().catch(() => '');
console.error('[CampaignsDashboard] createFlow failed:', res.status, msg);
alert(res.status === 401 || res.status === 403
? 'You need write access to this space to create a campaign.'
: `Failed to create campaign (${res.status})`);
return;
}
} catch {
console.error('[CampaignsDashboard] Failed to create flow');
const flow = await res.json();
this.navigateToFlow(flow.id);
} catch (e) {
console.error('[CampaignsDashboard] Failed to create flow', e);
alert('Failed to create campaign — check your connection.');
}
}

View File

@ -0,0 +1,338 @@
/**
* Postiz platform specifications.
*
* Character limits, required fields, media rules and postType defaults per
* Postiz provider identifier. Drives the planner palette and inline editor:
* dropping a platform onto the canvas creates a `post` node pre-tagged with
* that platform's constraints.
*
* Limits reflect Postiz 5.x provider handlers as of 2026-04. Numbers match
* what Postiz's `settings.__type` handlers enforce server-side.
*/
export type PostizPlatformId =
| 'x'
| 'linkedin'
| 'facebook'
| 'instagram'
| 'threads'
| 'bluesky'
| 'mastodon'
| 'youtube'
| 'tiktok'
| 'reddit'
| 'pinterest'
| 'discord'
| 'slack';
export type PostizMediaKind = 'image' | 'video' | 'gif';
export type PostizPostType =
| 'text'
| 'thread'
| 'image'
| 'carousel'
| 'video'
| 'short'
| 'story'
| 'reel'
| 'link';
export interface PlatformSpec {
id: PostizPlatformId;
label: string;
icon: string;
color: string;
/** Main content character limit (per segment for threads). */
charLimit: number;
/** Optional title/subject limit — rendered as a separate input when set. */
titleLimit?: number;
titleRequired?: boolean;
/** Supports a native multi-segment thread via Postiz `value[]`. */
supportsThreads: boolean;
/** Media types the platform accepts (drives the inline editor hint). */
mediaTypes: PostizMediaKind[];
/** Maximum media items per post. 0 = media not allowed. */
maxMedia: number;
/** Media is required (cannot post text-only). */
mediaRequired?: boolean;
/** Default postType when dropped on the canvas. */
defaultPostType: PostizPostType;
/** Extra postTypes selectable for this platform. */
postTypes: PostizPostType[];
/** Hashtag count limit (soft warning when exceeded). 0 = no hashtags. */
hashtagLimit: number;
/** Human-readable note displayed under the content editor. */
note: string;
/** Extra per-platform input fields rendered in the inline editor. */
extraFields?: PlatformExtraField[];
}
export interface PlatformExtraField {
key: string;
label: string;
type: 'text' | 'select' | 'textarea' | 'checkbox';
placeholder?: string;
options?: string[];
required?: boolean;
maxLength?: number;
}
export const PLATFORM_SPECS: Record<PostizPlatformId, PlatformSpec> = {
x: {
id: 'x',
label: 'X (Twitter)',
icon: '𝕏',
color: '#000000',
charLimit: 280,
supportsThreads: true,
mediaTypes: ['image', 'video', 'gif'],
maxMedia: 4,
defaultPostType: 'text',
postTypes: ['text', 'thread', 'image', 'video'],
hashtagLimit: 3,
note: '280 chars/tweet. Threads supported. Up to 4 media per tweet.',
extraFields: [
{
key: 'who_can_reply_post', label: 'Who can reply',
type: 'select', options: ['everyone', 'following', 'mentionedUsers'],
},
],
},
linkedin: {
id: 'linkedin',
label: 'LinkedIn',
icon: 'in',
color: '#0A66C2',
charLimit: 3000,
supportsThreads: false,
mediaTypes: ['image', 'video'],
maxMedia: 9,
defaultPostType: 'text',
postTypes: ['text', 'image', 'carousel', 'video'],
hashtagLimit: 5,
note: '3000 chars. Up to 9 images or 1 video. 35 hashtags recommended.',
extraFields: [
{ key: 'post_to_company', label: 'Post as company page', type: 'checkbox' },
],
},
facebook: {
id: 'facebook',
label: 'Facebook',
icon: 'f',
color: '#1877F2',
charLimit: 63206,
supportsThreads: false,
mediaTypes: ['image', 'video'],
maxMedia: 10,
defaultPostType: 'text',
postTypes: ['text', 'image', 'video', 'link'],
hashtagLimit: 6,
note: 'Effectively unlimited text. First 250 chars show in preview.',
},
instagram: {
id: 'instagram',
label: 'Instagram',
icon: '📷',
color: '#E4405F',
charLimit: 2200,
supportsThreads: false,
mediaTypes: ['image', 'video'],
maxMedia: 10,
mediaRequired: true,
defaultPostType: 'image',
postTypes: ['image', 'carousel', 'reel', 'story'],
hashtagLimit: 30,
note: '2200-char caption. Media required. Up to 10 items in a carousel.',
extraFields: [
{
key: 'post_type', label: 'Post type',
type: 'select', options: ['post', 'reel', 'story'],
},
],
},
threads: {
id: 'threads',
label: 'Threads',
icon: '@',
color: '#000000',
charLimit: 500,
supportsThreads: true,
mediaTypes: ['image', 'video'],
maxMedia: 10,
defaultPostType: 'text',
postTypes: ['text', 'thread', 'image'],
hashtagLimit: 1,
note: '500 chars/post. Threads supported. One hashtag per post.',
},
bluesky: {
id: 'bluesky',
label: 'Bluesky',
icon: '🦋',
color: '#0085FF',
charLimit: 300,
supportsThreads: true,
mediaTypes: ['image', 'gif'],
maxMedia: 4,
defaultPostType: 'text',
postTypes: ['text', 'thread', 'image'],
hashtagLimit: 3,
note: '300 chars/post. Threads supported. Up to 4 images.',
},
mastodon: {
id: 'mastodon',
label: 'Mastodon',
icon: '🐘',
color: '#6364FF',
charLimit: 500,
supportsThreads: true,
mediaTypes: ['image', 'video', 'gif'],
maxMedia: 4,
defaultPostType: 'text',
postTypes: ['text', 'thread', 'image'],
hashtagLimit: 10,
note: '500 chars/toot (instance-dependent). Threads via replies.',
extraFields: [
{
key: 'visibility', label: 'Visibility',
type: 'select', options: ['public', 'unlisted', 'private', 'direct'],
},
{ key: 'spoiler_text', label: 'Content warning', type: 'text', placeholder: 'Optional CW' },
],
},
youtube: {
id: 'youtube',
label: 'YouTube',
icon: '▶️',
color: '#FF0000',
charLimit: 5000,
titleLimit: 100,
titleRequired: true,
supportsThreads: false,
mediaTypes: ['video'],
maxMedia: 1,
mediaRequired: true,
defaultPostType: 'video',
postTypes: ['video', 'short'],
hashtagLimit: 15,
note: 'Title ≤100 chars. Description ≤5000. Video required.',
extraFields: [
{
key: 'type', label: 'Visibility',
type: 'select', options: ['public', 'unlisted', 'private'],
},
{
key: 'category', label: 'Category ID',
type: 'select',
options: ['1', '2', '10', '15', '17', '19', '20', '22', '23', '24', '25', '26', '27', '28'],
},
],
},
tiktok: {
id: 'tiktok',
label: 'TikTok',
icon: '🎵',
color: '#000000',
charLimit: 2200,
titleLimit: 150,
supportsThreads: false,
mediaTypes: ['video'],
maxMedia: 1,
mediaRequired: true,
defaultPostType: 'video',
postTypes: ['video'],
hashtagLimit: 10,
note: 'Caption ≤2200 chars. Video required. Short-form vertical.',
extraFields: [
{
key: 'privacy_level', label: 'Privacy',
type: 'select', options: ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'],
},
{ key: 'disable_duet', label: 'Disable duets', type: 'checkbox' },
{ key: 'disable_comment', label: 'Disable comments', type: 'checkbox' },
],
},
reddit: {
id: 'reddit',
label: 'Reddit',
icon: '🔶',
color: '#FF4500',
charLimit: 40000,
titleLimit: 300,
titleRequired: true,
supportsThreads: false,
mediaTypes: ['image', 'video'],
maxMedia: 1,
defaultPostType: 'text',
postTypes: ['text', 'link', 'image', 'video'],
hashtagLimit: 0,
note: 'Title ≤300 chars. Hashtags unused. Subreddit required.',
extraFields: [
{ key: 'subreddit', label: 'Subreddit', type: 'text', required: true, placeholder: 'r/example' },
{
key: 'flair', label: 'Flair (optional)',
type: 'text', placeholder: 'Flair text',
},
],
},
pinterest: {
id: 'pinterest',
label: 'Pinterest',
icon: '📌',
color: '#E60023',
charLimit: 500,
titleLimit: 100,
supportsThreads: false,
mediaTypes: ['image', 'video'],
maxMedia: 1,
mediaRequired: true,
defaultPostType: 'image',
postTypes: ['image', 'video'],
hashtagLimit: 20,
note: 'Pin description ≤500. Title ≤100. Image or video required.',
extraFields: [
{ key: 'link', label: 'Destination URL', type: 'text', placeholder: 'https://' },
{ key: 'board', label: 'Board', type: 'text', placeholder: 'Board name' },
],
},
discord: {
id: 'discord',
label: 'Discord',
icon: '🎮',
color: '#5865F2',
charLimit: 2000,
supportsThreads: false,
mediaTypes: ['image', 'video', 'gif'],
maxMedia: 10,
defaultPostType: 'text',
postTypes: ['text', 'image'],
hashtagLimit: 0,
note: '2000 chars per message. Posts into a configured channel.',
},
slack: {
id: 'slack',
label: 'Slack',
icon: '#',
color: '#4A154B',
charLimit: 40000,
supportsThreads: false,
mediaTypes: ['image'],
maxMedia: 10,
defaultPostType: 'text',
postTypes: ['text'],
hashtagLimit: 0,
note: 'Long-form message into a configured channel.',
},
};
export const ALL_PLATFORM_IDS: PostizPlatformId[] = [
'x', 'linkedin', 'facebook', 'instagram', 'threads', 'bluesky',
'mastodon', 'youtube', 'tiktok', 'reddit', 'pinterest', 'discord', 'slack',
];
export function getPlatformSpec(platform: string): PlatformSpec | null {
return (PLATFORM_SPECS as Record<string, PlatformSpec>)[platform] || null;
}
export function charLimitFor(platform: string): number {
return getPlatformSpec(platform)?.charLimit ?? 2200;
}

View File

@ -2798,8 +2798,8 @@ routes.get("/campaign-flow", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css?v=2">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css?v=3">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js?v=3"></script>`,
}));
});

View File

@ -66,6 +66,12 @@ export interface PostNodeData {
scheduledAt: string;
status: 'draft' | 'scheduled' | 'published';
hashtags: string[];
/** Optional platform-required title (YouTube, TikTok, Reddit, Pinterest). */
title?: string;
/** Optional media attachments — URLs to uploaded files. */
mediaUrls?: string[];
/** Per-platform Postiz settings overrides (merged into settings.__type). */
platformSettings?: Record<string, unknown>;
postizPostId?: string;
postizIntegrationId?: string;
postizStatus?: 'queued' | 'published' | 'failed';