merge(dev): rsocials per-platform post fields + auth headers
CI/CD / deploy (push) Successful in 3m44s
Details
CI/CD / deploy (push) Successful in 3m44s
Details
This commit is contained in:
commit
0be5e15c22
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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 => {
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. 3–5 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;
|
||||
}
|
||||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue